mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17a058bb2a |
No files matched your search
@@ -1,4 +1,3 @@
|
||||
self-hosted-runner:
|
||||
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
|
||||
# windows-11-vs2026-arm is GitHub's Windows ARM64 image (2026-08), newer than actionlint's label list.
|
||||
labels: [horizon-game, ludovics-mac-mini, windows-11-vs2026-arm]
|
||||
labels: [horizon-game, ludovics-mac-mini]
|
||||
@@ -9,9 +9,9 @@ permissions:
|
||||
actions: write
|
||||
jobs:
|
||||
cancel:
|
||||
# One of our small runners (horizon-light), out of GitHub's pool: no checkout, no code of the pull request
|
||||
# One of playhorizon's small runners, out of GitHub's shared 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", "horizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Cancel the pull request's unfinished runs
|
||||
env:
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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@v7
|
||||
- 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@v7
|
||||
- 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@v7
|
||||
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@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
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
|
||||
+19
-121
@@ -27,24 +27,19 @@ jobs:
|
||||
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
|
||||
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
|
||||
#
|
||||
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, 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.
|
||||
# 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.
|
||||
verify:
|
||||
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' }}
|
||||
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' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v5
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
@@ -68,16 +63,6 @@ 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
|
||||
@@ -119,14 +104,10 @@ 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 != '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' }}
|
||||
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' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request change the launcher or a crate it builds?
|
||||
@@ -159,100 +140,17 @@ jobs:
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
|
||||
windows-arm64:
|
||||
name: Windows ARM64 game and launcher
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-11-vs2026-arm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Use every runner core for Cargo
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: windows-arm64-game
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: windows-arm64-launcher
|
||||
- name: Check LLVM assembler used by ring
|
||||
shell: pwsh
|
||||
run: |
|
||||
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
|
||||
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
|
||||
$env:PATH = "$llvmBin;$env:PATH"
|
||||
"PATH=$env:PATH" >> $env:GITHUB_ENV
|
||||
}
|
||||
$clang = Get-Command clang -ErrorAction Stop
|
||||
Write-Output "Using $($clang.Source)"
|
||||
& $clang.Source --version
|
||||
- name: Build the game for native Windows ARM64
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Check the game's PE machine
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Check game DLL imports
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Start every game executable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
|
||||
$output = & $client --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
|
||||
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
|
||||
}
|
||||
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
|
||||
Write-Output ($output -join "`n")
|
||||
}
|
||||
- name: Test and build the native Windows ARM64 launcher
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
|
||||
cargo build --release --locked --target aarch64-pc-windows-msvc
|
||||
- name: Check the launcher's PE machine
|
||||
working-directory: launcher
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# 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 our small runners (horizon-light, docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
|
||||
# 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' }}
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -287,13 +185,13 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- uses: actions/setup-java@v6
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
@@ -635,11 +533,11 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-java@v6
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
# 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' }}
|
||||
# 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' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
@@ -56,14 +56,14 @@ jobs:
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
# 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' }}
|
||||
# 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' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, Windows x64
|
||||
# and ARM64 on GitHub, and macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
@@ -83,10 +83,6 @@ jobs:
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -103,18 +99,8 @@ jobs:
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
pe_arch: x64
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
dir: horizon-windows-arm64
|
||||
asset: horizon-windows-arm64.zip
|
||||
exe: .exe
|
||||
pe_arch: arm64
|
||||
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
@@ -142,7 +128,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -170,12 +156,6 @@ jobs:
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
python tools/check_pe_arch.py "${{ matrix.pe_arch }}" \
|
||||
"$BIN/horizon-client${{ matrix.exe }}" "$BIN/horizon-ctl${{ matrix.exe }}" \
|
||||
"$BIN/horizon-prepare${{ matrix.exe }}" "$BIN/horizon-server${{ matrix.exe }}"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
@@ -208,7 +188,7 @@ jobs:
|
||||
- name: Baseline x86-64 CPUs only
|
||||
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
|
||||
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
|
||||
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
|
||||
if: runner.os != 'macOS'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup component add llvm-tools
|
||||
@@ -259,14 +239,14 @@ jobs:
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
@@ -297,7 +277,7 @@ jobs:
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
@@ -319,7 +299,7 @@ jobs:
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: launcher
|
||||
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
|
||||
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
|
||||
# push to main that changes the launcher or a crate it builds in (the paths below),
|
||||
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
|
||||
# for the game.
|
||||
@@ -15,9 +15,8 @@ name: launcher
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# Windows ships each architecture's launcher in its ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe or horizon-launcher-setup-arm64.exe, which opens the per-user installer instead of Home
|
||||
# (launcher/src/installer.rs).
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -74,7 +73,7 @@ jobs:
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
@@ -103,10 +102,6 @@ jobs:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -130,20 +125,9 @@ jobs:
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: x64
|
||||
setup_asset: horizon-launcher-setup.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
asset: horizon-launcher-windows-arm64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: arm64
|
||||
setup_asset: horizon-launcher-setup-arm64.exe
|
||||
# The C runtime linked in, so the standalone ARM64 launcher needs no redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the app and then the .dmg can take a few minutes each.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -166,7 +150,7 @@ jobs:
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
@@ -198,9 +182,6 @@ jobs:
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
@@ -231,8 +212,7 @@ jobs:
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step. The Windows ARM64 launcher always has
|
||||
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
@@ -256,11 +236,6 @@ jobs:
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
if ! python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$client"; then
|
||||
# A Nightly without an ARM64 build: the ARM64 launcher installs the x64 one (backend.rs, GAME_FALLBACK).
|
||||
[[ "${{ matrix.pe_arch }}" == arm64 ]] && python ../tools/check_pe_arch.py x64 "$client" || exit 1
|
||||
echo "::warning::the latest game has no ARM64 build: the launcher installed the x64 one"
|
||||
fi
|
||||
python ../tools/check_imports.py "$client"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
@@ -320,8 +295,8 @@ jobs:
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" "out/${{ matrix.setup_asset }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: launcher/out/*
|
||||
@@ -339,12 +314,12 @@ jobs:
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-launcher-*
|
||||
@@ -356,13 +331,13 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
@@ -376,7 +351,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
@@ -386,7 +361,7 @@ jobs:
|
||||
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -398,13 +373,6 @@ jobs:
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
# Never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- name: The Windows ARM64 launcher, if it built
|
||||
# Its build may fail without holding the release (continue-on-error): the release then has no ARM64
|
||||
# files, and ARM64 launchers stay on their version until one has (backend::launcher_update_from).
|
||||
run: |
|
||||
for file in horizon-launcher-windows-arm64.zip horizon-launcher-setup-arm64.exe; do
|
||||
[[ -f "dist/$file" ]] || echo "::warning::$file is missing: this release goes out without it"
|
||||
done
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -419,7 +387,7 @@ jobs:
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-release-notes
|
||||
path: notes/
|
||||
|
||||
@@ -15,23 +15,17 @@ 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:
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- 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,11 +21,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -52,8 +52,4 @@ jobs:
|
||||
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
git push --force-with-lease origin "$branch"
|
||||
# The repo doesn't let GitHub Actions open pull requests (that setting would also let it approve them):
|
||||
# the branch is pushed, and a maintainer opens the PR from it.
|
||||
if ! gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'; then
|
||||
echo "::warning::Stamped fragments are on $branch; open a pull request from it (GitHub Actions may not open one here)."
|
||||
fi
|
||||
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -124,14 +124,14 @@ jobs:
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
path: nightly-notes.md
|
||||
@@ -155,12 +155,12 @@ jobs:
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
@@ -171,20 +171,16 @@ jobs:
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
|
||||
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
|
||||
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
@@ -199,10 +195,10 @@ jobs:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -234,20 +230,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
|
||||
arm64=()
|
||||
if [[ -f dist/horizon-windows-arm64.zip ]]; then
|
||||
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
|
||||
else
|
||||
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
|
||||
fi
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"${arm64[@]}" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
@@ -265,8 +253,7 @@ 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'
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
issues: write # answers in the thread
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.when.outputs.run == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
|
||||
@@ -19,15 +19,15 @@ jobs:
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
|
||||
# One of playhorizon's small runners, out of GitHub's shared 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", "horizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,24 +22,17 @@ env:
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
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
|
||||
@@ -53,15 +46,11 @@ 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.
|
||||
target=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/target}
|
||||
(cd ../base && CARGO_TARGET_DIR="$target" cargo run --profile fast -p horizon-client-render \
|
||||
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/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
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
|
||||
# main into it.
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
|
||||
@@ -16,15 +16,15 @@ 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 our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
|
||||
# One of playhorizon's small runners, out of GitHub's shared 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", "horizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
|
||||
@@ -15,23 +15,17 @@ 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:
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-telemetry-server
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- 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,15 +21,14 @@ jobs:
|
||||
startsWith(github.event.comment.body, '/reopen') ||
|
||||
startsWith(github.event.comment.body, '/label') ||
|
||||
startsWith(github.event.comment.body, '/unlabel'))
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
+33
-34
@@ -5,8 +5,7 @@ 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
|
||||
@@ -15,25 +14,23 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
|
||||
|
||||
## Build it
|
||||
|
||||
- **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:**
|
||||
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).
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
The launcher is 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`.
|
||||
@@ -44,10 +41,11 @@ 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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
|
||||
@@ -56,23 +54,24 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -88,8 +87,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
|
||||
@@ -124,8 +123,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)).
|
||||
|
||||
Generated
-14
@@ -1429,7 +1429,6 @@ dependencies = [
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"mio",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -1446,7 +1445,6 @@ dependencies = [
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"xxhash-rust",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -2317,18 +2315,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -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-0%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%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: 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.
|
||||
**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.
|
||||
|
||||
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: the 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: 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.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
|
||||
+2
-2
@@ -868,7 +868,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -878,7 +878,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ 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.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
|
||||
|
||||
python3 tools/bench.py --server horizon --exe target/release/horizon-server --bots 50
|
||||
python3 tools/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
|
||||
python3 tools/bench.py --server pumpkin --exe ./pumpkin --bots 50
|
||||
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
|
||||
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
|
||||
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
|
||||
|
||||
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
|
||||
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
|
||||
@@ -39,11 +39,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
from . import lab
|
||||
except ImportError:
|
||||
import lab
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
SERVERS = ('horizon', 'vanilla', 'pumpkin')
|
||||
@@ -212,7 +207,8 @@ class Server:
|
||||
self.lines = []
|
||||
self.lock = threading.Lock()
|
||||
self.log = log_path.open('w', encoding='utf-8')
|
||||
env = lab.game_env(HORIZON_HOME=directory / 'horizon-home')
|
||||
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
|
||||
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding='utf-8', errors='replace', env=env)
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 341 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 341 KiB |
@@ -1,870 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:00:15Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.408,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 66658.183,
|
||||
"stdev": 61839.502,
|
||||
"min": 1827.42,
|
||||
"max": 124993.694,
|
||||
"ci95": 153630.237
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 74754.411,
|
||||
"stdev": 59189.587,
|
||||
"min": 13600.651,
|
||||
"max": 131761.773,
|
||||
"ci95": 147046.952
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5571.499,
|
||||
"stdev": 1900.32,
|
||||
"min": 3378.38,
|
||||
"max": 6730.394,
|
||||
"ci95": 4721.037
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15287.0,
|
||||
"stdev": 527.501,
|
||||
"min": 14788.0,
|
||||
"max": 15839.0,
|
||||
"ci95": 1310.49
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2958.557,
|
||||
"stdev": 376.543,
|
||||
"min": 2526.453,
|
||||
"max": 3216.434,
|
||||
"ci95": 935.46
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 368.457,
|
||||
"stdev": 90.668,
|
||||
"min": 300.631,
|
||||
"max": 471.438,
|
||||
"ci95": 225.25
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.04,
|
||||
"stdev": 40.506,
|
||||
"min": 46.124,
|
||||
"max": 124.796,
|
||||
"ci95": 100.63
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 581.0,
|
||||
"stdev": 48.0,
|
||||
"min": 533.0,
|
||||
"max": 629.0,
|
||||
"ci95": 119.248
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1277.531,
|
||||
"stdev": 25.546,
|
||||
"min": 1253.879,
|
||||
"max": 1304.623,
|
||||
"ci95": 63.466
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.529,
|
||||
"stdev": 0.127,
|
||||
"min": 0.396,
|
||||
"max": 0.649,
|
||||
"ci95": 0.315
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.185,
|
||||
"stdev": 0.034,
|
||||
"min": 0.162,
|
||||
"max": 0.224,
|
||||
"ci95": 0.085
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3318.3,
|
||||
"stdev": 911.608,
|
||||
"min": 2266.627,
|
||||
"max": 3883.075,
|
||||
"ci95": 2264.743
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16565.333,
|
||||
"stdev": 172.755,
|
||||
"min": 16367.0,
|
||||
"max": 16683.0,
|
||||
"ci95": 429.182
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2333.626,
|
||||
"stdev": 569.124,
|
||||
"min": 1745.365,
|
||||
"max": 2881.457,
|
||||
"ci95": 1413.897
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.615,
|
||||
"stdev": 185.666,
|
||||
"min": 208.133,
|
||||
"max": 549.487,
|
||||
"ci95": 461.257
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 160.734,
|
||||
"stdev": 127.586,
|
||||
"min": 39.777,
|
||||
"max": 294.05,
|
||||
"ci95": 316.967
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 666.333,
|
||||
"stdev": 24.44,
|
||||
"min": 645.0,
|
||||
"max": 693.0,
|
||||
"ci95": 60.718
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1346.816,
|
||||
"stdev": 42.279,
|
||||
"min": 1305.547,
|
||||
"max": 1390.037,
|
||||
"ci95": 105.035
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.669,
|
||||
"stdev": 0.084,
|
||||
"min": 0.598,
|
||||
"max": 0.761,
|
||||
"ci95": 0.208
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.222,
|
||||
"stdev": 0.032,
|
||||
"min": 0.197,
|
||||
"max": 0.258,
|
||||
"ci95": 0.079
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2463.434,
|
||||
"stdev": 1056.0,
|
||||
"min": 1527.516,
|
||||
"max": 3608.283,
|
||||
"ci95": 2623.462
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14226.667,
|
||||
"stdev": 307.077,
|
||||
"min": 14038.0,
|
||||
"max": 14581.0,
|
||||
"ci95": 762.883
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2192.874,
|
||||
"stdev": 1320.904,
|
||||
"min": 673.829,
|
||||
"max": 3071.415,
|
||||
"ci95": 3281.573
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 345.406,
|
||||
"stdev": 294.904,
|
||||
"min": 8.977,
|
||||
"max": 559.232,
|
||||
"ci95": 732.642
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 172.573,
|
||||
"stdev": 149.386,
|
||||
"min": 1.038,
|
||||
"max": 274.089,
|
||||
"ci95": 371.125
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 629.0,
|
||||
"stdev": 32.0,
|
||||
"min": 597.0,
|
||||
"max": 661.0,
|
||||
"ci95": 79.499
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1352.223,
|
||||
"stdev": 51.951,
|
||||
"min": 1301.492,
|
||||
"max": 1405.314,
|
||||
"ci95": 129.065
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.512,
|
||||
"stdev": 0.099,
|
||||
"min": 0.406,
|
||||
"max": 0.603,
|
||||
"ci95": 0.246
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.178,
|
||||
"stdev": 0.038,
|
||||
"min": 0.135,
|
||||
"max": 0.206,
|
||||
"ci95": 0.094
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6260.675,
|
||||
"stdev": 3077.348,
|
||||
"min": 2976.342,
|
||||
"max": 9077.55,
|
||||
"ci95": 7645.173
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16015.333,
|
||||
"stdev": 209.872,
|
||||
"min": 15826.0,
|
||||
"max": 16241.0,
|
||||
"ci95": 521.393
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1412.095,
|
||||
"stdev": 962.145,
|
||||
"min": 714.773,
|
||||
"max": 2509.775,
|
||||
"ci95": 2390.293
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 182.479,
|
||||
"stdev": 177.563,
|
||||
"min": 13.364,
|
||||
"max": 367.429,
|
||||
"ci95": 441.126
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 88.451,
|
||||
"stdev": 87.578,
|
||||
"min": 1.526,
|
||||
"max": 176.667,
|
||||
"ci95": 217.572
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 799.667,
|
||||
"stdev": 18.475,
|
||||
"min": 789.0,
|
||||
"max": 821.0,
|
||||
"ci95": 45.899
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1491.572,
|
||||
"stdev": 36.719,
|
||||
"min": 1459.775,
|
||||
"max": 1531.76,
|
||||
"ci95": 91.221
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.697,
|
||||
"stdev": 0.155,
|
||||
"min": 0.602,
|
||||
"max": 0.876,
|
||||
"ci95": 0.386
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.325,
|
||||
"stdev": 0.115,
|
||||
"min": 0.239,
|
||||
"max": 0.456,
|
||||
"ci95": 0.286
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1545.896,
|
||||
"stdev": 30.591,
|
||||
"min": 1524.965,
|
||||
"max": 1581.004,
|
||||
"ci95": 75.999
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1348.389,
|
||||
"stdev": 52.5,
|
||||
"min": 1296.172,
|
||||
"max": 1401.168,
|
||||
"ci95": 130.428
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 37594,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3132.783,
|
||||
"low_1pct_fps": 471.438,
|
||||
"low_01pct_fps": 124.796,
|
||||
"p50_ms": 0.271,
|
||||
"p95_ms": 0.494,
|
||||
"p99_ms": 1.127,
|
||||
"p999_ms": 2.15,
|
||||
"max_ms": 155.204,
|
||||
"cpu_p50_ms": 0.168,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
37151,
|
||||
394,
|
||||
30,
|
||||
8,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 38598,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3216.434,
|
||||
"low_1pct_fps": 300.631,
|
||||
"low_01pct_fps": 46.124,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 1.034,
|
||||
"p999_ms": 1.941,
|
||||
"max_ms": 493.993,
|
||||
"cpu_p50_ms": 0.162,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
38177,
|
||||
387,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 30318,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2526.453,
|
||||
"low_1pct_fps": 333.302,
|
||||
"low_01pct_fps": 102.199,
|
||||
"p50_ms": 0.305,
|
||||
"p95_ms": 0.757,
|
||||
"p99_ms": 1.69,
|
||||
"p999_ms": 3.539,
|
||||
"max_ms": 170.856,
|
||||
"cpu_p50_ms": 0.224,
|
||||
"gpu_p50_ms": 0.106,
|
||||
"histogram": [
|
||||
29368,
|
||||
759,
|
||||
139,
|
||||
35,
|
||||
11,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 34578,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2881.457,
|
||||
"low_1pct_fps": 549.487,
|
||||
"low_01pct_fps": 294.05,
|
||||
"p50_ms": 0.32,
|
||||
"p95_ms": 0.483,
|
||||
"p99_ms": 1.213,
|
||||
"p999_ms": 2.5,
|
||||
"max_ms": 5.358,
|
||||
"cpu_p50_ms": 0.197,
|
||||
"gpu_p50_ms": 0.154,
|
||||
"histogram": [
|
||||
34119,
|
||||
379,
|
||||
64,
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28489,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2374.055,
|
||||
"low_1pct_fps": 208.133,
|
||||
"low_01pct_fps": 39.777,
|
||||
"p50_ms": 0.313,
|
||||
"p95_ms": 0.937,
|
||||
"p99_ms": 1.863,
|
||||
"p999_ms": 4.318,
|
||||
"max_ms": 571.48,
|
||||
"cpu_p50_ms": 0.211,
|
||||
"gpu_p50_ms": 0.151,
|
||||
"histogram": [
|
||||
27193,
|
||||
1071,
|
||||
140,
|
||||
51,
|
||||
19,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20945,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1745.365,
|
||||
"low_1pct_fps": 252.226,
|
||||
"low_01pct_fps": 148.375,
|
||||
"p50_ms": 0.343,
|
||||
"p95_ms": 1.505,
|
||||
"p99_ms": 2.758,
|
||||
"p999_ms": 5.341,
|
||||
"max_ms": 11.642,
|
||||
"cpu_p50_ms": 0.258,
|
||||
"gpu_p50_ms": 0.162,
|
||||
"histogram": [
|
||||
18145,
|
||||
2293,
|
||||
337,
|
||||
106,
|
||||
38,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 36856,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3071.415,
|
||||
"low_1pct_fps": 559.232,
|
||||
"low_01pct_fps": 274.089,
|
||||
"p50_ms": 0.286,
|
||||
"p95_ms": 0.492,
|
||||
"p99_ms": 1.179,
|
||||
"p999_ms": 2.711,
|
||||
"max_ms": 6.718,
|
||||
"cpu_p50_ms": 0.135,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
36366,
|
||||
405,
|
||||
60,
|
||||
13,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 34001,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2833.377,
|
||||
"low_1pct_fps": 468.008,
|
||||
"low_01pct_fps": 242.591,
|
||||
"p50_ms": 0.295,
|
||||
"p95_ms": 0.589,
|
||||
"p99_ms": 1.41,
|
||||
"p999_ms": 3.048,
|
||||
"max_ms": 7.7,
|
||||
"cpu_p50_ms": 0.206,
|
||||
"gpu_p50_ms": 0.126,
|
||||
"histogram": [
|
||||
33314,
|
||||
544,
|
||||
106,
|
||||
23,
|
||||
10,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 8086,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 673.829,
|
||||
"low_1pct_fps": 8.977,
|
||||
"low_01pct_fps": 1.038,
|
||||
"p50_ms": 0.302,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 2.495,
|
||||
"p999_ms": 15.716,
|
||||
"max_ms": 4849.364,
|
||||
"cpu_p50_ms": 0.194,
|
||||
"gpu_p50_ms": 0.128,
|
||||
"histogram": [
|
||||
7808,
|
||||
166,
|
||||
50,
|
||||
26,
|
||||
8,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12141,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1011.738,
|
||||
"low_1pct_fps": 166.643,
|
||||
"low_01pct_fps": 87.16,
|
||||
"p50_ms": 0.641,
|
||||
"p95_ms": 2.642,
|
||||
"p99_ms": 4.468,
|
||||
"p999_ms": 7.821,
|
||||
"max_ms": 22.536,
|
||||
"cpu_p50_ms": 0.456,
|
||||
"gpu_p50_ms": 0.138,
|
||||
"histogram": [
|
||||
7840,
|
||||
2996,
|
||||
866,
|
||||
278,
|
||||
87,
|
||||
38,
|
||||
16,
|
||||
11,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 30118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2509.775,
|
||||
"low_1pct_fps": 367.429,
|
||||
"low_01pct_fps": 176.667,
|
||||
"p50_ms": 0.316,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.931,
|
||||
"p999_ms": 4.2,
|
||||
"max_ms": 14.633,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
29101,
|
||||
775,
|
||||
173,
|
||||
38,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8577,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 714.773,
|
||||
"low_1pct_fps": 13.364,
|
||||
"low_01pct_fps": 1.526,
|
||||
"p50_ms": 0.416,
|
||||
"p95_ms": 2.257,
|
||||
"p99_ms": 4.36,
|
||||
"p999_ms": 212.217,
|
||||
"max_ms": 1051.428,
|
||||
"cpu_p50_ms": 0.28,
|
||||
"gpu_p50_ms": 0.132,
|
||||
"histogram": [
|
||||
7017,
|
||||
978,
|
||||
377,
|
||||
111,
|
||||
39,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T14:47:19Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 29.665,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 45386.16,
|
||||
"stdev": 38970.921,
|
||||
"min": 1077.276,
|
||||
"max": 74343.156,
|
||||
"ci95": 96816.948
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 86585.608,
|
||||
"stdev": 71331.593,
|
||||
"min": 11201.581,
|
||||
"max": 153020.143,
|
||||
"ci95": 177211.803
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11082.587,
|
||||
"stdev": 5351.291,
|
||||
"min": 7620.454,
|
||||
"max": 17246.096,
|
||||
"ci95": 13294.417
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 48440.333,
|
||||
"stdev": 656.16,
|
||||
"min": 48060.0,
|
||||
"max": 49198.0,
|
||||
"ci95": 1630.124
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 853.775,
|
||||
"stdev": 345.646,
|
||||
"min": 524.913,
|
||||
"max": 1214.059,
|
||||
"ci95": 858.701
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 130.235,
|
||||
"stdev": 117.39,
|
||||
"min": 9.024,
|
||||
"max": 243.388,
|
||||
"ci95": 291.635
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 70.65,
|
||||
"stdev": 68.628,
|
||||
"min": 1.125,
|
||||
"max": 138.345,
|
||||
"ci95": 170.496
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2202.333,
|
||||
"stdev": 33.307,
|
||||
"min": 2165.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 82.745
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1652.647,
|
||||
"stdev": 73.452,
|
||||
"min": 1568.063,
|
||||
"max": 1700.344,
|
||||
"ci95": 182.478
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.493,
|
||||
"stdev": 0.169,
|
||||
"min": 0.319,
|
||||
"max": 0.657,
|
||||
"ci95": 0.42
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.692,
|
||||
"stdev": 0.207,
|
||||
"min": 0.531,
|
||||
"max": 0.925,
|
||||
"ci95": 0.513
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15019.921,
|
||||
"stdev": 12855.4,
|
||||
"min": 5613.942,
|
||||
"max": 29668.11,
|
||||
"ci95": 31937.162
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 80224.667,
|
||||
"stdev": 678.709,
|
||||
"min": 79626.0,
|
||||
"max": 80962.0,
|
||||
"ci95": 1686.142
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 898.208,
|
||||
"stdev": 446.3,
|
||||
"min": 382.944,
|
||||
"max": 1163.61,
|
||||
"ci95": 1108.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.513,
|
||||
"stdev": 107.096,
|
||||
"min": 54.933,
|
||||
"max": 244.255,
|
||||
"ci95": 266.064
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 103.369,
|
||||
"stdev": 78.403,
|
||||
"min": 12.974,
|
||||
"max": 152.87,
|
||||
"ci95": 194.779
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2201.667,
|
||||
"stdev": 34.429,
|
||||
"min": 2163.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 85.532
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1809.063,
|
||||
"stdev": 93.103,
|
||||
"min": 1707.98,
|
||||
"max": 1891.305,
|
||||
"ci95": 231.3
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.585,
|
||||
"stdev": 0.147,
|
||||
"min": 0.493,
|
||||
"max": 0.754,
|
||||
"ci95": 0.365
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.989,
|
||||
"stdev": 0.725,
|
||||
"min": 0.57,
|
||||
"max": 1.826,
|
||||
"ci95": 1.802
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12399.047,
|
||||
"stdev": 15052.073,
|
||||
"min": 3174.115,
|
||||
"max": 29768.486,
|
||||
"ci95": 37394.441
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 64737.0,
|
||||
"stdev": 16149.666,
|
||||
"min": 46089.0,
|
||||
"max": 74089.0,
|
||||
"ci95": 40121.232
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 735.22,
|
||||
"stdev": 601.75,
|
||||
"min": 108.108,
|
||||
"max": 1307.912,
|
||||
"ci95": 1494.952
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 134.654,
|
||||
"stdev": 147.391,
|
||||
"min": 1.252,
|
||||
"max": 292.881,
|
||||
"ci95": 366.169
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 62.062,
|
||||
"stdev": 66.294,
|
||||
"min": 0.202,
|
||||
"max": 132.042,
|
||||
"ci95": 164.697
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1942.333,
|
||||
"stdev": 251.725,
|
||||
"min": 1657.0,
|
||||
"max": 2133.0,
|
||||
"ci95": 625.369
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1812.661,
|
||||
"stdev": 102.065,
|
||||
"min": 1697.912,
|
||||
"max": 1893.313,
|
||||
"ci95": 253.564
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.421,
|
||||
"stdev": 0.227,
|
||||
"min": 0.193,
|
||||
"max": 0.646,
|
||||
"ci95": 0.563
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.513,
|
||||
"stdev": 0.194,
|
||||
"min": 0.303,
|
||||
"max": 0.686,
|
||||
"ci95": 0.483
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 31815.539,
|
||||
"stdev": 36758.245,
|
||||
"min": 9582.166,
|
||||
"max": 74243.988,
|
||||
"ci95": 91319.913
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 90876.333,
|
||||
"stdev": 1525.588,
|
||||
"min": 89793.0,
|
||||
"max": 92621.0,
|
||||
"ci95": 3790.075
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 712.657,
|
||||
"stdev": 252.815,
|
||||
"min": 450.565,
|
||||
"max": 955.041,
|
||||
"ci95": 628.077
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.93,
|
||||
"stdev": 85.922,
|
||||
"min": 24.389,
|
||||
"max": 193.34,
|
||||
"ci95": 213.46
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.407,
|
||||
"stdev": 65.468,
|
||||
"min": 3.577,
|
||||
"max": 133.895,
|
||||
"ci95": 162.646
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2783.667,
|
||||
"stdev": 48.881,
|
||||
"min": 2741.0,
|
||||
"max": 2837.0,
|
||||
"ci95": 121.436
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1931.359,
|
||||
"stdev": 106.681,
|
||||
"min": 1808.184,
|
||||
"max": 1994.242,
|
||||
"ci95": 265.031
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.03,
|
||||
"stdev": 0.685,
|
||||
"min": 0.564,
|
||||
"max": 1.817,
|
||||
"ci95": 1.702
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.025,
|
||||
"stdev": 0.315,
|
||||
"min": 0.765,
|
||||
"max": 1.376,
|
||||
"ci95": 0.783
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2051.333,
|
||||
"stdev": 147.151,
|
||||
"min": 1884.176,
|
||||
"max": 2161.316,
|
||||
"ci95": 365.573
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1810.92,
|
||||
"stdev": 94.623,
|
||||
"min": 1707.342,
|
||||
"max": 1892.828,
|
||||
"ci95": 235.075
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 6301,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 524.913,
|
||||
"low_1pct_fps": 9.024,
|
||||
"low_01pct_fps": 1.125,
|
||||
"p50_ms": 0.555,
|
||||
"p95_ms": 2.072,
|
||||
"p99_ms": 4.067,
|
||||
"p999_ms": 282.455,
|
||||
"max_ms": 3525.74,
|
||||
"cpu_p50_ms": 0.531,
|
||||
"gpu_p50_ms": 0.218,
|
||||
"histogram": [
|
||||
4878,
|
||||
1060,
|
||||
217,
|
||||
86,
|
||||
19,
|
||||
15,
|
||||
6,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 9869,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 822.353,
|
||||
"low_1pct_fps": 138.293,
|
||||
"low_01pct_fps": 72.479,
|
||||
"p50_ms": 0.976,
|
||||
"p95_ms": 2.871,
|
||||
"p99_ms": 5.065,
|
||||
"p999_ms": 9.942,
|
||||
"max_ms": 23.337,
|
||||
"cpu_p50_ms": 0.925,
|
||||
"gpu_p50_ms": 0.245,
|
||||
"histogram": [
|
||||
5262,
|
||||
3404,
|
||||
771,
|
||||
248,
|
||||
81,
|
||||
49,
|
||||
23,
|
||||
11,
|
||||
12,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14570,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1214.059,
|
||||
"low_1pct_fps": 243.388,
|
||||
"low_01pct_fps": 138.345,
|
||||
"p50_ms": 0.655,
|
||||
"p95_ms": 1.486,
|
||||
"p99_ms": 2.883,
|
||||
"p999_ms": 5.366,
|
||||
"max_ms": 26.145,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 0.239,
|
||||
"histogram": [
|
||||
11502,
|
||||
2683,
|
||||
257,
|
||||
77,
|
||||
25,
|
||||
23,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 4595,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 382.944,
|
||||
"low_1pct_fps": 54.933,
|
||||
"low_01pct_fps": 12.974,
|
||||
"p50_ms": 2.269,
|
||||
"p95_ms": 5.193,
|
||||
"p99_ms": 8.719,
|
||||
"p999_ms": 21.933,
|
||||
"max_ms": 243.247,
|
||||
"cpu_p50_ms": 1.826,
|
||||
"gpu_p50_ms": 0.502,
|
||||
"histogram": [
|
||||
273,
|
||||
1654,
|
||||
1395,
|
||||
836,
|
||||
172,
|
||||
122,
|
||||
52,
|
||||
38,
|
||||
26,
|
||||
10,
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 13964,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1163.61,
|
||||
"low_1pct_fps": 244.255,
|
||||
"low_01pct_fps": 152.87,
|
||||
"p50_ms": 0.682,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.877,
|
||||
"p999_ms": 5.801,
|
||||
"max_ms": 7.977,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.467,
|
||||
"histogram": [
|
||||
11067,
|
||||
2532,
|
||||
235,
|
||||
82,
|
||||
23,
|
||||
16,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 13777,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1148.071,
|
||||
"low_1pct_fps": 236.351,
|
||||
"low_01pct_fps": 144.264,
|
||||
"p50_ms": 0.691,
|
||||
"p95_ms": 1.637,
|
||||
"p99_ms": 3.131,
|
||||
"p999_ms": 5.794,
|
||||
"max_ms": 9.301,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.465,
|
||||
"histogram": [
|
||||
10826,
|
||||
2511,
|
||||
284,
|
||||
106,
|
||||
23,
|
||||
16,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 1224,
|
||||
"seconds": 11.322,
|
||||
"avg_fps": 108.108,
|
||||
"low_1pct_fps": 1.252,
|
||||
"low_01pct_fps": 0.202,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 3.015,
|
||||
"p99_ms": 7.359,
|
||||
"p999_ms": 2880.23,
|
||||
"max_ms": 7005.436,
|
||||
"cpu_p50_ms": 0.303,
|
||||
"gpu_p50_ms": 0.258,
|
||||
"histogram": [
|
||||
1081,
|
||||
31,
|
||||
49,
|
||||
10,
|
||||
3,
|
||||
7,
|
||||
23,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15695,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1307.912,
|
||||
"low_1pct_fps": 292.881,
|
||||
"low_01pct_fps": 132.042,
|
||||
"p50_ms": 0.624,
|
||||
"p95_ms": 1.219,
|
||||
"p99_ms": 2.173,
|
||||
"p999_ms": 4.841,
|
||||
"max_ms": 16.764,
|
||||
"cpu_p50_ms": 0.551,
|
||||
"gpu_p50_ms": 0.401,
|
||||
"histogram": [
|
||||
13558,
|
||||
1932,
|
||||
140,
|
||||
37,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9476,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 789.641,
|
||||
"low_1pct_fps": 109.83,
|
||||
"low_01pct_fps": 53.941,
|
||||
"p50_ms": 0.802,
|
||||
"p95_ms": 3.406,
|
||||
"p99_ms": 5.844,
|
||||
"p999_ms": 14.977,
|
||||
"max_ms": 22.481,
|
||||
"cpu_p50_ms": 0.686,
|
||||
"gpu_p50_ms": 0.403,
|
||||
"histogram": [
|
||||
5819,
|
||||
2151,
|
||||
787,
|
||||
455,
|
||||
112,
|
||||
65,
|
||||
36,
|
||||
18,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 5352,
|
||||
"seconds": 11.878,
|
||||
"avg_fps": 450.565,
|
||||
"low_1pct_fps": 24.389,
|
||||
"low_01pct_fps": 3.577,
|
||||
"p50_ms": 1.431,
|
||||
"p95_ms": 4.548,
|
||||
"p99_ms": 7.601,
|
||||
"p999_ms": 96.086,
|
||||
"max_ms": 714.027,
|
||||
"cpu_p50_ms": 1.376,
|
||||
"gpu_p50_ms": 0.398,
|
||||
"histogram": [
|
||||
1412,
|
||||
2323,
|
||||
811,
|
||||
460,
|
||||
138,
|
||||
84,
|
||||
49,
|
||||
34,
|
||||
13,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8789,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 732.365,
|
||||
"low_1pct_fps": 136.06,
|
||||
"low_01pct_fps": 79.749,
|
||||
"p50_ms": 0.972,
|
||||
"p95_ms": 3.329,
|
||||
"p99_ms": 5.68,
|
||||
"p999_ms": 9.7,
|
||||
"max_ms": 18.289,
|
||||
"cpu_p50_ms": 0.935,
|
||||
"gpu_p50_ms": 0.383,
|
||||
"histogram": [
|
||||
4529,
|
||||
3007,
|
||||
715,
|
||||
287,
|
||||
120,
|
||||
61,
|
||||
37,
|
||||
16,
|
||||
12,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 11461,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 955.041,
|
||||
"low_1pct_fps": 193.34,
|
||||
"low_01pct_fps": 133.895,
|
||||
"p50_ms": 0.787,
|
||||
"p95_ms": 2.026,
|
||||
"p99_ms": 3.953,
|
||||
"p999_ms": 6.511,
|
||||
"max_ms": 9.761,
|
||||
"cpu_p50_ms": 0.765,
|
||||
"gpu_p50_ms": 0.379,
|
||||
"histogram": [
|
||||
7754,
|
||||
3114,
|
||||
328,
|
||||
165,
|
||||
46,
|
||||
33,
|
||||
14,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:44:40Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.39,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1516.155,
|
||||
"stdev": 34.227,
|
||||
"min": 1476.782,
|
||||
"max": 1538.813,
|
||||
"ci95": 85.032
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 4080.9,
|
||||
"stdev": 748.712,
|
||||
"min": 3273.516,
|
||||
"max": 4752.301,
|
||||
"ci95": 1860.054
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3363.749,
|
||||
"stdev": 224.552,
|
||||
"min": 3105.622,
|
||||
"max": 3514.059,
|
||||
"ci95": 557.864
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15473.667,
|
||||
"stdev": 339.602,
|
||||
"min": 15123.0,
|
||||
"max": 15801.0,
|
||||
"ci95": 843.686
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 999.753,
|
||||
"stdev": 41.901,
|
||||
"min": 970.527,
|
||||
"max": 1047.758,
|
||||
"ci95": 104.095
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.821,
|
||||
"stdev": 17.834,
|
||||
"min": 318.068,
|
||||
"max": 353.566,
|
||||
"ci95": 44.306
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 263.782,
|
||||
"stdev": 31.556,
|
||||
"min": 227.767,
|
||||
"max": 286.582,
|
||||
"ci95": 78.395
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1147.654,
|
||||
"stdev": 31.82,
|
||||
"min": 1116.875,
|
||||
"max": 1180.422,
|
||||
"ci95": 79.052
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.414,
|
||||
"stdev": 0.036,
|
||||
"min": 0.373,
|
||||
"max": 0.44,
|
||||
"ci95": 0.089
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.52,
|
||||
"stdev": 0.071,
|
||||
"min": 0.44,
|
||||
"max": 0.577,
|
||||
"ci95": 0.177
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3685.368,
|
||||
"stdev": 844.43,
|
||||
"min": 2932.117,
|
||||
"max": 4598.204,
|
||||
"ci95": 2097.85
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16795.333,
|
||||
"stdev": 437.802,
|
||||
"min": 16325.0,
|
||||
"max": 17191.0,
|
||||
"ci95": 1087.648
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 592.615,
|
||||
"stdev": 20.339,
|
||||
"min": 580.401,
|
||||
"max": 616.094,
|
||||
"ci95": 50.529
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.069,
|
||||
"stdev": 31.227,
|
||||
"min": 217.065,
|
||||
"max": 279.149,
|
||||
"ci95": 77.58
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 151.684,
|
||||
"stdev": 77.064,
|
||||
"min": 64.033,
|
||||
"max": 208.811,
|
||||
"ci95": 191.454
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1064.344,
|
||||
"stdev": 148.185,
|
||||
"min": 901.203,
|
||||
"max": 1190.609,
|
||||
"ci95": 368.142
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.424,
|
||||
"stdev": 0.003,
|
||||
"min": 0.422,
|
||||
"max": 0.427,
|
||||
"ci95": 0.007
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.617,
|
||||
"stdev": 0.016,
|
||||
"min": 0.599,
|
||||
"max": 0.629,
|
||||
"ci95": 0.039
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3594.311,
|
||||
"stdev": 1381.026,
|
||||
"min": 2011.153,
|
||||
"max": 4551.541,
|
||||
"ci95": 3430.936
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14527.0,
|
||||
"stdev": 319.629,
|
||||
"min": 14166.0,
|
||||
"max": 14774.0,
|
||||
"ci95": 794.068
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 789.003,
|
||||
"stdev": 25.076,
|
||||
"min": 766.83,
|
||||
"max": 816.216,
|
||||
"ci95": 62.297
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 276.887,
|
||||
"stdev": 51.785,
|
||||
"min": 217.373,
|
||||
"max": 311.665,
|
||||
"ci95": 128.651
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 169.278,
|
||||
"stdev": 29.119,
|
||||
"min": 137.156,
|
||||
"max": 193.944,
|
||||
"ci95": 72.341
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 982.995,
|
||||
"stdev": 287.819,
|
||||
"min": 651.109,
|
||||
"max": 1164.063,
|
||||
"ci95": 715.039
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.397,
|
||||
"stdev": 0.028,
|
||||
"min": 0.38,
|
||||
"max": 0.429,
|
||||
"ci95": 0.069
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.569,
|
||||
"stdev": 0.045,
|
||||
"min": 0.533,
|
||||
"max": 0.62,
|
||||
"ci95": 0.112
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5682.993,
|
||||
"stdev": 1272.063,
|
||||
"min": 4476.873,
|
||||
"max": 7012.069,
|
||||
"ci95": 3160.234
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15640.0,
|
||||
"stdev": 204.105,
|
||||
"min": 15507.0,
|
||||
"max": 15875.0,
|
||||
"ci95": 507.067
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 716.475,
|
||||
"stdev": 8.139,
|
||||
"min": 707.613,
|
||||
"max": 723.617,
|
||||
"ci95": 20.221
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 313.736,
|
||||
"stdev": 3.759,
|
||||
"min": 311.007,
|
||||
"max": 318.023,
|
||||
"ci95": 9.338
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 270.042,
|
||||
"stdev": 19.572,
|
||||
"min": 247.802,
|
||||
"max": 284.642,
|
||||
"ci95": 48.624
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 997.474,
|
||||
"stdev": 231.733,
|
||||
"min": 790.266,
|
||||
"max": 1247.703,
|
||||
"ci95": 575.704
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.385,
|
||||
"stdev": 0.023,
|
||||
"min": 0.359,
|
||||
"max": 0.401,
|
||||
"ci95": 0.057
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.609,
|
||||
"stdev": 0.044,
|
||||
"min": 0.56,
|
||||
"max": 0.645,
|
||||
"ci95": 0.109
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1213.255,
|
||||
"stdev": 81.897,
|
||||
"min": 1136.078,
|
||||
"max": 1299.172,
|
||||
"ci95": 203.461
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1034.279,
|
||||
"stdev": 188.802,
|
||||
"min": 816.453,
|
||||
"max": 1150.961,
|
||||
"ci95": 469.049
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2167.747,
|
||||
"stdev": 14.039,
|
||||
"min": 2153.721,
|
||||
"max": 2181.799,
|
||||
"ci95": 34.878
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1889.468,
|
||||
"stdev": 24.444,
|
||||
"min": 1862.799,
|
||||
"max": 1910.807,
|
||||
"ci95": 60.727
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 12573,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1047.758,
|
||||
"low_1pct_fps": 353.566,
|
||||
"low_01pct_fps": 286.582,
|
||||
"p50_ms": 0.921,
|
||||
"p95_ms": 1.077,
|
||||
"p99_ms": 2.675,
|
||||
"p999_ms": 2.946,
|
||||
"max_ms": 5.36,
|
||||
"cpu_p50_ms": 0.44,
|
||||
"gpu_p50_ms": 0.914,
|
||||
"histogram": [
|
||||
11056,
|
||||
1291,
|
||||
218,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11646,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 970.527,
|
||||
"low_1pct_fps": 318.068,
|
||||
"low_01pct_fps": 227.767,
|
||||
"p50_ms": 0.973,
|
||||
"p95_ms": 1.264,
|
||||
"p99_ms": 2.805,
|
||||
"p999_ms": 3.808,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 0.577,
|
||||
"gpu_p50_ms": 0.969,
|
||||
"histogram": [
|
||||
7035,
|
||||
4334,
|
||||
230,
|
||||
39,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11771,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 980.973,
|
||||
"low_1pct_fps": 338.828,
|
||||
"low_01pct_fps": 276.996,
|
||||
"p50_ms": 0.985,
|
||||
"p95_ms": 1.157,
|
||||
"p99_ms": 2.779,
|
||||
"p999_ms": 3.068,
|
||||
"max_ms": 4.308,
|
||||
"cpu_p50_ms": 0.542,
|
||||
"gpu_p50_ms": 0.98,
|
||||
"histogram": [
|
||||
6880,
|
||||
4663,
|
||||
208,
|
||||
18,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 7394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 616.094,
|
||||
"low_1pct_fps": 253.993,
|
||||
"low_01pct_fps": 182.207,
|
||||
"p50_ms": 1.565,
|
||||
"p95_ms": 1.915,
|
||||
"p99_ms": 3.329,
|
||||
"p999_ms": 4.661,
|
||||
"max_ms": 7.32,
|
||||
"cpu_p50_ms": 0.599,
|
||||
"gpu_p50_ms": 1.551,
|
||||
"histogram": [
|
||||
82,
|
||||
6988,
|
||||
144,
|
||||
162,
|
||||
14,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 6965,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 580.401,
|
||||
"low_1pct_fps": 217.065,
|
||||
"low_01pct_fps": 64.033,
|
||||
"p50_ms": 1.654,
|
||||
"p95_ms": 1.983,
|
||||
"p99_ms": 3.233,
|
||||
"p999_ms": 3.893,
|
||||
"max_ms": 81.863,
|
||||
"cpu_p50_ms": 0.629,
|
||||
"gpu_p50_ms": 1.645,
|
||||
"histogram": [
|
||||
11,
|
||||
6622,
|
||||
153,
|
||||
174,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 6977,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 581.35,
|
||||
"low_1pct_fps": 279.149,
|
||||
"low_01pct_fps": 208.811,
|
||||
"p50_ms": 1.659,
|
||||
"p95_ms": 2.006,
|
||||
"p99_ms": 3.273,
|
||||
"p999_ms": 3.982,
|
||||
"max_ms": 5.889,
|
||||
"cpu_p50_ms": 0.623,
|
||||
"gpu_p50_ms": 1.653,
|
||||
"histogram": [
|
||||
17,
|
||||
6606,
|
||||
164,
|
||||
186,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 9793,
|
||||
"seconds": 11.998,
|
||||
"avg_fps": 816.216,
|
||||
"low_1pct_fps": 301.624,
|
||||
"low_01pct_fps": 176.734,
|
||||
"p50_ms": 1.171,
|
||||
"p95_ms": 1.499,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 3.333,
|
||||
"max_ms": 23.472,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 1.163,
|
||||
"histogram": [
|
||||
259,
|
||||
9293,
|
||||
182,
|
||||
56,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 9202,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 766.83,
|
||||
"low_1pct_fps": 217.373,
|
||||
"low_01pct_fps": 137.156,
|
||||
"p50_ms": 1.226,
|
||||
"p95_ms": 1.912,
|
||||
"p99_ms": 3.396,
|
||||
"p999_ms": 6.003,
|
||||
"max_ms": 13.562,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 1.219,
|
||||
"histogram": [
|
||||
635,
|
||||
8130,
|
||||
261,
|
||||
124,
|
||||
27,
|
||||
15,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9408,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 783.964,
|
||||
"low_1pct_fps": 311.665,
|
||||
"low_01pct_fps": 193.944,
|
||||
"p50_ms": 1.22,
|
||||
"p95_ms": 1.541,
|
||||
"p99_ms": 2.865,
|
||||
"p999_ms": 3.298,
|
||||
"max_ms": 13.861,
|
||||
"cpu_p50_ms": 0.555,
|
||||
"gpu_p50_ms": 1.214,
|
||||
"histogram": [
|
||||
100,
|
||||
9066,
|
||||
204,
|
||||
34,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 8618,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 718.194,
|
||||
"low_1pct_fps": 312.177,
|
||||
"low_01pct_fps": 277.681,
|
||||
"p50_ms": 1.338,
|
||||
"p95_ms": 1.664,
|
||||
"p99_ms": 3.068,
|
||||
"p999_ms": 3.364,
|
||||
"max_ms": 4.008,
|
||||
"cpu_p50_ms": 0.622,
|
||||
"gpu_p50_ms": 1.329,
|
||||
"histogram": [
|
||||
62,
|
||||
8301,
|
||||
130,
|
||||
125,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8492,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 707.613,
|
||||
"low_1pct_fps": 311.007,
|
||||
"low_01pct_fps": 284.642,
|
||||
"p50_ms": 1.35,
|
||||
"p95_ms": 1.707,
|
||||
"p99_ms": 3.08,
|
||||
"p999_ms": 3.329,
|
||||
"max_ms": 3.796,
|
||||
"cpu_p50_ms": 0.645,
|
||||
"gpu_p50_ms": 1.342,
|
||||
"histogram": [
|
||||
32,
|
||||
8216,
|
||||
111,
|
||||
133,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8684,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 723.617,
|
||||
"low_1pct_fps": 318.023,
|
||||
"low_01pct_fps": 247.802,
|
||||
"p50_ms": 1.318,
|
||||
"p95_ms": 1.699,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 3.573,
|
||||
"max_ms": 5.186,
|
||||
"cpu_p50_ms": 0.56,
|
||||
"gpu_p50_ms": 1.306,
|
||||
"histogram": [
|
||||
46,
|
||||
8389,
|
||||
199,
|
||||
49,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:17:26Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.712,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1528.444,
|
||||
"stdev": 181.21,
|
||||
"min": 1319.58,
|
||||
"max": 1643.783,
|
||||
"ci95": 450.188
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 12368.716,
|
||||
"stdev": 6475.37,
|
||||
"min": 7168.429,
|
||||
"max": 19621.62,
|
||||
"ci95": 16087.01
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6926.208,
|
||||
"stdev": 3712.691,
|
||||
"min": 4208.555,
|
||||
"max": 11156.419,
|
||||
"ci95": 9223.581
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 46790.333,
|
||||
"stdev": 1381.89,
|
||||
"min": 45236.0,
|
||||
"max": 47880.0,
|
||||
"ci95": 3433.082
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 453.505,
|
||||
"stdev": 14.384,
|
||||
"min": 441.517,
|
||||
"max": 469.455,
|
||||
"ci95": 35.735
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 222.897,
|
||||
"stdev": 10.449,
|
||||
"min": 212.173,
|
||||
"max": 233.048,
|
||||
"ci95": 25.96
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 173.55,
|
||||
"stdev": 31.798,
|
||||
"min": 142.967,
|
||||
"max": 206.437,
|
||||
"ci95": 78.996
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1544.565,
|
||||
"stdev": 26.422,
|
||||
"min": 1516.75,
|
||||
"max": 1569.328,
|
||||
"ci95": 65.64
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.528,
|
||||
"stdev": 0.018,
|
||||
"min": 0.511,
|
||||
"max": 0.548,
|
||||
"ci95": 0.045
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.496,
|
||||
"stdev": 0.049,
|
||||
"min": 1.453,
|
||||
"max": 1.549,
|
||||
"ci95": 0.121
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8297.116,
|
||||
"stdev": 689.909,
|
||||
"min": 7716.447,
|
||||
"max": 9059.776,
|
||||
"ci95": 1713.966
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 78617.667,
|
||||
"stdev": 494.532,
|
||||
"min": 78228.0,
|
||||
"max": 79174.0,
|
||||
"ci95": 1228.586
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 216.265,
|
||||
"stdev": 6.372,
|
||||
"min": 209.501,
|
||||
"max": 222.156,
|
||||
"ci95": 15.831
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 137.027,
|
||||
"stdev": 3.787,
|
||||
"min": 133.07,
|
||||
"max": 140.617,
|
||||
"ci95": 9.408
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 114.683,
|
||||
"stdev": 7.985,
|
||||
"min": 106.585,
|
||||
"max": 122.551,
|
||||
"ci95": 19.839
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1420.638,
|
||||
"stdev": 247.812,
|
||||
"min": 1266.047,
|
||||
"max": 1706.469,
|
||||
"ci95": 615.649
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.423,
|
||||
"stdev": 0.059,
|
||||
"min": 0.355,
|
||||
"max": 0.458,
|
||||
"ci95": 0.148
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.605,
|
||||
"stdev": 0.269,
|
||||
"min": 1.295,
|
||||
"max": 1.779,
|
||||
"ci95": 0.668
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8354.659,
|
||||
"stdev": 2877.749,
|
||||
"min": 5454.947,
|
||||
"max": 11209.93,
|
||||
"ci95": 7149.301
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74315.667,
|
||||
"stdev": 63.106,
|
||||
"min": 74246.0,
|
||||
"max": 74369.0,
|
||||
"ci95": 156.776
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 274.828,
|
||||
"stdev": 4.993,
|
||||
"min": 270.041,
|
||||
"max": 280.004,
|
||||
"ci95": 12.404
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 165.765,
|
||||
"stdev": 7.21,
|
||||
"min": 157.538,
|
||||
"max": 170.985,
|
||||
"ci95": 17.912
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 140.185,
|
||||
"stdev": 9.74,
|
||||
"min": 129.976,
|
||||
"max": 149.377,
|
||||
"ci95": 24.198
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1306.578,
|
||||
"stdev": 97.011,
|
||||
"min": 1199.094,
|
||||
"max": 1387.641,
|
||||
"ci95": 241.007
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.416,
|
||||
"stdev": 0.029,
|
||||
"min": 0.392,
|
||||
"max": 0.448,
|
||||
"ci95": 0.071
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.636,
|
||||
"stdev": 0.121,
|
||||
"min": 1.496,
|
||||
"max": 1.708,
|
||||
"ci95": 0.301
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17124.873,
|
||||
"stdev": 3461.08,
|
||||
"min": 13599.217,
|
||||
"max": 20517.544,
|
||||
"ci95": 8598.494
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 91909.0,
|
||||
"stdev": 815.603,
|
||||
"min": 91157.0,
|
||||
"max": 92776.0,
|
||||
"ci95": 2026.235
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 242.657,
|
||||
"stdev": 3.405,
|
||||
"min": 240.66,
|
||||
"max": 246.588,
|
||||
"ci95": 8.459
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 110.95,
|
||||
"stdev": 14.622,
|
||||
"min": 99.907,
|
||||
"max": 127.532,
|
||||
"ci95": 36.325
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.329,
|
||||
"stdev": 28.253,
|
||||
"min": 30.391,
|
||||
"max": 83.465,
|
||||
"ci95": 70.191
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1451.688,
|
||||
"stdev": 127.471,
|
||||
"min": 1310.969,
|
||||
"max": 1559.43,
|
||||
"ci95": 316.681
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.614,
|
||||
"stdev": 0.017,
|
||||
"min": 0.594,
|
||||
"max": 0.627,
|
||||
"ci95": 0.043
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 2.233,
|
||||
"stdev": 0.05,
|
||||
"min": 2.194,
|
||||
"max": 2.289,
|
||||
"ci95": 0.124
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1697.984,
|
||||
"stdev": 29.735,
|
||||
"min": 1667.406,
|
||||
"max": 1726.797,
|
||||
"ci95": 73.871
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1428.19,
|
||||
"stdev": 120.658,
|
||||
"min": 1333.0,
|
||||
"max": 1563.891,
|
||||
"ci95": 299.756
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4921.753,
|
||||
"stdev": 30.436,
|
||||
"min": 4890.971,
|
||||
"max": 4951.831,
|
||||
"ci95": 75.613
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4079.43,
|
||||
"stdev": 10.978,
|
||||
"min": 4068.049,
|
||||
"max": 4089.956,
|
||||
"ci95": 27.273
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 5634,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 469.455,
|
||||
"low_1pct_fps": 223.469,
|
||||
"low_01pct_fps": 171.246,
|
||||
"p50_ms": 2.054,
|
||||
"p95_ms": 2.492,
|
||||
"p99_ms": 3.993,
|
||||
"p999_ms": 5.425,
|
||||
"max_ms": 6.645,
|
||||
"cpu_p50_ms": 1.453,
|
||||
"gpu_p50_ms": 1.965,
|
||||
"histogram": [
|
||||
0,
|
||||
2082,
|
||||
3324,
|
||||
193,
|
||||
24,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5395,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 449.543,
|
||||
"low_1pct_fps": 233.048,
|
||||
"low_01pct_fps": 206.437,
|
||||
"p50_ms": 2.155,
|
||||
"p95_ms": 2.611,
|
||||
"p99_ms": 4.068,
|
||||
"p999_ms": 4.456,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 1.549,
|
||||
"gpu_p50_ms": 2.043,
|
||||
"histogram": [
|
||||
0,
|
||||
902,
|
||||
4255,
|
||||
206,
|
||||
29,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5298,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 441.517,
|
||||
"low_1pct_fps": 212.173,
|
||||
"low_01pct_fps": 142.967,
|
||||
"p50_ms": 2.171,
|
||||
"p95_ms": 3.249,
|
||||
"p99_ms": 4.138,
|
||||
"p999_ms": 5.591,
|
||||
"max_ms": 9.676,
|
||||
"cpu_p50_ms": 1.485,
|
||||
"gpu_p50_ms": 2.092,
|
||||
"histogram": [
|
||||
0,
|
||||
709,
|
||||
4314,
|
||||
229,
|
||||
36,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 2606,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 217.137,
|
||||
"low_1pct_fps": 140.617,
|
||||
"low_01pct_fps": 106.585,
|
||||
"p50_ms": 4.471,
|
||||
"p95_ms": 5.926,
|
||||
"p99_ms": 6.575,
|
||||
"p999_ms": 7.735,
|
||||
"max_ms": 12.187,
|
||||
"cpu_p50_ms": 1.74,
|
||||
"gpu_p50_ms": 4.238,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
221,
|
||||
2094,
|
||||
172,
|
||||
110,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2514,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 209.501,
|
||||
"low_1pct_fps": 137.393,
|
||||
"low_01pct_fps": 122.551,
|
||||
"p50_ms": 4.627,
|
||||
"p95_ms": 6.304,
|
||||
"p99_ms": 6.93,
|
||||
"p999_ms": 7.971,
|
||||
"max_ms": 8.506,
|
||||
"cpu_p50_ms": 1.779,
|
||||
"gpu_p50_ms": 4.409,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
35,
|
||||
2097,
|
||||
200,
|
||||
162,
|
||||
17,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 2667,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 222.156,
|
||||
"low_1pct_fps": 133.07,
|
||||
"low_01pct_fps": 114.913,
|
||||
"p50_ms": 4.37,
|
||||
"p95_ms": 5.756,
|
||||
"p99_ms": 6.742,
|
||||
"p999_ms": 8.444,
|
||||
"max_ms": 8.984,
|
||||
"cpu_p50_ms": 1.295,
|
||||
"gpu_p50_ms": 4.152,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
440,
|
||||
1978,
|
||||
144,
|
||||
85,
|
||||
16,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 3294,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 274.44,
|
||||
"low_1pct_fps": 157.538,
|
||||
"low_01pct_fps": 129.976,
|
||||
"p50_ms": 3.521,
|
||||
"p95_ms": 4.814,
|
||||
"p99_ms": 5.592,
|
||||
"p999_ms": 7.491,
|
||||
"max_ms": 7.868,
|
||||
"cpu_p50_ms": 1.703,
|
||||
"gpu_p50_ms": 3.277,
|
||||
"histogram": [
|
||||
0,
|
||||
4,
|
||||
55,
|
||||
2967,
|
||||
130,
|
||||
122,
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3241,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 270.041,
|
||||
"low_1pct_fps": 168.771,
|
||||
"low_01pct_fps": 141.201,
|
||||
"p50_ms": 3.578,
|
||||
"p95_ms": 4.942,
|
||||
"p99_ms": 5.631,
|
||||
"p999_ms": 6.498,
|
||||
"max_ms": 7.601,
|
||||
"cpu_p50_ms": 1.708,
|
||||
"gpu_p50_ms": 3.323,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
8,
|
||||
2970,
|
||||
110,
|
||||
144,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 3360,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 280.004,
|
||||
"low_1pct_fps": 170.985,
|
||||
"low_01pct_fps": 149.377,
|
||||
"p50_ms": 3.477,
|
||||
"p95_ms": 4.678,
|
||||
"p99_ms": 5.462,
|
||||
"p999_ms": 6.626,
|
||||
"max_ms": 6.788,
|
||||
"cpu_p50_ms": 1.496,
|
||||
"gpu_p50_ms": 3.23,
|
||||
"histogram": [
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
3125,
|
||||
129,
|
||||
90,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2888,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 240.66,
|
||||
"low_1pct_fps": 127.532,
|
||||
"low_01pct_fps": 83.465,
|
||||
"p50_ms": 3.984,
|
||||
"p95_ms": 5.171,
|
||||
"p99_ms": 6.823,
|
||||
"p999_ms": 9.168,
|
||||
"max_ms": 15.341,
|
||||
"cpu_p50_ms": 2.194,
|
||||
"gpu_p50_ms": 3.786,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2048,
|
||||
638,
|
||||
144,
|
||||
33,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 2889,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 240.722,
|
||||
"low_1pct_fps": 99.907,
|
||||
"low_01pct_fps": 40.132,
|
||||
"p50_ms": 3.961,
|
||||
"p95_ms": 5.706,
|
||||
"p99_ms": 7.364,
|
||||
"p999_ms": 12.597,
|
||||
"max_ms": 49.156,
|
||||
"cpu_p50_ms": 2.289,
|
||||
"gpu_p50_ms": 3.802,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
126,
|
||||
1859,
|
||||
599,
|
||||
194,
|
||||
72,
|
||||
22,
|
||||
8,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2959,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 246.588,
|
||||
"low_1pct_fps": 105.411,
|
||||
"low_01pct_fps": 30.391,
|
||||
"p50_ms": 3.851,
|
||||
"p95_ms": 5.353,
|
||||
"p99_ms": 6.248,
|
||||
"p999_ms": 9.05,
|
||||
"max_ms": 54.378,
|
||||
"cpu_p50_ms": 2.216,
|
||||
"gpu_p50_ms": 3.667,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
58,
|
||||
2196,
|
||||
462,
|
||||
196,
|
||||
32,
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
|
||||
|
||||
> **The Mac mini reports (`*-macmini-rd*.json`) are offscreen, not comparable.** Horizon rendered offscreen (no presenting)
|
||||
> while vanilla drew into a window, so the Mac rows below are not a Horizon-vs-vanilla comparison, and the website does not
|
||||
> publish them: it keeps the Mac mini's 2026-10-22 runs. They are kept here only as a record of that day's offscreen run.
|
||||
|
||||
The desktop runs are the ones behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
|
||||
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
|
||||
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
|
||||
|
||||
## Builds
|
||||
|
||||
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
|
||||
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
|
||||
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
|
||||
|
||||
## Machines and conditions
|
||||
|
||||
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS: offscreen, not comparable.** The session was locked, so Horizon ran `--offscreen`: a locked
|
||||
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
|
||||
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
|
||||
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
|
||||
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
|
||||
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
|
||||
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
|
||||
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
|
||||
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
|
||||
load and by a cold disk on each series' first start (start → world up to 130-235 s).
|
||||
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
|
||||
|
||||
## Results (average over the flights of the three games; FPS average / 1% low)
|
||||
|
||||
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|
||||
|---|---|---|---|---|
|
||||
| Mac mini (**offscreen, not comparable**) | 12 | 774 / 294 | 132 / 27 | — |
|
||||
| Mac mini (**offscreen, not comparable**) | 32 | 297 / 159 | 68 / 16 | — |
|
||||
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
|
||||
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
|
||||
|
||||
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
|
||||
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
|
||||
@@ -1,787 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:28:55Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.788,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 121075.467,
|
||||
"stdev": 30646.429,
|
||||
"min": 93272.034,
|
||||
"max": 153935.834,
|
||||
"ci95": 76136.094
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 191063.8,
|
||||
"stdev": 34368.16,
|
||||
"min": 153895.533,
|
||||
"max": 221692.034,
|
||||
"ci95": 85382.134
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18724.322,
|
||||
"stdev": 9639.048,
|
||||
"min": 13107.549,
|
||||
"max": 29854.376,
|
||||
"ci95": 23946.655
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2161.532,
|
||||
"stdev": 287.109,
|
||||
"min": 1830.069,
|
||||
"max": 2332.81,
|
||||
"ci95": 713.276
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.575,
|
||||
"stdev": 29.799,
|
||||
"min": 136.023,
|
||||
"max": 194.804,
|
||||
"ci95": 74.03
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 27.752,
|
||||
"stdev": 3.186,
|
||||
"min": 24.403,
|
||||
"max": 30.746,
|
||||
"ci95": 7.916
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2902.054,
|
||||
"stdev": 158.708,
|
||||
"min": 2747.244,
|
||||
"max": 3064.393,
|
||||
"ci95": 394.285
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.691,
|
||||
"stdev": 0.097,
|
||||
"min": 0.601,
|
||||
"max": 0.794,
|
||||
"ci95": 0.241
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8852.332,
|
||||
"stdev": 530.809,
|
||||
"min": 8350.811,
|
||||
"max": 9408.241,
|
||||
"ci95": 1318.709
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1841.581,
|
||||
"stdev": 1186.781,
|
||||
"min": 471.209,
|
||||
"max": 2529.847,
|
||||
"ci95": 2948.365
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 255.087,
|
||||
"stdev": 217.128,
|
||||
"min": 5.798,
|
||||
"max": 402.877,
|
||||
"ci95": 539.419
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.882,
|
||||
"stdev": 81.722,
|
||||
"min": 0.61,
|
||||
"max": 156.13,
|
||||
"ci95": 203.024
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3008.178,
|
||||
"stdev": 149.07,
|
||||
"min": 2875.082,
|
||||
"max": 3169.256,
|
||||
"ci95": 370.34
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.687,
|
||||
"stdev": 0.132,
|
||||
"min": 0.562,
|
||||
"max": 0.825,
|
||||
"ci95": 0.327
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 21215.745,
|
||||
"stdev": 22821.547,
|
||||
"min": 8034.077,
|
||||
"max": 47567.797,
|
||||
"ci95": 56696.441
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1525.483,
|
||||
"stdev": 1472.651,
|
||||
"min": 633.984,
|
||||
"max": 3225.275,
|
||||
"ci95": 3658.563
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.398,
|
||||
"stdev": 272.942,
|
||||
"min": 10.346,
|
||||
"max": 505.335,
|
||||
"ci95": 678.08
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 49.506,
|
||||
"stdev": 66.145,
|
||||
"min": 1.104,
|
||||
"max": 124.874,
|
||||
"ci95": 164.326
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3062.136,
|
||||
"stdev": 128.594,
|
||||
"min": 2962.541,
|
||||
"max": 3207.313,
|
||||
"ci95": 319.472
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.029,
|
||||
"stdev": 0.573,
|
||||
"min": 0.595,
|
||||
"max": 1.678,
|
||||
"ci95": 1.423
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 33255.15,
|
||||
"stdev": 20451.99,
|
||||
"min": 16655.144,
|
||||
"max": 56102.104,
|
||||
"ci95": 50809.661
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1769.12,
|
||||
"stdev": 996.863,
|
||||
"min": 818.512,
|
||||
"max": 2806.557,
|
||||
"ci95": 2476.544
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.916,
|
||||
"stdev": 195.285,
|
||||
"min": 11.586,
|
||||
"max": 400.567,
|
||||
"ci95": 485.154
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 52.202,
|
||||
"stdev": 51.208,
|
||||
"min": 1.461,
|
||||
"max": 103.864,
|
||||
"ci95": 127.217
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2957.948,
|
||||
"stdev": 340.935,
|
||||
"min": 2586.504,
|
||||
"max": 3256.625,
|
||||
"ci95": 846.998
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.436,
|
||||
"stdev": 1.406,
|
||||
"min": 0.462,
|
||||
"max": 3.048,
|
||||
"ci95": 3.494
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3107.953,
|
||||
"stdev": 141.888,
|
||||
"min": 3020.105,
|
||||
"max": 3271.645,
|
||||
"ci95": 352.498
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2985.317,
|
||||
"stdev": 210.327,
|
||||
"min": 2761.164,
|
||||
"max": 3178.35,
|
||||
"ci95": 522.523
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21963,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1830.069,
|
||||
"low_1pct_fps": 136.023,
|
||||
"low_01pct_fps": 30.746,
|
||||
"p50_ms": 0.331,
|
||||
"p95_ms": 1.153,
|
||||
"p99_ms": 3.001,
|
||||
"p999_ms": 9.997,
|
||||
"max_ms": 326.36,
|
||||
"histogram": [
|
||||
20336,
|
||||
1238,
|
||||
167,
|
||||
108,
|
||||
40,
|
||||
23,
|
||||
12,
|
||||
10,
|
||||
8,
|
||||
7,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 27994,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2332.81,
|
||||
"low_1pct_fps": 194.804,
|
||||
"low_01pct_fps": 28.107,
|
||||
"p50_ms": 0.321,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.356,
|
||||
"p999_ms": 3.836,
|
||||
"max_ms": 331.931,
|
||||
"histogram": [
|
||||
27270,
|
||||
652,
|
||||
34,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 27861,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2321.717,
|
||||
"low_1pct_fps": 156.898,
|
||||
"low_01pct_fps": 24.403,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.852,
|
||||
"p99_ms": 1.73,
|
||||
"p999_ms": 6.331,
|
||||
"max_ms": 301.991,
|
||||
"histogram": [
|
||||
26932,
|
||||
726,
|
||||
124,
|
||||
42,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 30359,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2529.847,
|
||||
"low_1pct_fps": 356.586,
|
||||
"low_01pct_fps": 121.906,
|
||||
"p50_ms": 0.353,
|
||||
"p95_ms": 0.563,
|
||||
"p99_ms": 1.405,
|
||||
"p999_ms": 4.444,
|
||||
"max_ms": 20.302,
|
||||
"histogram": [
|
||||
29756,
|
||||
460,
|
||||
57,
|
||||
52,
|
||||
11,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 5781,
|
||||
"seconds": 12.268,
|
||||
"avg_fps": 471.209,
|
||||
"low_1pct_fps": 5.798,
|
||||
"low_01pct_fps": 0.61,
|
||||
"p50_ms": 0.359,
|
||||
"p95_ms": 0.77,
|
||||
"p99_ms": 1.78,
|
||||
"p999_ms": 20.826,
|
||||
"max_ms": 5834.37,
|
||||
"histogram": [
|
||||
5586,
|
||||
158,
|
||||
12,
|
||||
11,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 30285,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2523.687,
|
||||
"low_1pct_fps": 402.877,
|
||||
"low_01pct_fps": 156.13,
|
||||
"p50_ms": 0.351,
|
||||
"p95_ms": 0.539,
|
||||
"p99_ms": 1.574,
|
||||
"p999_ms": 3.683,
|
||||
"max_ms": 16.022,
|
||||
"histogram": [
|
||||
29666,
|
||||
490,
|
||||
74,
|
||||
35,
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 11685,
|
||||
"seconds": 16.293,
|
||||
"avg_fps": 717.19,
|
||||
"low_1pct_fps": 10.346,
|
||||
"low_01pct_fps": 1.104,
|
||||
"p50_ms": 0.299,
|
||||
"p95_ms": 1.119,
|
||||
"p99_ms": 2.568,
|
||||
"p999_ms": 12.054,
|
||||
"max_ms": 5250.537,
|
||||
"histogram": [
|
||||
10993,
|
||||
502,
|
||||
102,
|
||||
39,
|
||||
14,
|
||||
8,
|
||||
6,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 38704,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3225.275,
|
||||
"low_1pct_fps": 505.335,
|
||||
"low_01pct_fps": 124.874,
|
||||
"p50_ms": 0.283,
|
||||
"p95_ms": 0.36,
|
||||
"p99_ms": 0.909,
|
||||
"p999_ms": 2.015,
|
||||
"max_ms": 132.801,
|
||||
"histogram": [
|
||||
38385,
|
||||
279,
|
||||
25,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 7609,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 633.984,
|
||||
"low_1pct_fps": 58.513,
|
||||
"low_01pct_fps": 22.539,
|
||||
"p50_ms": 1.141,
|
||||
"p95_ms": 4.438,
|
||||
"p99_ms": 9.113,
|
||||
"p999_ms": 27.496,
|
||||
"max_ms": 64.424,
|
||||
"histogram": [
|
||||
3452,
|
||||
2398,
|
||||
956,
|
||||
368,
|
||||
143,
|
||||
96,
|
||||
61,
|
||||
46,
|
||||
20,
|
||||
29,
|
||||
6,
|
||||
10,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10168,
|
||||
"seconds": 12.423,
|
||||
"avg_fps": 818.512,
|
||||
"low_1pct_fps": 11.586,
|
||||
"low_01pct_fps": 1.461,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.892,
|
||||
"p99_ms": 3.176,
|
||||
"p999_ms": 198.757,
|
||||
"max_ms": 1882.64,
|
||||
"histogram": [
|
||||
9733,
|
||||
205,
|
||||
121,
|
||||
49,
|
||||
12,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2806.557,
|
||||
"low_1pct_fps": 400.567,
|
||||
"low_01pct_fps": 103.864,
|
||||
"p50_ms": 0.324,
|
||||
"p95_ms": 0.411,
|
||||
"p99_ms": 1.167,
|
||||
"p999_ms": 3.508,
|
||||
"max_ms": 83.572,
|
||||
"histogram": [
|
||||
33272,
|
||||
315,
|
||||
46,
|
||||
22,
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20188,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1682.292,
|
||||
"low_1pct_fps": 175.594,
|
||||
"low_01pct_fps": 51.28,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 1.37,
|
||||
"p99_ms": 2.821,
|
||||
"p999_ms": 8.947,
|
||||
"max_ms": 53.494,
|
||||
"histogram": [
|
||||
18109,
|
||||
1592,
|
||||
314,
|
||||
83,
|
||||
36,
|
||||
19,
|
||||
7,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -1,787 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T15:46:25Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.799,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 112742.179,
|
||||
"stdev": 27885.804,
|
||||
"min": 81110.692,
|
||||
"max": 133773.748,
|
||||
"ci95": 69277.769
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 235049.179,
|
||||
"stdev": 31461.958,
|
||||
"min": 203486.748,
|
||||
"max": 266409.692,
|
||||
"ci95": 78162.144
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 79828.05,
|
||||
"stdev": 109107.365,
|
||||
"min": 16685.972,
|
||||
"max": 205814.266,
|
||||
"ci95": 271059.597
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 525.667,
|
||||
"stdev": 390.505,
|
||||
"min": 101.073,
|
||||
"max": 869.433,
|
||||
"ci95": 970.146
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.209,
|
||||
"stdev": 39.491,
|
||||
"min": 1.644,
|
||||
"max": 79.658,
|
||||
"ci95": 98.109
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.635,
|
||||
"stdev": 9.759,
|
||||
"min": 0.686,
|
||||
"max": 20.192,
|
||||
"ci95": 24.244
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3296.229,
|
||||
"stdev": 296.464,
|
||||
"min": 2956.234,
|
||||
"max": 3500.773,
|
||||
"ci95": 736.517
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.23,
|
||||
"stdev": 0.449,
|
||||
"min": 0.897,
|
||||
"max": 1.74,
|
||||
"ci95": 1.114
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61172.527,
|
||||
"stdev": 17434.762,
|
||||
"min": 46779.931,
|
||||
"max": 80559.429,
|
||||
"ci95": 43313.846
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 599.408,
|
||||
"stdev": 492.96,
|
||||
"min": 64.173,
|
||||
"max": 1034.813,
|
||||
"ci95": 1224.68
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 61.923,
|
||||
"stdev": 56.534,
|
||||
"min": 0.737,
|
||||
"max": 112.221,
|
||||
"ci95": 140.449
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.771,
|
||||
"stdev": 14.11,
|
||||
"min": 0.098,
|
||||
"max": 27.463,
|
||||
"ci95": 35.054
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3278.964,
|
||||
"stdev": 301.465,
|
||||
"min": 2947.268,
|
||||
"max": 3536.27,
|
||||
"ci95": 748.941
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 5.908,
|
||||
"stdev": 8.813,
|
||||
"min": 0.693,
|
||||
"max": 16.084,
|
||||
"ci95": 21.895
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61762.098,
|
||||
"stdev": 3569.17,
|
||||
"min": 57919.097,
|
||||
"max": 64972.973,
|
||||
"ci95": 8867.026
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1175.044,
|
||||
"stdev": 131.94,
|
||||
"min": 1093.801,
|
||||
"max": 1327.281,
|
||||
"ci95": 327.785
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 119.758,
|
||||
"stdev": 68.419,
|
||||
"min": 77.252,
|
||||
"max": 198.683,
|
||||
"ci95": 169.976
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 24.224,
|
||||
"stdev": 18.982,
|
||||
"min": 12.308,
|
||||
"max": 46.114,
|
||||
"ci95": 47.159
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3275.349,
|
||||
"stdev": 317.864,
|
||||
"min": 2924.113,
|
||||
"max": 3543.23,
|
||||
"ci95": 789.681
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.654,
|
||||
"stdev": 0.066,
|
||||
"min": 0.579,
|
||||
"max": 0.699,
|
||||
"ci95": 0.163
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 113439.529,
|
||||
"stdev": 64746.162,
|
||||
"min": 55046.887,
|
||||
"max": 183068.386,
|
||||
"ci95": 160851.363
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 485.832,
|
||||
"stdev": 317.254,
|
||||
"min": 121.312,
|
||||
"max": 699.617,
|
||||
"ci95": 788.165
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.612,
|
||||
"stdev": 23.334,
|
||||
"min": 2.229,
|
||||
"max": 46.542,
|
||||
"ci95": 57.97
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.56,
|
||||
"stdev": 3.408,
|
||||
"min": 0.75,
|
||||
"max": 7.318,
|
||||
"ci95": 8.467
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3248.708,
|
||||
"stdev": 480.962,
|
||||
"min": 2700.645,
|
||||
"max": 3600.484,
|
||||
"ci95": 1194.873
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.832,
|
||||
"stdev": 2.076,
|
||||
"min": 0.618,
|
||||
"max": 4.229,
|
||||
"ci95": 5.157
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3371.958,
|
||||
"stdev": 280.347,
|
||||
"min": 3059.926,
|
||||
"max": 3602.613,
|
||||
"ci95": 696.476
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3291.249,
|
||||
"stdev": 316.441,
|
||||
"min": 2935.975,
|
||||
"max": 3542.848,
|
||||
"ci95": 786.147
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1248,
|
||||
"seconds": 12.348,
|
||||
"avg_fps": 101.073,
|
||||
"low_1pct_fps": 1.644,
|
||||
"low_01pct_fps": 0.686,
|
||||
"p50_ms": 1.225,
|
||||
"p95_ms": 11.161,
|
||||
"p99_ms": 226.597,
|
||||
"p999_ms": 1457.605,
|
||||
"max_ms": 1457.874,
|
||||
"histogram": [
|
||||
548,
|
||||
232,
|
||||
97,
|
||||
118,
|
||||
51,
|
||||
49,
|
||||
24,
|
||||
39,
|
||||
16,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10435,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 869.433,
|
||||
"low_1pct_fps": 79.658,
|
||||
"low_01pct_fps": 20.192,
|
||||
"p50_ms": 0.813,
|
||||
"p95_ms": 2.537,
|
||||
"p99_ms": 5.685,
|
||||
"p999_ms": 13.292,
|
||||
"max_ms": 351.251,
|
||||
"histogram": [
|
||||
7598,
|
||||
2071,
|
||||
393,
|
||||
171,
|
||||
55,
|
||||
53,
|
||||
22,
|
||||
24,
|
||||
20,
|
||||
10,
|
||||
9,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 7278,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 606.495,
|
||||
"low_1pct_fps": 51.326,
|
||||
"low_01pct_fps": 11.026,
|
||||
"p50_ms": 1.105,
|
||||
"p95_ms": 3.854,
|
||||
"p99_ms": 7.067,
|
||||
"p999_ms": 24.484,
|
||||
"max_ms": 443.95,
|
||||
"histogram": [
|
||||
3206,
|
||||
2621,
|
||||
800,
|
||||
360,
|
||||
112,
|
||||
71,
|
||||
34,
|
||||
26,
|
||||
12,
|
||||
13,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
7,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 975,
|
||||
"seconds": 15.193,
|
||||
"avg_fps": 64.173,
|
||||
"low_1pct_fps": 0.737,
|
||||
"low_01pct_fps": 0.098,
|
||||
"p50_ms": 0.864,
|
||||
"p95_ms": 5.083,
|
||||
"p99_ms": 77.689,
|
||||
"p999_ms": 10225.237,
|
||||
"max_ms": 10225.237,
|
||||
"histogram": [
|
||||
680,
|
||||
99,
|
||||
104,
|
||||
12,
|
||||
30,
|
||||
13,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 8391,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 699.237,
|
||||
"low_1pct_fps": 72.811,
|
||||
"low_01pct_fps": 19.752,
|
||||
"p50_ms": 1.087,
|
||||
"p95_ms": 2.927,
|
||||
"p99_ms": 5.909,
|
||||
"p999_ms": 15.268,
|
||||
"max_ms": 252.557,
|
||||
"histogram": [
|
||||
3342,
|
||||
3998,
|
||||
656,
|
||||
231,
|
||||
56,
|
||||
26,
|
||||
15,
|
||||
19,
|
||||
12,
|
||||
12,
|
||||
11,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 12418,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1034.813,
|
||||
"low_1pct_fps": 112.221,
|
||||
"low_01pct_fps": 27.463,
|
||||
"p50_ms": 0.837,
|
||||
"p95_ms": 1.394,
|
||||
"p99_ms": 3.491,
|
||||
"p999_ms": 13.217,
|
||||
"max_ms": 245.59,
|
||||
"histogram": [
|
||||
11155,
|
||||
1004,
|
||||
100,
|
||||
64,
|
||||
29,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
2,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 13126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1093.801,
|
||||
"low_1pct_fps": 77.252,
|
||||
"low_01pct_fps": 12.308,
|
||||
"p50_ms": 0.714,
|
||||
"p95_ms": 1.402,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 12.067,
|
||||
"max_ms": 238.158,
|
||||
"histogram": [
|
||||
11605,
|
||||
1207,
|
||||
191,
|
||||
54,
|
||||
17,
|
||||
11,
|
||||
7,
|
||||
12,
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15928,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1327.281,
|
||||
"low_1pct_fps": 198.683,
|
||||
"low_01pct_fps": 46.114,
|
||||
"p50_ms": 0.67,
|
||||
"p95_ms": 1.172,
|
||||
"p99_ms": 2.111,
|
||||
"p999_ms": 7.067,
|
||||
"max_ms": 193.743,
|
||||
"histogram": [
|
||||
14891,
|
||||
856,
|
||||
102,
|
||||
42,
|
||||
7,
|
||||
6,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 13249,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1104.051,
|
||||
"low_1pct_fps": 83.338,
|
||||
"low_01pct_fps": 14.249,
|
||||
"p50_ms": 0.711,
|
||||
"p95_ms": 1.403,
|
||||
"p99_ms": 3.046,
|
||||
"p999_ms": 10.625,
|
||||
"max_ms": 451.869,
|
||||
"histogram": [
|
||||
11824,
|
||||
1165,
|
||||
124,
|
||||
58,
|
||||
13,
|
||||
16,
|
||||
11,
|
||||
14,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 1469,
|
||||
"seconds": 12.109,
|
||||
"avg_fps": 121.312,
|
||||
"low_1pct_fps": 2.229,
|
||||
"low_01pct_fps": 0.75,
|
||||
"p50_ms": 2.12,
|
||||
"p95_ms": 13.173,
|
||||
"p99_ms": 125.189,
|
||||
"p999_ms": 727.045,
|
||||
"max_ms": 1939.723,
|
||||
"histogram": [
|
||||
196,
|
||||
440,
|
||||
438,
|
||||
136,
|
||||
50,
|
||||
36,
|
||||
24,
|
||||
25,
|
||||
22,
|
||||
17,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
11,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
7,
|
||||
2,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8396,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 699.617,
|
||||
"low_1pct_fps": 46.542,
|
||||
"low_01pct_fps": 7.318,
|
||||
"p50_ms": 1.022,
|
||||
"p95_ms": 2.433,
|
||||
"p99_ms": 5.528,
|
||||
"p999_ms": 13.993,
|
||||
"max_ms": 305.464,
|
||||
"histogram": [
|
||||
3248,
|
||||
4509,
|
||||
361,
|
||||
113,
|
||||
52,
|
||||
37,
|
||||
21,
|
||||
26,
|
||||
14,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7639,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 636.566,
|
||||
"low_1pct_fps": 37.064,
|
||||
"low_01pct_fps": 5.612,
|
||||
"p50_ms": 1.023,
|
||||
"p95_ms": 3.0,
|
||||
"p99_ms": 7.11,
|
||||
"p999_ms": 17.521,
|
||||
"max_ms": 268.877,
|
||||
"histogram": [
|
||||
3051,
|
||||
3794,
|
||||
413,
|
||||
158,
|
||||
66,
|
||||
43,
|
||||
35,
|
||||
21,
|
||||
31,
|
||||
12,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -1,771 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:54:29Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.628,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12652.182,
|
||||
"stdev": 3231.342,
|
||||
"min": 9526.004,
|
||||
"max": 15979.309,
|
||||
"ci95": 8027.746
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 31329.516,
|
||||
"stdev": 10016.129,
|
||||
"min": 20777.004,
|
||||
"max": 40705.309,
|
||||
"ci95": 24883.451
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12623.084,
|
||||
"stdev": 4958.724,
|
||||
"min": 7251.986,
|
||||
"max": 17026.894,
|
||||
"ci95": 12319.147
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.729,
|
||||
"stdev": 58.527,
|
||||
"min": 28.713,
|
||||
"max": 143.496,
|
||||
"ci95": 145.402
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.52,
|
||||
"stdev": 10.949,
|
||||
"min": 4.001,
|
||||
"max": 24.308,
|
||||
"ci95": 27.2
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 11.515,
|
||||
"stdev": 7.155,
|
||||
"min": 3.549,
|
||||
"max": 17.394,
|
||||
"ci95": 17.774
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1834.469,
|
||||
"stdev": 329.198,
|
||||
"min": 1455.688,
|
||||
"max": 2051.516,
|
||||
"ci95": 817.839
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.903,
|
||||
"stdev": 1.114,
|
||||
"min": 1.131,
|
||||
"max": 3.18,
|
||||
"ci95": 2.767
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14590.694,
|
||||
"stdev": 3260.615,
|
||||
"min": 10942.613,
|
||||
"max": 17221.108,
|
||||
"ci95": 8100.47
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 106.817,
|
||||
"stdev": 104.558,
|
||||
"min": 43.857,
|
||||
"max": 227.513,
|
||||
"ci95": 259.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 23.0,
|
||||
"stdev": 26.394,
|
||||
"min": 7.338,
|
||||
"max": 53.473,
|
||||
"ci95": 65.57
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 14.841,
|
||||
"stdev": 15.773,
|
||||
"min": 5.157,
|
||||
"max": 33.042,
|
||||
"ci95": 39.186
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1814.245,
|
||||
"stdev": 154.974,
|
||||
"min": 1635.453,
|
||||
"max": 1910.125,
|
||||
"ci95": 385.007
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.452,
|
||||
"stdev": 0.542,
|
||||
"min": 0.826,
|
||||
"max": 1.777,
|
||||
"ci95": 1.346
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13309.049,
|
||||
"stdev": 8088.157,
|
||||
"min": 6394.403,
|
||||
"max": 22203.194,
|
||||
"ci95": 20093.716
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.405,
|
||||
"stdev": 141.4,
|
||||
"min": 62.162,
|
||||
"max": 325.77,
|
||||
"ci95": 351.285
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 36.242,
|
||||
"stdev": 37.051,
|
||||
"min": 14.736,
|
||||
"max": 79.024,
|
||||
"ci95": 92.047
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.898,
|
||||
"stdev": 17.899,
|
||||
"min": 8.855,
|
||||
"max": 41.466,
|
||||
"ci95": 44.468
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1724.453,
|
||||
"stdev": 347.692,
|
||||
"min": 1323.5,
|
||||
"max": 1942.734,
|
||||
"ci95": 863.784
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.866,
|
||||
"stdev": 0.248,
|
||||
"min": 0.58,
|
||||
"max": 1.03,
|
||||
"ci95": 0.617
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17989.43,
|
||||
"stdev": 3969.295,
|
||||
"min": 13444.423,
|
||||
"max": 20774.261,
|
||||
"ci95": 9861.07
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.084,
|
||||
"stdev": 160.057,
|
||||
"min": 68.44,
|
||||
"max": 346.896,
|
||||
"ci95": 397.635
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.74,
|
||||
"stdev": 36.146,
|
||||
"min": 11.158,
|
||||
"max": 75.429,
|
||||
"ci95": 89.798
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.194,
|
||||
"stdev": 14.232,
|
||||
"min": 7.903,
|
||||
"max": 33.579,
|
||||
"ci95": 35.357
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1977.602,
|
||||
"stdev": 108.627,
|
||||
"min": 1872.516,
|
||||
"max": 2089.453,
|
||||
"ci95": 269.866
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.772,
|
||||
"stdev": 0.332,
|
||||
"min": 0.463,
|
||||
"max": 1.123,
|
||||
"ci95": 0.825
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2135.313,
|
||||
"stdev": 54.617,
|
||||
"min": 2072.344,
|
||||
"max": 2169.828,
|
||||
"ci95": 135.686
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1836.44,
|
||||
"stdev": 223.694,
|
||||
"min": 1578.141,
|
||||
"max": 1965.781,
|
||||
"ci95": 555.732
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3253.456,
|
||||
"stdev": 68.323,
|
||||
"min": 3178.409,
|
||||
"max": 3312.05,
|
||||
"ci95": 169.737
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3144.363,
|
||||
"stdev": 69.868,
|
||||
"min": 3069.394,
|
||||
"max": 3207.66,
|
||||
"ci95": 173.576
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1272,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 105.979,
|
||||
"low_1pct_fps": 21.25,
|
||||
"low_01pct_fps": 17.394,
|
||||
"p50_ms": 7.527,
|
||||
"p95_ms": 22.34,
|
||||
"p99_ms": 40.461,
|
||||
"p999_ms": 54.28,
|
||||
"max_ms": 60.7,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
56,
|
||||
161,
|
||||
170,
|
||||
180,
|
||||
167,
|
||||
184,
|
||||
118,
|
||||
71,
|
||||
61,
|
||||
26,
|
||||
25,
|
||||
28,
|
||||
12,
|
||||
10,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1723,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 143.496,
|
||||
"low_1pct_fps": 24.308,
|
||||
"low_01pct_fps": 13.602,
|
||||
"p50_ms": 5.317,
|
||||
"p95_ms": 16.181,
|
||||
"p99_ms": 29.771,
|
||||
"p999_ms": 56.832,
|
||||
"max_ms": 90.209,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
63,
|
||||
413,
|
||||
271,
|
||||
273,
|
||||
185,
|
||||
148,
|
||||
112,
|
||||
89,
|
||||
47,
|
||||
43,
|
||||
28,
|
||||
22,
|
||||
18,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 346,
|
||||
"seconds": 12.05,
|
||||
"avg_fps": 28.713,
|
||||
"low_1pct_fps": 4.001,
|
||||
"low_01pct_fps": 3.549,
|
||||
"p50_ms": 23.684,
|
||||
"p95_ms": 110.057,
|
||||
"p99_ms": 225.236,
|
||||
"p999_ms": 281.776,
|
||||
"max_ms": 281.776,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
8,
|
||||
6,
|
||||
6,
|
||||
12,
|
||||
21,
|
||||
20,
|
||||
23,
|
||||
25,
|
||||
24,
|
||||
32,
|
||||
50,
|
||||
28,
|
||||
24,
|
||||
27,
|
||||
17,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 589,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 49.081,
|
||||
"low_1pct_fps": 8.19,
|
||||
"low_01pct_fps": 6.325,
|
||||
"p50_ms": 15.443,
|
||||
"p95_ms": 53.004,
|
||||
"p99_ms": 94.69,
|
||||
"p999_ms": 158.114,
|
||||
"max_ms": 158.114,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
7,
|
||||
20,
|
||||
30,
|
||||
46,
|
||||
53,
|
||||
48,
|
||||
56,
|
||||
62,
|
||||
61,
|
||||
66,
|
||||
63,
|
||||
18,
|
||||
22,
|
||||
20,
|
||||
10,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2731,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 227.513,
|
||||
"low_1pct_fps": 53.473,
|
||||
"low_01pct_fps": 33.042,
|
||||
"p50_ms": 3.801,
|
||||
"p95_ms": 7.955,
|
||||
"p99_ms": 14.177,
|
||||
"p999_ms": 24.479,
|
||||
"max_ms": 38.218,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
526,
|
||||
1157,
|
||||
432,
|
||||
257,
|
||||
151,
|
||||
88,
|
||||
46,
|
||||
32,
|
||||
13,
|
||||
15,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 527,
|
||||
"seconds": 12.016,
|
||||
"avg_fps": 43.857,
|
||||
"low_1pct_fps": 7.338,
|
||||
"low_01pct_fps": 5.157,
|
||||
"p50_ms": 16.784,
|
||||
"p95_ms": 57.353,
|
||||
"p99_ms": 111.984,
|
||||
"p999_ms": 193.905,
|
||||
"max_ms": 193.905,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
9,
|
||||
16,
|
||||
29,
|
||||
40,
|
||||
61,
|
||||
50,
|
||||
54,
|
||||
52,
|
||||
62,
|
||||
57,
|
||||
35,
|
||||
28,
|
||||
11,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 746,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 62.162,
|
||||
"low_1pct_fps": 14.965,
|
||||
"low_01pct_fps": 12.372,
|
||||
"p50_ms": 13.76,
|
||||
"p95_ms": 35.144,
|
||||
"p99_ms": 57.63,
|
||||
"p999_ms": 80.827,
|
||||
"max_ms": 80.827,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
13,
|
||||
12,
|
||||
41,
|
||||
37,
|
||||
53,
|
||||
67,
|
||||
82,
|
||||
80,
|
||||
99,
|
||||
82,
|
||||
76,
|
||||
58,
|
||||
17,
|
||||
11,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3910,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 325.77,
|
||||
"low_1pct_fps": 79.024,
|
||||
"low_01pct_fps": 41.466,
|
||||
"p50_ms": 2.685,
|
||||
"p95_ms": 5.125,
|
||||
"p99_ms": 8.342,
|
||||
"p999_ms": 20.782,
|
||||
"max_ms": 30.571,
|
||||
"histogram": [
|
||||
0,
|
||||
78,
|
||||
2526,
|
||||
892,
|
||||
203,
|
||||
95,
|
||||
49,
|
||||
27,
|
||||
13,
|
||||
11,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1268,
|
||||
"seconds": 12.044,
|
||||
"avg_fps": 105.282,
|
||||
"low_1pct_fps": 14.736,
|
||||
"low_01pct_fps": 8.855,
|
||||
"p50_ms": 6.542,
|
||||
"p95_ms": 25.245,
|
||||
"p99_ms": 50.465,
|
||||
"p999_ms": 79.065,
|
||||
"max_ms": 146.809,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
171,
|
||||
190,
|
||||
169,
|
||||
167,
|
||||
133,
|
||||
105,
|
||||
93,
|
||||
53,
|
||||
43,
|
||||
35,
|
||||
41,
|
||||
26,
|
||||
13,
|
||||
11,
|
||||
10,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 822,
|
||||
"seconds": 12.011,
|
||||
"avg_fps": 68.44,
|
||||
"low_1pct_fps": 14.632,
|
||||
"low_01pct_fps": 10.101,
|
||||
"p50_ms": 11.396,
|
||||
"p95_ms": 35.387,
|
||||
"p99_ms": 55.24,
|
||||
"p999_ms": 99.005,
|
||||
"max_ms": 99.005,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
42,
|
||||
62,
|
||||
55,
|
||||
86,
|
||||
89,
|
||||
97,
|
||||
64,
|
||||
73,
|
||||
74,
|
||||
72,
|
||||
48,
|
||||
21,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 4165,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 346.896,
|
||||
"low_1pct_fps": 75.429,
|
||||
"low_01pct_fps": 33.579,
|
||||
"p50_ms": 2.541,
|
||||
"p95_ms": 4.838,
|
||||
"p99_ms": 7.998,
|
||||
"p999_ms": 20.209,
|
||||
"max_ms": 53.577,
|
||||
"histogram": [
|
||||
0,
|
||||
640,
|
||||
2307,
|
||||
855,
|
||||
178,
|
||||
93,
|
||||
34,
|
||||
22,
|
||||
15,
|
||||
3,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 851,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 70.916,
|
||||
"low_1pct_fps": 11.158,
|
||||
"low_01pct_fps": 7.903,
|
||||
"p50_ms": 10.128,
|
||||
"p95_ms": 36.498,
|
||||
"p99_ms": 68.434,
|
||||
"p999_ms": 126.541,
|
||||
"max_ms": 126.541,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
61,
|
||||
87,
|
||||
76,
|
||||
69,
|
||||
84,
|
||||
100,
|
||||
63,
|
||||
53,
|
||||
68,
|
||||
53,
|
||||
48,
|
||||
15,
|
||||
9,
|
||||
16,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -1,771 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:08:37Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 18.159,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 8607.88,
|
||||
"stdev": 949.596,
|
||||
"min": 8010.323,
|
||||
"max": 9702.854,
|
||||
"ci95": 2359.119
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 44182.88,
|
||||
"stdev": 4031.933,
|
||||
"min": 40172.323,
|
||||
"max": 48235.854,
|
||||
"ci95": 10016.685
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17227.171,
|
||||
"stdev": 5351.234,
|
||||
"min": 13892.279,
|
||||
"max": 23399.566,
|
||||
"ci95": 13294.274
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 76.237,
|
||||
"stdev": 45.661,
|
||||
"min": 28.922,
|
||||
"max": 120.041,
|
||||
"ci95": 113.437
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.319,
|
||||
"stdev": 12.99,
|
||||
"min": 5.593,
|
||||
"max": 31.283,
|
||||
"ci95": 32.272
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.642,
|
||||
"stdev": 9.463,
|
||||
"min": 4.308,
|
||||
"max": 22.93,
|
||||
"ci95": 23.51
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2674.987,
|
||||
"stdev": 30.71,
|
||||
"min": 2641.352,
|
||||
"max": 2701.531,
|
||||
"ci95": 76.294
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.341,
|
||||
"stdev": 0.407,
|
||||
"min": 0.924,
|
||||
"max": 1.737,
|
||||
"ci95": 1.012
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 34112.389,
|
||||
"stdev": 19305.487,
|
||||
"min": 22633.094,
|
||||
"max": 56401.09,
|
||||
"ci95": 47961.36
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.848,
|
||||
"stdev": 51.859,
|
||||
"min": 18.307,
|
||||
"max": 121.528,
|
||||
"ci95": 128.837
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 22.096,
|
||||
"stdev": 20.299,
|
||||
"min": 3.973,
|
||||
"max": 44.03,
|
||||
"ci95": 50.429
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.093,
|
||||
"stdev": 14.608,
|
||||
"min": 3.688,
|
||||
"max": 32.194,
|
||||
"ci95": 36.291
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2519.729,
|
||||
"stdev": 82.097,
|
||||
"min": 2426.297,
|
||||
"max": 2580.328,
|
||||
"ci95": 203.957
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.115,
|
||||
"stdev": 0.411,
|
||||
"min": 0.726,
|
||||
"max": 1.545,
|
||||
"ci95": 1.02
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19066.516,
|
||||
"stdev": 9372.33,
|
||||
"min": 13163.781,
|
||||
"max": 29873.383,
|
||||
"ci95": 23284.039
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 69.735,
|
||||
"stdev": 46.656,
|
||||
"min": 16.524,
|
||||
"max": 103.635,
|
||||
"ci95": 115.909
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.636,
|
||||
"stdev": 13.616,
|
||||
"min": 3.179,
|
||||
"max": 30.217,
|
||||
"ci95": 33.827
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.115,
|
||||
"stdev": 10.798,
|
||||
"min": 2.584,
|
||||
"max": 23.842,
|
||||
"ci95": 26.825
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2557.143,
|
||||
"stdev": 84.375,
|
||||
"min": 2463.781,
|
||||
"max": 2627.945,
|
||||
"ci95": 209.616
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.056,
|
||||
"stdev": 0.233,
|
||||
"min": 0.815,
|
||||
"max": 1.281,
|
||||
"ci95": 0.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 62214.712,
|
||||
"stdev": 4884.13,
|
||||
"min": 58937.045,
|
||||
"max": 67828.14,
|
||||
"ci95": 12133.83
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.809,
|
||||
"stdev": 7.118,
|
||||
"min": 45.235,
|
||||
"max": 59.369,
|
||||
"ci95": 17.685
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.336,
|
||||
"stdev": 1.097,
|
||||
"min": 5.545,
|
||||
"max": 7.588,
|
||||
"ci95": 2.725
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.458,
|
||||
"stdev": 0.756,
|
||||
"min": 2.827,
|
||||
"max": 4.296,
|
||||
"ci95": 1.878
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2604.227,
|
||||
"stdev": 15.043,
|
||||
"min": 2588.234,
|
||||
"max": 2618.094,
|
||||
"ci95": 37.371
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.957,
|
||||
"stdev": 0.03,
|
||||
"min": 0.926,
|
||||
"max": 0.985,
|
||||
"ci95": 0.074
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2731.016,
|
||||
"stdev": 56.767,
|
||||
"min": 2688.516,
|
||||
"max": 2795.484,
|
||||
"ci95": 141.03
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2577.339,
|
||||
"stdev": 77.295,
|
||||
"min": 2488.203,
|
||||
"max": 2625.875,
|
||||
"ci95": 192.028
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4612.571,
|
||||
"stdev": 20.245,
|
||||
"min": 4595.363,
|
||||
"max": 4634.879,
|
||||
"ci95": 50.296
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4271.925,
|
||||
"stdev": 26.308,
|
||||
"min": 4246.207,
|
||||
"max": 4298.785,
|
||||
"ci95": 65.357
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 349,
|
||||
"seconds": 12.067,
|
||||
"avg_fps": 28.922,
|
||||
"low_1pct_fps": 5.593,
|
||||
"low_01pct_fps": 4.308,
|
||||
"p50_ms": 24.684,
|
||||
"p95_ms": 92.764,
|
||||
"p99_ms": 150.338,
|
||||
"p999_ms": 232.136,
|
||||
"max_ms": 232.136,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
13,
|
||||
17,
|
||||
26,
|
||||
31,
|
||||
40,
|
||||
46,
|
||||
50,
|
||||
32,
|
||||
25,
|
||||
20,
|
||||
30,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1441,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 120.041,
|
||||
"low_1pct_fps": 31.283,
|
||||
"low_01pct_fps": 22.93,
|
||||
"p50_ms": 7.267,
|
||||
"p95_ms": 13.622,
|
||||
"p99_ms": 23.449,
|
||||
"p999_ms": 42.564,
|
||||
"max_ms": 44.657,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
543,
|
||||
397,
|
||||
218,
|
||||
100,
|
||||
54,
|
||||
24,
|
||||
20,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 958,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 79.749,
|
||||
"low_1pct_fps": 15.082,
|
||||
"low_01pct_fps": 10.689,
|
||||
"p50_ms": 9.138,
|
||||
"p95_ms": 28.848,
|
||||
"p99_ms": 52.247,
|
||||
"p999_ms": 93.55,
|
||||
"max_ms": 93.55,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
19,
|
||||
144,
|
||||
232,
|
||||
148,
|
||||
111,
|
||||
64,
|
||||
56,
|
||||
59,
|
||||
57,
|
||||
32,
|
||||
17,
|
||||
9,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 220,
|
||||
"seconds": 12.017,
|
||||
"avg_fps": 18.307,
|
||||
"low_1pct_fps": 3.973,
|
||||
"low_01pct_fps": 3.688,
|
||||
"p50_ms": 40.635,
|
||||
"p95_ms": 157.363,
|
||||
"p99_ms": 235.253,
|
||||
"p999_ms": 271.153,
|
||||
"max_ms": 271.153,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
14,
|
||||
17,
|
||||
25,
|
||||
25,
|
||||
17,
|
||||
27,
|
||||
33,
|
||||
24,
|
||||
16,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1459,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 121.528,
|
||||
"low_1pct_fps": 44.03,
|
||||
"low_01pct_fps": 32.194,
|
||||
"p50_ms": 7.381,
|
||||
"p95_ms": 12.386,
|
||||
"p99_ms": 18.354,
|
||||
"p999_ms": 29.428,
|
||||
"max_ms": 32.695,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
357,
|
||||
642,
|
||||
300,
|
||||
80,
|
||||
28,
|
||||
28,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 945,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 78.709,
|
||||
"low_1pct_fps": 18.284,
|
||||
"low_01pct_fps": 12.397,
|
||||
"p50_ms": 9.554,
|
||||
"p95_ms": 27.657,
|
||||
"p99_ms": 43.253,
|
||||
"p999_ms": 80.663,
|
||||
"max_ms": 80.663,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18,
|
||||
304,
|
||||
191,
|
||||
119,
|
||||
67,
|
||||
59,
|
||||
53,
|
||||
70,
|
||||
34,
|
||||
15,
|
||||
10,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 201,
|
||||
"seconds": 12.164,
|
||||
"avg_fps": 16.524,
|
||||
"low_1pct_fps": 3.179,
|
||||
"low_01pct_fps": 2.584,
|
||||
"p50_ms": 44.933,
|
||||
"p95_ms": 155.019,
|
||||
"p99_ms": 263.919,
|
||||
"p999_ms": 387.044,
|
||||
"max_ms": 387.044,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
20,
|
||||
8,
|
||||
29,
|
||||
18,
|
||||
25,
|
||||
29,
|
||||
29,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 1244,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 103.635,
|
||||
"low_1pct_fps": 30.217,
|
||||
"low_01pct_fps": 23.842,
|
||||
"p50_ms": 8.349,
|
||||
"p95_ms": 17.445,
|
||||
"p99_ms": 28.276,
|
||||
"p999_ms": 38.037,
|
||||
"max_ms": 45.85,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
162,
|
||||
453,
|
||||
312,
|
||||
129,
|
||||
71,
|
||||
47,
|
||||
32,
|
||||
18,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1069,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 89.047,
|
||||
"low_1pct_fps": 19.512,
|
||||
"low_01pct_fps": 9.92,
|
||||
"p50_ms": 9.541,
|
||||
"p95_ms": 20.819,
|
||||
"p99_ms": 31.409,
|
||||
"p999_ms": 99.668,
|
||||
"max_ms": 101.942,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
266,
|
||||
283,
|
||||
160,
|
||||
112,
|
||||
74,
|
||||
51,
|
||||
33,
|
||||
17,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 551,
|
||||
"seconds": 12.181,
|
||||
"avg_fps": 45.235,
|
||||
"low_1pct_fps": 5.545,
|
||||
"low_01pct_fps": 3.252,
|
||||
"p50_ms": 14.921,
|
||||
"p95_ms": 50.78,
|
||||
"p99_ms": 105.154,
|
||||
"p999_ms": 307.476,
|
||||
"max_ms": 307.476,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
42,
|
||||
137,
|
||||
72,
|
||||
58,
|
||||
50,
|
||||
47,
|
||||
57,
|
||||
39,
|
||||
20,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 718,
|
||||
"seconds": 12.094,
|
||||
"avg_fps": 59.369,
|
||||
"low_1pct_fps": 7.588,
|
||||
"low_01pct_fps": 4.296,
|
||||
"p50_ms": 12.178,
|
||||
"p95_ms": 38.273,
|
||||
"p99_ms": 65.694,
|
||||
"p999_ms": 232.789,
|
||||
"max_ms": 232.789,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
140,
|
||||
202,
|
||||
115,
|
||||
72,
|
||||
48,
|
||||
47,
|
||||
43,
|
||||
19,
|
||||
19,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 610,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 50.822,
|
||||
"low_1pct_fps": 5.875,
|
||||
"low_01pct_fps": 2.827,
|
||||
"p50_ms": 13.894,
|
||||
"p95_ms": 44.208,
|
||||
"p99_ms": 75.023,
|
||||
"p999_ms": 353.686,
|
||||
"max_ms": 353.686,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
67,
|
||||
115,
|
||||
125,
|
||||
88,
|
||||
55,
|
||||
54,
|
||||
43,
|
||||
25,
|
||||
13,
|
||||
15,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -6,14 +6,6 @@ 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"
|
||||
|
||||
@@ -489,36 +489,15 @@ pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
@@ -327,19 +327,3 @@ fn an_allow_line_without_letters_allows_nothing() {
|
||||
assert!(f.matches("nigger"), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_parts_preserve_rounding_without_overflow() {
|
||||
for (millis, expected) in [
|
||||
(1, (1, DurationUnit::Minute)),
|
||||
(60_000, (1, DurationUnit::Minute)),
|
||||
(61_000, (2, DurationUnit::Minute)),
|
||||
(3_600_000, (60, DurationUnit::Minute)),
|
||||
(3_600_001, (2, DurationUnit::Hour)),
|
||||
(172_800_000, (48, DurationUnit::Hour)),
|
||||
(172_800_001, (3, DurationUnit::Day)),
|
||||
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
|
||||
] {
|
||||
assert_eq!(duration_parts(millis), expected);
|
||||
}
|
||||
}
|
||||
@@ -100,222 +100,6 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ur_pk.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
|
||||
}
|
||||
"assets/horizon/lang/fa_ir.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fa_ir.json")
|
||||
}
|
||||
"assets/horizon/lang/he_il.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/he_il.json")
|
||||
}
|
||||
"assets/horizon/lang/hu_hu.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hu_hu.json")
|
||||
}
|
||||
"assets/horizon/lang/ro_ro.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ro_ro.json")
|
||||
}
|
||||
"assets/horizon/lang/da_dk.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/da_dk.json")
|
||||
}
|
||||
"assets/horizon/lang/fi_fi.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fi_fi.json")
|
||||
}
|
||||
"assets/horizon/lang/no_no.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/no_no.json")
|
||||
}
|
||||
"assets/horizon/lang/bg_bg.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/bg_bg.json")
|
||||
}
|
||||
"assets/horizon/lang/sk_sk.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/sk_sk.json")
|
||||
}
|
||||
"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")
|
||||
}
|
||||
|
||||
@@ -79,65 +79,28 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
|
||||
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
|
||||
fn int_value(t: &Tag) -> Option<i32> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i32::from(*v),
|
||||
Tag::Short(v) => i32::from(*v),
|
||||
Tag::Int(v) => *v,
|
||||
Tag::Long(v) => *v as i32,
|
||||
Tag::Float(v) => f32::from_bits(*v).floor() as i32,
|
||||
Tag::Double(v) => f64::from_bits(*v).floor() as i32,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `FloatTag.longValue` truncates; `DoubleTag.longValue` floors before narrowing.
|
||||
fn long_value(t: &Tag) -> Option<i64> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
// FloatTag.longValue truncates; DoubleTag.longValue floors first.
|
||||
Tag::Float(v) => f32::from_bits(*v) as i64,
|
||||
Tag::Double(v) => f64::from_bits(*v).floor() as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum ElementKind {
|
||||
List,
|
||||
NumericArray,
|
||||
}
|
||||
impl ElementKind {
|
||||
// Admission only: arrays require NumericTag; lists accept every tag type.
|
||||
fn accepts(self, value: &Tag) -> bool {
|
||||
matches!(self, Self::List)
|
||||
|| matches!(
|
||||
value,
|
||||
Tag::Byte(_)
|
||||
| Tag::Short(_)
|
||||
| Tag::Int(_)
|
||||
| Tag::Long(_)
|
||||
| Tag::Float(_)
|
||||
| Tag::Double(_)
|
||||
)
|
||||
fn number(t: &Tag) -> Option<i64> {
|
||||
match t {
|
||||
Tag::Byte(v) => Some(i64::from(*v)),
|
||||
Tag::Short(v) => Some(i64::from(*v)),
|
||||
Tag::Int(v) => Some(i64::from(*v)),
|
||||
Tag::Long(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Rust view adapter: run `f` over the elements, then write arrays using their numeric accessor.
|
||||
/// Run `f` over the elements of a list/array, writing array results back.
|
||||
/// None when `tag` is not list-like.
|
||||
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>, ElementKind) -> R) -> Option<R> {
|
||||
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>) -> R) -> Option<R> {
|
||||
match tag {
|
||||
Tag::List(l) => Some(f(l, ElementKind::List)),
|
||||
Tag::List(l) => Some(f(l)),
|
||||
Tag::ByteArray(_) | Tag::IntArray(_) | Tag::LongArray(_) => {
|
||||
let mut v = elements(tag)?;
|
||||
let r = f(&mut v, ElementKind::NumericArray);
|
||||
let r = f(&mut v);
|
||||
let nums: Vec<i64> = v.iter().filter_map(number).collect();
|
||||
match tag {
|
||||
// Float/double byteValue uses intValue's floor, then narrows.
|
||||
Tag::ByteArray(a) => *a = v.iter().filter_map(int_value).map(|n| n as u8).collect(),
|
||||
Tag::IntArray(a) => *a = v.iter().filter_map(int_value).collect(),
|
||||
Tag::LongArray(a) => *a = v.iter().filter_map(long_value).collect(),
|
||||
Tag::ByteArray(a) => *a = nums.iter().map(|n| *n as u8).collect(),
|
||||
Tag::IntArray(a) => *a = nums.iter().map(|n| *n as i32).collect(),
|
||||
Tag::LongArray(a) => *a = nums,
|
||||
_ => {}
|
||||
}
|
||||
Some(r)
|
||||
@@ -386,7 +349,7 @@ impl NbtPath {
|
||||
});
|
||||
removed
|
||||
}
|
||||
/// `NbtPathArgument.NbtPath.insert`: insert accepted tags into every reached collection.
|
||||
/// `insert` (also append and prepend): into every list the path reaches.
|
||||
pub fn insert(
|
||||
&self,
|
||||
index: i32,
|
||||
@@ -409,23 +372,19 @@ impl NbtPath {
|
||||
return;
|
||||
}
|
||||
let shown = crate::snbt::to_snbt(target);
|
||||
let result = with_elements(target, |list, kind| {
|
||||
let result = with_elements(target, |list| {
|
||||
let start = if index < 0 {
|
||||
list.len() as i64 + i64::from(index) + 1
|
||||
} else {
|
||||
i64::from(index)
|
||||
};
|
||||
let mut any = false;
|
||||
let mut at = start;
|
||||
for v in values {
|
||||
if !kind.accepts(v) {
|
||||
continue;
|
||||
}
|
||||
for (n, v) in values.iter().enumerate() {
|
||||
let at = start + n as i64;
|
||||
if at < 0 || at as usize > list.len() {
|
||||
return Err(CollectionError::InvalidIndex(at as i32));
|
||||
}
|
||||
list.insert(at as usize, v.clone());
|
||||
at += 1;
|
||||
any = true;
|
||||
}
|
||||
Ok(any)
|
||||
@@ -469,8 +428,7 @@ fn preferred_parent(node: &Node) -> Tag {
|
||||
_ => Tag::Compound(Compound::new()),
|
||||
}
|
||||
}
|
||||
/// Children of `parent`; the array creation arm follows
|
||||
/// `NbtPathArgument.AllElementsNode.getOrCreateTag` admission.
|
||||
/// `getOrCreateTag` (or `getTag` without `create`): the children of `parent`.
|
||||
fn step_mut(
|
||||
node: &Node,
|
||||
parent: &mut Tag,
|
||||
@@ -510,14 +468,11 @@ fn step_mut(
|
||||
}
|
||||
Node::AllElements => {
|
||||
let make = create;
|
||||
let _ = with_elements(parent, |list, kind| {
|
||||
let _ = with_elements(parent, |list| {
|
||||
if list.is_empty() {
|
||||
if let Some(make) = make {
|
||||
let value = make();
|
||||
if kind.accepts(&value) {
|
||||
list.push(value);
|
||||
out(&mut list[0]);
|
||||
}
|
||||
list.push(make());
|
||||
out(&mut list[0]);
|
||||
}
|
||||
} else {
|
||||
for t in list.iter_mut() {
|
||||
@@ -527,7 +482,7 @@ fn step_mut(
|
||||
});
|
||||
}
|
||||
Node::Index(i) => {
|
||||
let _ = with_elements(parent, |list, _kind| {
|
||||
let _ = with_elements(parent, |list| {
|
||||
let n = list.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
@@ -563,8 +518,7 @@ fn step_mut(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Element setters follow `NbtPathArgument.AllElementsNode.setTag` and
|
||||
/// `NbtPathArgument.IndexedElementNode.setTag`; object nodes replace their children.
|
||||
/// `Node.setTag`: replace the child(ren) of `parent` with `value`.
|
||||
fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
match node {
|
||||
Node::Child(name) => {
|
||||
@@ -584,36 +538,29 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
}
|
||||
0
|
||||
}
|
||||
Node::AllElements => with_elements(parent, |list, kind| {
|
||||
let len = list.len();
|
||||
if len == 0 {
|
||||
if kind.accepts(value) {
|
||||
list.push(value.clone());
|
||||
}
|
||||
// AllElementsNode returns 1 here even when addTag rejects.
|
||||
Node::AllElements => with_elements(parent, |list| {
|
||||
if list.is_empty() {
|
||||
list.push(value.clone());
|
||||
return 1;
|
||||
}
|
||||
// Compare the original tags, before numeric conversion.
|
||||
let changed = list.iter().filter(|t| *t != value).count();
|
||||
if changed == 0 {
|
||||
return 0;
|
||||
}
|
||||
list.clear();
|
||||
if !kind.accepts(value) {
|
||||
return 0;
|
||||
for t in list.iter_mut() {
|
||||
*t = value.clone();
|
||||
}
|
||||
list.resize(len, value.clone());
|
||||
changed
|
||||
})
|
||||
.unwrap_or(0),
|
||||
Node::Index(i) => with_elements(parent, |list, kind| {
|
||||
Node::Index(i) => with_elements(parent, |list| {
|
||||
let n = list.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
} else {
|
||||
i64::from(*i)
|
||||
};
|
||||
if (0..n).contains(&idx) && list[idx as usize] != *value && kind.accepts(value) {
|
||||
if (0..n).contains(&idx) && list[idx as usize] != *value {
|
||||
list[idx as usize] = value.clone();
|
||||
1
|
||||
} else {
|
||||
@@ -639,7 +586,6 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
Node::MatchRoot(_) => 0,
|
||||
}
|
||||
}
|
||||
/// Removal arms, including `NbtPathArgument.AllElementsNode.removeTag`.
|
||||
fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
|
||||
match node {
|
||||
Node::Child(name) => match parent {
|
||||
@@ -657,13 +603,13 @@ fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
|
||||
}
|
||||
_ => 0,
|
||||
},
|
||||
Node::AllElements => with_elements(parent, |l, _kind| {
|
||||
Node::AllElements => with_elements(parent, |l| {
|
||||
let n = l.len();
|
||||
l.clear();
|
||||
n
|
||||
})
|
||||
.unwrap_or(0),
|
||||
Node::Index(i) => with_elements(parent, |l, _kind| {
|
||||
Node::Index(i) => with_elements(parent, |l| {
|
||||
let n = l.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
@@ -941,100 +887,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_array_edits_use_the_destination_numeric_accessor() {
|
||||
for (initial, value, inserted, indexed, all) in [
|
||||
(
|
||||
"{v:[B;1b,2b]}",
|
||||
"-3.5f",
|
||||
"{v:[B;1b,-4b,2b]}",
|
||||
"{v:[B;-4b,2b]}",
|
||||
"{v:[B;-4b,-4b]}",
|
||||
),
|
||||
(
|
||||
"{v:[B;1b,2b]}",
|
||||
"-3.5d",
|
||||
"{v:[B;1b,-4b,2b]}",
|
||||
"{v:[B;-4b,2b]}",
|
||||
"{v:[B;-4b,-4b]}",
|
||||
),
|
||||
(
|
||||
"{v:[I;1,2]}",
|
||||
"-3.5f",
|
||||
"{v:[I;1,-4,2]}",
|
||||
"{v:[I;-4,2]}",
|
||||
"{v:[I;-4,-4]}",
|
||||
),
|
||||
(
|
||||
"{v:[I;1,2]}",
|
||||
"-3.5d",
|
||||
"{v:[I;1,-4,2]}",
|
||||
"{v:[I;-4,2]}",
|
||||
"{v:[I;-4,-4]}",
|
||||
),
|
||||
(
|
||||
"{v:[L;1L,2L]}",
|
||||
"-3.5f",
|
||||
"{v:[L;1L,-3L,2L]}",
|
||||
"{v:[L;-3L,2L]}",
|
||||
"{v:[L;-3L,-3L]}",
|
||||
),
|
||||
(
|
||||
"{v:[L;1L,2L]}",
|
||||
"-3.5d",
|
||||
"{v:[L;1L,-4L,2L]}",
|
||||
"{v:[L;-4L,2L]}",
|
||||
"{v:[L;-4L,-4L]}",
|
||||
),
|
||||
] {
|
||||
let value = tag(value);
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(
|
||||
path("v")
|
||||
.insert(1, &mut root, std::slice::from_ref(&value))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(root, tag(inserted));
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 1);
|
||||
assert_eq!(root, tag(indexed));
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[]").set(&mut root, &value).unwrap(), 2);
|
||||
assert_eq!(root, tag(all));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_arrays_reject_nonnumeric_insert_and_indexed_set() {
|
||||
for initial in ["{v:[B;1b,2b]}", "{v:[I;1,2]}", "{v:[L;1L,2L]}"] {
|
||||
let original = tag(initial);
|
||||
let value = tag("\"bad\"");
|
||||
for index in [0, 99, -99] {
|
||||
let mut root = original.clone();
|
||||
assert_eq!(
|
||||
path("v")
|
||||
.insert(index, &mut root, std::slice::from_ref(&value))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(root, original);
|
||||
}
|
||||
let mut root = original.clone();
|
||||
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 0);
|
||||
assert_eq!(root, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_array_wildcard_set_preserves_vanillas_count_on_rejection() {
|
||||
for initial in ["{v:[B;]}", "{v:[I;]}", "{v:[L;]}"] {
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[]").set(&mut root, &tag("\"bad\"")).unwrap(), 1);
|
||||
assert_eq!(root, tag(initial));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_comparison() {
|
||||
let actual = tag("{a:1,b:{c:[1,2,3],d:\"x\"},e:2}");
|
||||
|
||||
@@ -173,16 +173,6 @@ 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",
|
||||
@@ -246,16 +236,6 @@ 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());
|
||||
|
||||
@@ -179,13 +179,12 @@ impl PadState {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Edges {
|
||||
held: u32,
|
||||
suppressed: u32,
|
||||
}
|
||||
impl Edges {
|
||||
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
|
||||
let mut now = state.buttons;
|
||||
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
|
||||
let was = (self.held | self.suppressed) & button.bit() != 0;
|
||||
let was = self.held & button.bit() != 0;
|
||||
let value = state.triggers[trigger];
|
||||
let on = if was {
|
||||
value >= (threshold - 0.1).max(0.05)
|
||||
@@ -198,8 +197,6 @@ impl Edges {
|
||||
now &= !button.bit();
|
||||
}
|
||||
}
|
||||
self.suppressed &= now;
|
||||
now &= !self.suppressed;
|
||||
let changed = now ^ self.held;
|
||||
self.held = now;
|
||||
Button::ALL
|
||||
@@ -220,50 +217,6 @@ impl Edges {
|
||||
pub fn is_held(&self, button: Button) -> bool {
|
||||
self.held & button.bit() != 0
|
||||
}
|
||||
/// Cancel a context without turning its still-held buttons into new presses.
|
||||
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
|
||||
self.suppressed |= self.held;
|
||||
self.release_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Intentional activity may take input ownership; an unchanged hold may not.
|
||||
#[derive(Default)]
|
||||
pub struct Activity {
|
||||
buttons: u32,
|
||||
sticks: [[f32; 2]; 2],
|
||||
}
|
||||
impl Activity {
|
||||
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
|
||||
let mut buttons = state.buttons;
|
||||
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
|
||||
let threshold = if self.buttons & button.bit() != 0 {
|
||||
(settings.trigger_threshold - 0.1).max(0.05)
|
||||
} else {
|
||||
settings.trigger_threshold
|
||||
};
|
||||
if state.triggers[i] >= threshold {
|
||||
buttons |= button.bit();
|
||||
}
|
||||
}
|
||||
let mut activity = buttons & !self.buttons != 0;
|
||||
self.buttons = buttons;
|
||||
for (i, (stick, deadzone)) in [
|
||||
(state.left, settings.move_deadzone),
|
||||
(state.right, settings.look_deadzone),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if length(stick) <= deadzone {
|
||||
self.sticks[i] = [0.0; 2];
|
||||
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
|
||||
activity = true;
|
||||
self.sticks[i] = stick;
|
||||
}
|
||||
}
|
||||
activity
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
@@ -295,39 +248,6 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.smoothCamera", None),
|
||||
];
|
||||
|
||||
/// How a bindable action reaches the game: one of its own handlers, shared
|
||||
/// with the keyboard where the keyboard has one, or else the vanilla default
|
||||
/// key or button of its mapping, as a rebound keyboard key would.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Key(winit::keyboard::KeyCode),
|
||||
Mouse(winit::event::MouseButton),
|
||||
}
|
||||
|
||||
/// The route of a bindable action; `None` for one the pad can't reach.
|
||||
pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
crate::keybinds::mouse_button(default)
|
||||
.map(Route::Mouse)
|
||||
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
@@ -393,6 +313,31 @@ impl GlyphStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a controller drives menus (docs/controllers.md, "Menus and
|
||||
/// inventories").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum MenuNav {
|
||||
/// A console-style highlight moved between widgets and slots.
|
||||
#[default]
|
||||
Focus,
|
||||
/// A virtual mouse pointer.
|
||||
Pointer,
|
||||
}
|
||||
impl MenuNav {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MenuNav::Focus => "focus",
|
||||
MenuNav::Pointer => "pointer",
|
||||
}
|
||||
}
|
||||
pub fn parse(name: &str) -> Self {
|
||||
match name {
|
||||
"pointer" => MenuNav::Pointer,
|
||||
_ => MenuNav::Focus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard A opens on a text field in focus mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TextKeyboard {
|
||||
@@ -454,12 +399,9 @@ pub struct PadSettings {
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// How the pad drives menus: a focus highlight or a virtual pointer.
|
||||
pub menu_nav: MenuNav,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
pub scroll_speed: f32,
|
||||
pub repeat_delay: f32,
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
}
|
||||
@@ -478,11 +420,8 @@ impl Default for PadSettings {
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
menu_nav: MenuNav::Focus,
|
||||
keyboard: TextKeyboard::Auto,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -523,13 +462,6 @@ impl PadSettings {
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(d, |v| v.clamp(0.0, 1.0))
|
||||
};
|
||||
let bounded = |default: f32, min: f32, max: f32| {
|
||||
value
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(default, |v| v.clamp(min, max))
|
||||
};
|
||||
match key.trim() {
|
||||
"enabled" => s.enabled = flag(s.enabled),
|
||||
"vibration" => s.vibration = flag(s.vibration),
|
||||
@@ -545,11 +477,8 @@ impl PadSettings {
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
other => {
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
@@ -568,7 +497,7 @@ impl PadSettings {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
|
||||
bool_text(self.enabled),
|
||||
bool_text(self.vibration),
|
||||
bool_text(self.adaptive_triggers),
|
||||
@@ -581,12 +510,9 @@ impl PadSettings {
|
||||
self.trigger_threshold,
|
||||
bool_text(self.toggle_sneak),
|
||||
self.glyphs.name(),
|
||||
self.menu_nav.name(),
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
for (action, button) in &self.bindings {
|
||||
text.push_str(&format!(
|
||||
"bind_{action}:{}\n",
|
||||
@@ -1237,131 +1163,6 @@ pub fn bow_power(ticks: u32) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_bindable_action_reaches_the_game() {
|
||||
for (action, _) in ACTIONS {
|
||||
assert!(route(action).is_some(), "{action} has nowhere to go");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_preferences_migrate_and_stay_with_each_player() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-pad-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let p2 = dir.join("P2");
|
||||
fs::create_dir_all(&p2).unwrap();
|
||||
let fresh = PadSettings::load(&dir);
|
||||
assert_eq!(fresh, PadSettings::default());
|
||||
// An older file, with the removed menuNavigation key: bindings kept.
|
||||
let legacy =
|
||||
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
|
||||
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
|
||||
assert_eq!(legacy.look_sensitivity, 0.7);
|
||||
fresh.save(&dir);
|
||||
assert!(!dir.join(FILE).exists(), "defaults write no file");
|
||||
let mut other = legacy;
|
||||
other.pointer_speed = 2.5;
|
||||
other.scroll_speed = 24.0;
|
||||
other.repeat_delay = 0.6;
|
||||
other.repeat_interval = 0.12;
|
||||
other.save(&p2);
|
||||
assert_eq!(PadSettings::load(&p2), other);
|
||||
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
|
||||
let invalid = PadSettings::parse(
|
||||
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
|
||||
);
|
||||
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
|
||||
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
|
||||
let bounded = PadSettings::parse(
|
||||
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
|
||||
);
|
||||
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
|
||||
assert_eq!(
|
||||
(bounded.repeat_delay, bounded.repeat_interval),
|
||||
(0.35, 0.09)
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_requires_new_activity_and_ignores_drift() {
|
||||
let settings = PadSettings::default();
|
||||
let mut p1 = Activity::default();
|
||||
let mut p2 = Activity::default();
|
||||
let mut state = PadState {
|
||||
left: [0.04, 0.03],
|
||||
..PadState::default()
|
||||
};
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.set(Button::South, true);
|
||||
assert!(p1.observe(&state, &settings));
|
||||
assert!(
|
||||
!p1.observe(&state, &settings),
|
||||
"held pad cannot reclaim mouse ownership"
|
||||
);
|
||||
assert!(
|
||||
p2.observe(&state, &settings),
|
||||
"another player's tracker is independent"
|
||||
);
|
||||
state.set(Button::South, false);
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.left = [0.5, 0.0];
|
||||
assert!(p1.observe(&state, &settings));
|
||||
state.left = [0.52, 0.0];
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
for x in [0.54, 0.56] {
|
||||
state.left = [x, 0.0];
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
}
|
||||
state.left = [0.6, 0.0];
|
||||
assert!(
|
||||
p1.observe(&state, &settings),
|
||||
"slow intentional movement accumulates"
|
||||
);
|
||||
state.left = [0.0; 2];
|
||||
state.triggers[0] = 0.5;
|
||||
assert!(p1.observe(&state, &settings));
|
||||
state.triggers[0] = 0.35;
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.triggers[0] = 0.45;
|
||||
assert!(
|
||||
!p1.observe(&state, &settings),
|
||||
"trigger threshold noise is not new activity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
|
||||
let mut edges = Edges::default();
|
||||
let mut state = PadState::default();
|
||||
state.set(Button::South, true);
|
||||
state.triggers[1] = 0.8;
|
||||
assert_eq!(
|
||||
edges.update(&state, 0.4),
|
||||
vec![(Button::South, true), (Button::RightTrigger, true)]
|
||||
);
|
||||
assert_eq!(
|
||||
edges.cancel(),
|
||||
vec![(Button::South, false), (Button::RightTrigger, false)]
|
||||
);
|
||||
assert!(edges.update(&state, 0.4).is_empty());
|
||||
state.triggers[1] = 0.35;
|
||||
assert!(edges.update(&state, 0.4).is_empty());
|
||||
assert!(edges.update(&PadState::default(), 0.4).is_empty());
|
||||
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
|
||||
state.triggers[1] = 0.7;
|
||||
assert_eq!(
|
||||
edges.update(&state, 0.4),
|
||||
vec![(Button::RightTrigger, true)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_follow_bedrock() {
|
||||
let s = PadSettings::default();
|
||||
@@ -1613,13 +1414,14 @@ mod tests {
|
||||
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
|
||||
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
|
||||
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
|
||||
let s = PadSettings {
|
||||
keyboard: TextKeyboard::Horizon,
|
||||
..PadSettings::default()
|
||||
};
|
||||
let mut s = PadSettings::default();
|
||||
assert_eq!(s.menu_nav, MenuNav::Focus);
|
||||
s.menu_nav = MenuNav::Pointer;
|
||||
s.keyboard = TextKeyboard::Horizon;
|
||||
let back = PadSettings::parse(&s.serialize());
|
||||
assert_eq!(
|
||||
PadSettings::parse(&s.serialize()).keyboard,
|
||||
TextKeyboard::Horizon
|
||||
(back.menu_nav, back.keyboard),
|
||||
(MenuNav::Pointer, TextKeyboard::Horizon)
|
||||
);
|
||||
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
//! The keys a player's mappings hold, as their vanilla default keys, and which
|
||||
//! devices hold each one. A key stays held while any device holds it, so one
|
||||
//! device letting go (splitscreen moving the keyboard to another player, say)
|
||||
//! can't release what another still presses.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Where a press came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The keyboard and mouse, and the scripting socket acting as them.
|
||||
Keyboard,
|
||||
Pad,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn bit(self) -> u8 {
|
||||
match self {
|
||||
Source::Keyboard => 1,
|
||||
Source::Pad => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HeldKeys {
|
||||
held: HashMap<KeyCode, u8>,
|
||||
}
|
||||
|
||||
impl HeldKeys {
|
||||
pub fn contains(&self, key: &KeyCode) -> bool {
|
||||
self.held.contains_key(key)
|
||||
}
|
||||
/// `source` holds `key`. True when nothing held it before.
|
||||
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
|
||||
let sources = self.held.entry(key).or_insert(0);
|
||||
let new = *sources == 0;
|
||||
*sources |= source.bit();
|
||||
new
|
||||
}
|
||||
/// `source` lets go of `key`. True when nothing holds it any more.
|
||||
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
|
||||
let Some(sources) = self.held.get_mut(key) else {
|
||||
return true;
|
||||
};
|
||||
*sources &= !source.bit();
|
||||
if *sources == 0 {
|
||||
self.held.remove(key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
|
||||
self.held
|
||||
.get(key)
|
||||
.is_some_and(|sources| sources & source.bit() != 0)
|
||||
}
|
||||
/// `source` lets go of everything: the keys nothing holds any more.
|
||||
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
|
||||
let keys: Vec<KeyCode> = self
|
||||
.held
|
||||
.iter()
|
||||
.filter(|(_, sources)| *sources & source.bit() != 0)
|
||||
.map(|(key, _)| *key)
|
||||
.collect();
|
||||
keys.into_iter()
|
||||
.filter(|key| self.release(key, source))
|
||||
.collect()
|
||||
}
|
||||
/// Held, by the keyboard unless a device already holds it: for callers
|
||||
/// that don't say where the press came from.
|
||||
pub fn insert(&mut self, key: KeyCode) -> bool {
|
||||
if self.contains(&key) {
|
||||
return false;
|
||||
}
|
||||
self.press(key, Source::Keyboard)
|
||||
}
|
||||
/// Let go of `key` whoever holds it.
|
||||
pub fn remove(&mut self, key: &KeyCode) -> bool {
|
||||
self.held.remove(key).is_some()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.held.clear();
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.held.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.held.len()
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
|
||||
self.held.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for HeldKeys {
|
||||
type Item = KeyCode;
|
||||
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.held.into_keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a HeldKeys {
|
||||
type Item = &'a KeyCode;
|
||||
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.held.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<KeyCode> for HeldKeys {
|
||||
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
|
||||
let mut held = HeldKeys::default();
|
||||
for key in keys {
|
||||
held.insert(key);
|
||||
}
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_key_stays_held_while_any_device_holds_it() {
|
||||
let mut keys = HeldKeys::default();
|
||||
assert!(keys.press(KeyCode::Space, Source::Pad));
|
||||
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
|
||||
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
|
||||
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
|
||||
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
|
||||
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.release(&KeyCode::Space, Source::Pad));
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_inserts_and_removes_act_as_before() {
|
||||
let mut keys = HeldKeys::default();
|
||||
keys.press(KeyCode::ShiftLeft, Source::Pad);
|
||||
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
|
||||
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
|
||||
assert!(
|
||||
keys.remove(&KeyCode::ShiftLeft),
|
||||
"remove lets go for everyone"
|
||||
);
|
||||
assert!(!keys.contains(&KeyCode::ShiftLeft));
|
||||
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
@@ -13,5 +12,4 @@ pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
@@ -1,152 +0,0 @@
|
||||
//! Where the controller's menu cursor settles once the stick is let go, and
|
||||
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
|
||||
//! be adjusted in one place.
|
||||
|
||||
/// The snapping engine's knobs, in GUI pixels and seconds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tuning {
|
||||
/// A let-go cursor settles on a target this close to it.
|
||||
pub radius: f32,
|
||||
/// How long the glide onto a target takes.
|
||||
pub glide_seconds: f32,
|
||||
/// Movement shorter than this is a wobble: the cursor stays where it was.
|
||||
pub min_travel: f32,
|
||||
/// A flick that ends still nearest its starting target commits to the
|
||||
/// neighbour it was heading for once it covered this fraction of the way.
|
||||
pub commit: f32,
|
||||
/// How far off the flick's direction a neighbour may lie (cosine of the
|
||||
/// angle: 0.7 is about 45 degrees).
|
||||
pub cone: f32,
|
||||
/// The pointer's speed over a slot or control, as a fraction of its
|
||||
/// speed elsewhere (1 leaves it unchanged).
|
||||
pub over_target_speed: f32,
|
||||
}
|
||||
|
||||
pub const TUNING: Tuning = Tuning {
|
||||
radius: 16.0,
|
||||
glide_seconds: 0.025,
|
||||
min_travel: 1.5,
|
||||
commit: 0.15,
|
||||
cone: 0.7,
|
||||
over_target_speed: 1.0,
|
||||
};
|
||||
|
||||
type Point = (f32, f32);
|
||||
|
||||
fn distance(a: Point, b: Point) -> f32 {
|
||||
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The target nearest `at`, if one lies within `radius`.
|
||||
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
|
||||
targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| (i, distance(at, *t)))
|
||||
.filter(|(_, d)| *d <= radius)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Where a stick movement from `start` to `end` settles among `targets`
|
||||
/// (their centres), or `None` to leave the cursor where it is.
|
||||
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
|
||||
let near = nearest(end, targets, tuning.radius);
|
||||
let travel = (end.0 - start.0, end.1 - start.1);
|
||||
let length = distance(start, end);
|
||||
if length < tuning.min_travel {
|
||||
return near;
|
||||
}
|
||||
let origin = nearest(start, targets, tuning.radius);
|
||||
if near != origin {
|
||||
return near;
|
||||
}
|
||||
// Still nearest where it started: a quick flick toward a neighbour.
|
||||
let Some(o) = origin else {
|
||||
return near;
|
||||
};
|
||||
let from = targets[o];
|
||||
let heading = (travel.0 / length, travel.1 / length);
|
||||
let next = targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != o)
|
||||
.filter_map(|(i, t)| {
|
||||
let to = (t.0 - from.0, t.1 - from.1);
|
||||
let gap = distance(from, *t);
|
||||
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
|
||||
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
match next {
|
||||
Some((i, gap, to)) => {
|
||||
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
|
||||
if progress >= tuning.commit {
|
||||
Some(i)
|
||||
} else {
|
||||
near
|
||||
}
|
||||
}
|
||||
None => near,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
|
||||
/// slows as it lands, and exactly `to` once the glide is over.
|
||||
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
|
||||
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t).powi(3);
|
||||
(
|
||||
from.0 + (to.0 - from.0) * eased,
|
||||
from.1 + (to.1 - from.1) * eased,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
|
||||
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
|
||||
|
||||
#[test]
|
||||
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
|
||||
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
|
||||
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
|
||||
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
|
||||
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wobble_stays_put() {
|
||||
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
|
||||
// Barely started toward the neighbour: not committed.
|
||||
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_move_settles_on_the_nearest_target() {
|
||||
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
|
||||
// Far from everything: left where it is.
|
||||
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flick_off_any_row_does_not_jump_sideways() {
|
||||
// Up from slot 0: nothing lies that way, so it stays on slot 0.
|
||||
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glide_eases_onto_its_target_and_stops_there() {
|
||||
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
|
||||
assert_eq!(glide(from, to, 0.0, &TUNING), from);
|
||||
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
|
||||
assert!(
|
||||
half.0 > 10.0 && half.0 < 20.0,
|
||||
"eased out: past halfway at half time"
|
||||
);
|
||||
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
|
||||
assert_eq!(glide(from, to, 1.0, &TUNING), to);
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,6 @@ ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
# The network threads of a server's players: epoll, kqueue or Windows' AFD (lan/io_pool.rs).
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
|
||||
@@ -64,11 +64,10 @@ 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");
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
renderer.set_shader_pack(0, None, &[]);
|
||||
assert!(
|
||||
!renderer.pack_entity_shadows(),
|
||||
"selected pack without a compiled post"
|
||||
@@ -165,7 +164,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(dimension, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if break_pipeline {
|
||||
assert!(
|
||||
|
||||
@@ -1,830 +0,0 @@
|
||||
//! 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 pixels = render.pixels.get();
|
||||
let prepared = PreparedTextures::new(
|
||||
&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,
|
||||
&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,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
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)
|
||||
}
|
||||
@@ -79,10 +79,7 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &renderer.pack_inputs());
|
||||
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(0, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
|
||||
assert!(
|
||||
!valid || notes.iter().all(|n| !n.contains("skipped")),
|
||||
"{notes:?}"
|
||||
@@ -124,7 +121,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
@@ -381,7 +377,6 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -84,9 +84,7 @@ pub struct Assets {
|
||||
pub struct RenderAssets {
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
/// The block sprites' RGBA, layer by layer: as built until a renderer has
|
||||
/// them, then kept compressed ([`SpritePixels`]).
|
||||
pub pixels: SpritePixels,
|
||||
pub pixels: Vec<u8>,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
@@ -113,76 +111,6 @@ pub struct RenderAssets {
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
}
|
||||
/// The block sprites' pixels for a renderer to upload. Once a renderer has
|
||||
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
|
||||
/// texture array, and the copy only serves a renderer made again (another
|
||||
/// window, a lost device, another graphics API), which waits for
|
||||
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
|
||||
/// `RenderAssets`, so the compressed form replaces the raw one in place.
|
||||
pub struct SpritePixels(Arc<PixelStore>);
|
||||
struct PixelStore {
|
||||
len: usize,
|
||||
/// Set by the first `pack`: later calls cost one atomic read.
|
||||
packing: std::sync::atomic::AtomicBool,
|
||||
/// As built; dropped once `packed` holds them.
|
||||
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
|
||||
packed: std::sync::OnceLock<Vec<u8>>,
|
||||
}
|
||||
impl SpritePixels {
|
||||
pub fn new(pixels: Vec<u8>) -> Self {
|
||||
Self(Arc::new(PixelStore {
|
||||
len: pixels.len(),
|
||||
packing: std::sync::atomic::AtomicBool::new(false),
|
||||
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
|
||||
packed: std::sync::OnceLock::new(),
|
||||
}))
|
||||
}
|
||||
/// Their size in bytes, uncompressed.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.len == 0
|
||||
}
|
||||
/// The pixels as built, uncompressed again once packed.
|
||||
pub fn get(&self) -> Arc<Vec<u8>> {
|
||||
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
raw.unwrap_or_else(|| {
|
||||
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
|
||||
Arc::new(
|
||||
lz4_flex::decompress_size_prepended(packed)
|
||||
.ok()
|
||||
.filter(|p| p.len() == self.0.len)
|
||||
.unwrap_or_else(|| vec![0; self.0.len]),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Keep them compressed from now on, once a renderer has uploaded them:
|
||||
/// compressed on a thread of their own, the raw copy dropped after.
|
||||
pub fn pack(&self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let store = self.0.clone();
|
||||
let job = move || {
|
||||
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(raw) = raw {
|
||||
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
|
||||
// `get` reads the compressed copy from here on.
|
||||
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
};
|
||||
let fallback = job.clone();
|
||||
if std::thread::Builder::new()
|
||||
.name("Sprite packing".into())
|
||||
.spawn(job)
|
||||
.is_err()
|
||||
{
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The last `RenderAssets::generation` handed out.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
@@ -279,20 +207,6 @@ 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> {
|
||||
@@ -304,16 +218,6 @@ 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 {
|
||||
@@ -321,24 +225,17 @@ 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 = 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 parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
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 = {
|
||||
let _step = crate::spans::startup::step("core.item_library");
|
||||
parsed.into_item_library()
|
||||
};
|
||||
let model_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 [
|
||||
@@ -354,7 +251,6 @@ 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()
|
||||
@@ -405,7 +301,7 @@ impl RenderAssets {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
pixels: SpritePixels::new(sprites.pixels),
|
||||
pixels: sprites.pixels,
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
@@ -503,29 +399,4 @@ mod tests {
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let stored = super::SpritePixels::new(pixels.clone());
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
stored.pack();
|
||||
stored.pack();
|
||||
// The packing thread drops the raw copy once the compressed one is set.
|
||||
let started = std::time::Instant::now();
|
||||
while stored.0.raw.lock().unwrap().is_some() {
|
||||
assert!(started.elapsed().as_secs() < 10, "packing never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
stored
|
||||
.0
|
||||
.packed
|
||||
.get()
|
||||
.is_some_and(|p| p.len() < pixels.len())
|
||||
);
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
assert_eq!(stored.len(), pixels.len());
|
||||
}
|
||||
}
|
||||
@@ -379,17 +379,14 @@ pub fn generate(
|
||||
let (_, _, key) = load(palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = load(palette, "textures/palettes")?;
|
||||
let mut mapped = argb(&pixels);
|
||||
let key = argb(&key);
|
||||
let target = argb(&target);
|
||||
apply_palette(&mut mapped, &key, &target)?;
|
||||
let out: Vec<u8> = mapped
|
||||
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
|
||||
let out = 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,8 +61,6 @@ 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")]
|
||||
@@ -546,8 +544,9 @@ 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.
|
||||
mixer.cameras.clear();
|
||||
mixer.cameras.extend(
|
||||
let mut cameras = std::mem::take(&mut mixer.cameras);
|
||||
cameras.clear();
|
||||
cameras.extend(
|
||||
std::iter::once((
|
||||
0,
|
||||
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
|
||||
@@ -566,11 +565,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
)
|
||||
}),
|
||||
);
|
||||
let shared = mixer.cameras.len() > 1;
|
||||
let shared = 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 = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
continue;
|
||||
@@ -588,8 +586,7 @@ 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 = mixer
|
||||
.cameras
|
||||
let camera = cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
@@ -620,7 +617,8 @@ 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));
|
||||
}
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
mixer.cameras = cameras;
|
||||
for (id, [left, right, gain], step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -650,7 +648,6 @@ 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,22 +101,6 @@ 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();
|
||||
@@ -452,8 +436,11 @@ struct ChatKeys {
|
||||
fetching: bool,
|
||||
next_try: Option<std::time::Instant>,
|
||||
}
|
||||
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
|
||||
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
|
||||
current: None,
|
||||
fetching: false,
|
||||
next_try: None,
|
||||
});
|
||||
|
||||
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
|
||||
/// then prepareKeyPair: start fetching when there is no key pair or it is
|
||||
@@ -462,14 +449,7 @@ pub fn prepare_chat_keys(now: bool) {
|
||||
let Some(session) = current() else {
|
||||
return;
|
||||
};
|
||||
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 mut keys = CHAT_KEYS.lock().unwrap();
|
||||
let instant = std::time::Instant::now();
|
||||
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
|
||||
return;
|
||||
@@ -483,13 +463,12 @@ pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
|
||||
return;
|
||||
}
|
||||
keys.fetching = true;
|
||||
drop(accounts);
|
||||
drop(keys);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Profile key fetch".into())
|
||||
.spawn(move || {
|
||||
let result = fetch_chat_keys(&session);
|
||||
let mut accounts = CHAT_KEYS.lock().unwrap();
|
||||
let keys = accounts.entry(uuid).or_default();
|
||||
let mut keys = CHAT_KEYS.lock().unwrap();
|
||||
keys.fetching = false;
|
||||
match result {
|
||||
Ok(pair) => keys.current = Some(Arc::new(pair)),
|
||||
@@ -497,24 +476,19 @@ pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().fetching = false;
|
||||
CHAT_KEYS.lock().unwrap().fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat key pair fetched so far, unless it has expired.
|
||||
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
|
||||
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>> {
|
||||
let now = now_millis();
|
||||
CHAT_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&uuid)?
|
||||
.current
|
||||
.clone()
|
||||
.filter(|k| !k.public.has_expired(now_millis()))
|
||||
.filter(|k| !k.public.has_expired(now))
|
||||
}
|
||||
|
||||
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
@@ -658,42 +632,6 @@ 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();
|
||||
@@ -857,37 +795,6 @@ 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();
|
||||
|
||||
@@ -939,14 +939,6 @@ impl BlockEntityAnims {
|
||||
/// Drops the state of block entities that are gone or changed kind:
|
||||
/// `present` holds the loaded block entities' tickers, sorted by position.
|
||||
pub fn retain_present(&mut self, present: &[([i32; 3], Ticker)]) {
|
||||
self.retain_animations(present, false);
|
||||
}
|
||||
/// An integrated server owns bell state independently of renderer visibility.
|
||||
/// Its world ticker removes bells when their actual block changes.
|
||||
pub fn retain_present_preserving_bells(&mut self, present: &[([i32; 3], Ticker)]) {
|
||||
self.retain_animations(present, true);
|
||||
}
|
||||
fn retain_animations(&mut self, present: &[([i32; 3], Ticker)], keep_bells: bool) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| {
|
||||
present
|
||||
.binary_search_by_key(p, |(q, _)| *q)
|
||||
@@ -956,9 +948,7 @@ impl BlockEntityAnims {
|
||||
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
|
||||
self.shulkers
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
|
||||
if !keep_bells {
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
}
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
self.conduits
|
||||
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
|
||||
self.skulls
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::{
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
@@ -661,7 +660,7 @@ fn sign_text_calls(
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed))
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let advance = |c: char| (scene.glyph)(c).1;
|
||||
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
|
||||
text.lines
|
||||
.iter()
|
||||
@@ -700,19 +699,10 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let g = (scene.glyph)(ch);
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let (cell, advance) = (scene.glyph)(ch);
|
||||
if let Some(r) = cell {
|
||||
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
@@ -731,7 +721,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
x += g.advance;
|
||||
x += advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
@@ -828,8 +818,8 @@ pub struct Scene<'a> {
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -3352,7 +3342,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -3620,13 +3610,7 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
Glyph {
|
||||
rect: Some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
}
|
||||
(Some(rect), 6.0)
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
@@ -3676,78 +3660,6 @@ mod tests {
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
/// BakedSheetGlyph draws a glyph over its own box from the pen
|
||||
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
|
||||
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
|
||||
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
|
||||
#[test]
|
||||
fn sign_text_draws_each_glyph_over_its_own_box() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.125,
|
||||
w: 8,
|
||||
h: 16,
|
||||
}),
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 4.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
translucent_sprites: &HashSet::new(),
|
||||
atlas_revision: 1,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
let boxes: Vec<_> = out
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let xs = q.iter().map(|v| v.pos[0]);
|
||||
let ys = q.iter().map(|v| v.pos[1]);
|
||||
[
|
||||
xs.clone().fold(f32::MAX, f32::min),
|
||||
xs.fold(f32::MIN, f32::max),
|
||||
ys.clone().fold(f32::MAX, f32::min),
|
||||
ys.fold(f32::MIN, f32::max),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
@@ -3802,13 +3714,7 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
Glyph {
|
||||
rect: (c != ' ').then_some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: wide.get(),
|
||||
..Glyph::default()
|
||||
}
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
@@ -3973,7 +3879,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -4058,7 +3964,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::{
|
||||
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
|
||||
},
|
||||
chunk::Chunk,
|
||||
font::Glyph,
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
signs::{SignData, SignText},
|
||||
@@ -356,7 +355,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -444,7 +443,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -498,7 +497,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -652,19 +651,18 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
};
|
||||
let sign_data = &data;
|
||||
let sign = move |_: [i32; 3]| Some(sign_data);
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
let glyph = |_: char| {
|
||||
(
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
6.0,
|
||||
)
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::new(2.25, 64.5, -3.75),
|
||||
@@ -737,7 +735,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -838,7 +836,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -892,7 +890,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -953,7 +951,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1048,7 +1046,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1136,7 +1134,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1211,7 +1209,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1289,7 +1287,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
sign: &|_| None,
|
||||
banner: &banner_patterns,
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1355,7 +1353,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1402,7 +1400,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1507,7 +1505,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -431,6 +431,30 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
|
||||
@@ -58,24 +58,6 @@ impl DataPack {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The files of `data/<namespace>/<folder>/` in every namespace, as
|
||||
/// `list` names them; a folder pack walks only those folders.
|
||||
pub fn list_folder(&self, folder: &str) -> Vec<String> {
|
||||
let files = self.list_kinds(&[folder]);
|
||||
let Source::Zip(_) = &self.source else {
|
||||
return files;
|
||||
};
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
f.strip_prefix("data/")
|
||||
.and_then(|r| r.split_once('/'))
|
||||
.is_some_and(|(_, r)| {
|
||||
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z.read(path).ok(),
|
||||
@@ -275,34 +257,19 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
|
||||
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
|
||||
/// does (a folder zipped by the system, which adds a wrapper). Copied into
|
||||
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
|
||||
/// `dir`; false when `path` is no pack.
|
||||
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
fn copy_dir(from: &Path, at: &Path) -> bool {
|
||||
let mut whole = true;
|
||||
if let Err(e) = std::fs::create_dir_all(at) {
|
||||
log::warn!("Horizon: {} is not made: {e}", at.display());
|
||||
whole = false;
|
||||
}
|
||||
fn copy_dir(from: &Path, at: &Path) {
|
||||
let _ = std::fs::create_dir_all(at);
|
||||
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let to = at.join(entry.file_name());
|
||||
let copied = if path.is_dir() {
|
||||
copy_dir(&path, &to)
|
||||
if path.is_dir() {
|
||||
copy_dir(&path, &to);
|
||||
} else {
|
||||
match std::fs::copy(&path, &to) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: {} is not copied into the pack: {e}",
|
||||
path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
whole &= copied;
|
||||
let _ = std::fs::copy(&path, &to);
|
||||
}
|
||||
}
|
||||
whole
|
||||
}
|
||||
let name = || {
|
||||
path.file_name()
|
||||
@@ -322,7 +289,8 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
if path.join("pack.mcmeta").is_file() {
|
||||
return copy_dir(path, &dir.join(name()));
|
||||
copy_dir(path, &dir.join(name()));
|
||||
return true;
|
||||
}
|
||||
let own: Vec<PathBuf> = std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
@@ -332,7 +300,10 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
|
||||
.collect();
|
||||
match own.as_slice() {
|
||||
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
|
||||
[one] => {
|
||||
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -925,24 +896,6 @@ mod tests {
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_that_cannot_be_copied_is_reported() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let pack = root.join("pack");
|
||||
std::fs::create_dir_all(pack.join("data")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
|
||||
// A file where the pack's folder goes: every copy under it fails.
|
||||
let blocked = root.join("blocked");
|
||||
std::fs::write(&blocked, "").unwrap();
|
||||
assert!(!copy_pack(&pack, &blocked));
|
||||
let dir = root.join("packs");
|
||||
assert!(copy_pack(&pack, &dir));
|
||||
assert!(dir.join("pack/pack.mcmeta").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -50,9 +50,8 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<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>,
|
||||
@location(2) spherical: f32,
|
||||
@location(3) cylindrical: f32,
|
||||
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
|
||||
@location(5) overlay_color: vec4<f32>,
|
||||
@location(4) @interpolate(flat) overlay: u32,
|
||||
@@ -158,11 +157,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.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.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.overlay = overlay;
|
||||
out.glint_uv = vec2(0.0);
|
||||
if overlay == 4u { out.fog_distance.x = length(position); }
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@@ -294,7 +293,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.fog_distance.x, 0.0, globals.fog_ends.y)));
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 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
|
||||
@@ -313,8 +312,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.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
@@ -328,8 +327,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.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
@@ -348,8 +347,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.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;
|
||||
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;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
@@ -380,7 +379,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.fog_distance.x, 0.0, globals.fog_ends.y));
|
||||
let a = in.color.a * (1.0 - linear_fog(in.spherical, 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));
|
||||
}
|
||||
@@ -389,8 +388,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.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
@@ -429,8 +428,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.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return f * globals.fog_color.a;
|
||||
}
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
@@ -573,7 +572,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.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, 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, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Sky {
|
||||
pub sky_color: [f32; 3],
|
||||
pub fog_color: [f32; 3],
|
||||
|
||||
@@ -8,8 +8,6 @@ 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")];
|
||||
@@ -78,13 +76,6 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// Far terrain a zoom magnifies is planned finer, so a spyglass does not
|
||||
/// show the coarse cells it was given for an unzoomed screen
|
||||
/// (`lod::Aim`). Costs far tiles while the zoom is held and gives them
|
||||
/// back after, and does nothing at all unless far terrain is on
|
||||
/// (`lodDistance`) and the view is actually zoomed. Off by default until
|
||||
/// a measurement shows the extra tiles land while the zoom is still held.
|
||||
pub lod_zoom_detail: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
@@ -161,20 +152,11 @@ pub struct Extras {
|
||||
pub music_page: bool,
|
||||
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
|
||||
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
|
||||
/// on by default), how much of other players' to show (no line until chosen:
|
||||
/// `effective_others`), and their auras and trails (off by default).
|
||||
/// on by default), how much of other players' to show (all by default), and their auras and
|
||||
/// trails (off by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
|
||||
pub cosmetics_others: horizon_cosmetics::prefs::Others,
|
||||
pub cosmetics_auras: bool,
|
||||
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
|
||||
/// sets it): with no choice made, others' cosmetics show only then.
|
||||
pub cosmetics_owner: bool,
|
||||
/// 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,
|
||||
/// The PvP & HUD page's options (`pvp`).
|
||||
pub pvp: crate::pvp::Pvp,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -187,7 +169,6 @@ impl Default for Extras {
|
||||
zoom_level: 4,
|
||||
zoom_smooth: true,
|
||||
zoom_sensitivity: true,
|
||||
lod_zoom_detail: false,
|
||||
fps: false,
|
||||
fps_corner: Corner::TopLeft,
|
||||
fps_lows: false,
|
||||
@@ -223,11 +204,8 @@ impl Default for Extras {
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: None,
|
||||
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
|
||||
cosmetics_auras: false,
|
||||
cosmetics_owner: false,
|
||||
three_d_skin_layers: false,
|
||||
pvp: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,11 +213,6 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
|
||||
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -270,7 +243,6 @@ impl Extras {
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
|
||||
"lodZoomDetail" => e.lod_zoom_detail = flag(e.lod_zoom_detail),
|
||||
"fps" => e.fps = flag(e.fps),
|
||||
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
|
||||
"fpsLows" => e.fps_lows = flag(e.fps_lows),
|
||||
@@ -332,20 +304,17 @@ impl Extras {
|
||||
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
|
||||
"cosmeticsOthers" => {
|
||||
e.cosmetics_others =
|
||||
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
|
||||
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),
|
||||
key => {
|
||||
e.pvp.parse_line(key, value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
|
||||
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",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
@@ -354,7 +323,6 @@ impl Extras {
|
||||
self.zoom_level,
|
||||
bool_text(self.zoom_smooth),
|
||||
bool_text(self.zoom_sensitivity),
|
||||
bool_text(self.lod_zoom_detail),
|
||||
bool_text(self.fps),
|
||||
self.fps_corner.name(),
|
||||
bool_text(self.fps_lows),
|
||||
@@ -395,33 +363,9 @@ impl Extras {
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
// Written once the player chose.
|
||||
self.cosmetics_others
|
||||
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
|
||||
self.cosmetics_others.name(),
|
||||
bool_text(self.cosmetics_auras),
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
self.pvp.serialize(&mut text);
|
||||
text
|
||||
}
|
||||
/// 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).
|
||||
@@ -574,8 +518,6 @@ pub enum Item {
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
CosmeticsAuras,
|
||||
/// On the PvP & HUD page (`pvp::rows`).
|
||||
Pvp(crate::pvp::Opt),
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -642,7 +584,6 @@ impl Item {
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
|
||||
Item::Pvp(o) => o.caption_key(),
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -671,7 +612,6 @@ impl Item {
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
|
||||
Item::Pvp(o) => o.description_key(),
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -709,7 +649,6 @@ impl Item {
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
Item::CosmeticsAuras => "Others' auras and trails",
|
||||
Item::Pvp(o) => o.caption(),
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -720,7 +659,6 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
@@ -786,10 +724,9 @@ impl Item {
|
||||
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
|
||||
}
|
||||
Item::CosmeticsOthers => {
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
|
||||
}
|
||||
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
|
||||
Item::Pvp(o) => o.description(),
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
@@ -842,7 +779,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
Item::CosmeticsAuras => e.cosmetics_auras,
|
||||
Item::Pvp(o) => return o.switch(&e.pvp),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -850,14 +786,13 @@ impl Item {
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
|
||||
Item::CosmeticsMine => match e.cosmetics_mine {
|
||||
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
|
||||
},
|
||||
Item::CosmeticsOthers => {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
match e.effective_others() {
|
||||
match e.cosmetics_others {
|
||||
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
Others::NameTags => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
|
||||
@@ -898,12 +833,10 @@ impl Item {
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => {
|
||||
// From what the button shows; the result is the player's choice from now on.
|
||||
let o = e.effective_others();
|
||||
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
|
||||
let o = e.cosmetics_others;
|
||||
e.cosmetics_others = if back { o.next().next() } else { o.next() };
|
||||
}
|
||||
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
|
||||
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -923,70 +856,31 @@ mod tests {
|
||||
#[test]
|
||||
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
let mut e = Extras::default();
|
||||
assert_eq!(
|
||||
(e.effective_others(), e.cosmetics_auras, e.cosmetics_mine),
|
||||
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
|
||||
(Others::Shown, false, true)
|
||||
);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
assert_eq!(e.cosmetics_others, Others::NameTags);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
|
||||
assert_eq!(e.cosmetics_others, Others::Hidden);
|
||||
// The right button goes back.
|
||||
Item::CosmeticsOthers.cycle(&mut e, true);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
// Ownership is the service's, never written.
|
||||
assert_eq!(
|
||||
Extras::parse(&e.serialize()),
|
||||
Extras {
|
||||
cosmetics_owner: false,
|
||||
..e.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(e.cosmetics_others, Others::NameTags);
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
// The launcher's Wardrobe writes the same lines.
|
||||
assert_eq!(
|
||||
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
|
||||
Some(Others::NameTags)
|
||||
Others::NameTags
|
||||
);
|
||||
// Before this, the line was on or off.
|
||||
assert_eq!(
|
||||
Extras::parse("cosmeticsOthers:false").cosmetics_others,
|
||||
Some(Others::Hidden)
|
||||
Others::Hidden
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
|
||||
assert_eq!(e.effective_others(), Others::Hidden);
|
||||
assert_eq!(
|
||||
Item::CosmeticsOthers.value(&e),
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// The service says the account owns one: shown, still not a choice.
|
||||
e.cosmetics_owner = true;
|
||||
assert_eq!(e.effective_others(), Others::Shown);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
|
||||
e.cosmetics_owner = false;
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Shown));
|
||||
let saved = Extras::parse(&e.serialize());
|
||||
assert_eq!(saved.effective_others(), Others::Shown);
|
||||
// An owner who chose Hidden keeps it.
|
||||
let hidden = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::parse("cosmeticsOthers:hidden\n")
|
||||
};
|
||||
assert_eq!(hidden.effective_others(), Others::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_is_off_by_default_and_round_trips() {
|
||||
let d = Extras::default();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub enum FogType {
|
||||
}
|
||||
|
||||
/// The fog uniform (`FogData`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FogData {
|
||||
pub color: [f32; 4],
|
||||
pub environmental_start: f32,
|
||||
|
||||
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
|
||||
|
||||
/// A glyph ready to draw: atlas UVs and its box relative to the pen
|
||||
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Glyph {
|
||||
pub rect: Option<Rect>,
|
||||
pub left: f32,
|
||||
@@ -104,11 +104,8 @@ 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>,
|
||||
/// `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>>>,
|
||||
/// `glyphsByWidth` for obfuscated text (built on first use).
|
||||
by_width: Option<HashMap<u32, Vec<char>>>,
|
||||
}
|
||||
/// The atlas region glyphs are rasterized into, filled in rows.
|
||||
struct Region {
|
||||
@@ -134,7 +131,6 @@ pub struct Font {
|
||||
/// Atlas size and the reserved dynamic region (y, height).
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
metrics_only: bool,
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
@@ -262,11 +258,10 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
|
||||
})
|
||||
.map_or(0, |x| x + 1)
|
||||
}
|
||||
/// `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.
|
||||
/// `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.
|
||||
fn fishy_advance(advance: f32) -> bool {
|
||||
!(0.0..=32.0).contains(&advance)
|
||||
}
|
||||
@@ -285,7 +280,6 @@ fn load_bitmap(
|
||||
sheets: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
metrics_only: bool,
|
||||
) -> HashMap<char, BitmapGlyph> {
|
||||
let mut out = HashMap::new();
|
||||
let file = p["file"].as_str().unwrap_or("");
|
||||
@@ -351,10 +345,6 @@ fn load_bitmap(
|
||||
advance: (0.5 + used as f32 * scale).floor() + 1.0,
|
||||
offset: 1.0,
|
||||
};
|
||||
if metrics_only {
|
||||
out.insert(*ch, BitmapGlyph::Atlas(glyph));
|
||||
continue;
|
||||
}
|
||||
let glyph = match &sheet {
|
||||
None => BitmapGlyph::Atlas(Glyph {
|
||||
rect: Some(glyph_rect(
|
||||
@@ -602,30 +592,6 @@ impl Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
Self::load_with(root, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// A server measures text but never draws it. Decode bitmap sheets only
|
||||
/// to obtain advances, then release their pixels; never rasterize glyphs.
|
||||
pub fn metrics(root: &Path) -> Self {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
|
||||
}
|
||||
fn load_with(
|
||||
root: &Path,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
metrics_only: bool,
|
||||
) -> Self {
|
||||
let sheets = sprites
|
||||
.iter()
|
||||
@@ -647,7 +613,6 @@ impl Font {
|
||||
root: root.to_owned(),
|
||||
atlas,
|
||||
region,
|
||||
metrics_only,
|
||||
cache: RefCell::new(Cache {
|
||||
sets: Vec::new(),
|
||||
region: Region {
|
||||
@@ -696,14 +661,7 @@ impl Font {
|
||||
)),
|
||||
"bitmap" => Some(Provider::Bitmap(
|
||||
Filter::of(p),
|
||||
load_bitmap(
|
||||
&self.root,
|
||||
p,
|
||||
&self.sheets,
|
||||
pixels,
|
||||
self.atlas,
|
||||
self.metrics_only,
|
||||
),
|
||||
load_bitmap(&self.root, p, &self.sheets, pixels, self.atlas),
|
||||
)),
|
||||
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
|
||||
"ttf" => Some(Provider::TrueType(
|
||||
@@ -716,7 +674,6 @@ impl Font {
|
||||
FontSet {
|
||||
providers,
|
||||
glyphs: Default::default(),
|
||||
non_fishy: Default::default(),
|
||||
by_width: None,
|
||||
}
|
||||
}
|
||||
@@ -725,7 +682,6 @@ 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);
|
||||
@@ -779,7 +735,7 @@ impl Font {
|
||||
return *g;
|
||||
}
|
||||
let was_full = std::mem::replace(&mut region.full, false);
|
||||
let found = self.resolve(set, region, c, false);
|
||||
let found = self.resolve(set, region, c);
|
||||
// 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);
|
||||
@@ -791,46 +747,13 @@ impl Font {
|
||||
set.glyphs.insert(c, glyph);
|
||||
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> {
|
||||
fn resolve(&self, set: &mut FontSet, region: &mut Region, c: char) -> 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)
|
||||
&& !(non_fishy && fishy_advance(*a))
|
||||
&& !fishy_advance(*a)
|
||||
{
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
@@ -845,12 +768,10 @@ impl Font {
|
||||
}
|
||||
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
|
||||
match glyphs.get(&c) {
|
||||
Some(BitmapGlyph::Atlas(g)) if !(non_fishy && fishy_advance(g.advance)) => {
|
||||
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
|
||||
return Some(*g);
|
||||
}
|
||||
Some(BitmapGlyph::Sheet(cell))
|
||||
if !(non_fishy && fishy_advance(cell.glyph.advance)) =>
|
||||
{
|
||||
Some(BitmapGlyph::Sheet(cell)) if !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 {
|
||||
@@ -870,23 +791,15 @@ 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()
|
||||
&& !(non_fishy && fishy_advance(hex_advance(&h)))
|
||||
&& !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)
|
||||
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
|
||||
&& !fishy_advance(ttf.advance(*id))
|
||||
{
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
advance: ttf.advance(*id),
|
||||
offset: 1.0,
|
||||
..self.missing
|
||||
});
|
||||
}
|
||||
let (glyph, bitmap) = ttf.glyph(*id);
|
||||
return match bitmap {
|
||||
Some((w, h, rgba)) => {
|
||||
@@ -907,17 +820,6 @@ impl Font {
|
||||
}
|
||||
fn rasterize_hex(&self, region: &mut Region, h: HexGlyph) -> Option<Glyph> {
|
||||
let width = h.right.saturating_sub(h.left) + 1;
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: width as f32 / 2.0,
|
||||
bottom: 8.0,
|
||||
advance: hex_advance(&h),
|
||||
offset: 0.5,
|
||||
});
|
||||
}
|
||||
let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
|
||||
for row in h.rows {
|
||||
for bit in h.left..=h.right {
|
||||
@@ -949,7 +851,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 i32;
|
||||
let width = advance.ceil() as u32;
|
||||
let pick = {
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
let Cache { sets, random, .. } = &mut *cache;
|
||||
@@ -967,9 +869,9 @@ impl Font {
|
||||
chars.get(i as usize).copied()
|
||||
})
|
||||
};
|
||||
pick.map_or(self.missing, |c| self.glyph_non_fishy(id, c))
|
||||
pick.map_or(self.missing, |c| self.glyph_of(id, c))
|
||||
}
|
||||
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<i32, Vec<char>> {
|
||||
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<u32, Vec<char>> {
|
||||
let (uniform, japanese) = (self.uniform, self.japanese);
|
||||
let mut chars = std::collections::BTreeSet::new();
|
||||
let mut in_atlas = false;
|
||||
@@ -991,31 +893,33 @@ 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<i32, Vec<char>> = HashMap::new();
|
||||
let mut out: HashMap<u32, Vec<char>> = HashMap::new();
|
||||
for c in chars {
|
||||
if let Some(advance) = self.provider_advance(set, c) {
|
||||
out.entry(advance.ceil() as i32).or_default().push(c);
|
||||
out.entry(advance.ceil() as u32).or_default().push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// 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.
|
||||
/// A character's advance in `set` (its first provider that has 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) {
|
||||
if let Some(a) = advances.get(&c)
|
||||
&& !fishy_advance(*a)
|
||||
{
|
||||
return Some(*a);
|
||||
}
|
||||
}
|
||||
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
|
||||
match glyphs.get(&c) {
|
||||
Some(BitmapGlyph::Atlas(g)) => {
|
||||
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
|
||||
return Some(g.advance);
|
||||
}
|
||||
Some(BitmapGlyph::Sheet(cell)) => {
|
||||
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
|
||||
return Some(cell.glyph.advance);
|
||||
}
|
||||
_ => {}
|
||||
@@ -1023,12 +927,16 @@ 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)) {
|
||||
if let Some(h) = glyphs.get(&u32::from(c))
|
||||
&& !fishy_advance(hex_advance(h))
|
||||
{
|
||||
return Some(hex_advance(h));
|
||||
}
|
||||
}
|
||||
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
|
||||
if let Some(id) = ttf.ids.get(&c) {
|
||||
if let Some(id) = ttf.ids.get(&c)
|
||||
&& !fishy_advance(ttf.advance(*id))
|
||||
{
|
||||
return Some(ttf.advance(*id));
|
||||
}
|
||||
}
|
||||
@@ -1418,55 +1326,6 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_metrics_preserve_bitmap_true_type_and_unihex_advances() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let def = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let ttf = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2}]}"#;
|
||||
let root = root(
|
||||
"metrics",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", def),
|
||||
("assets/minecraft/font/alt.json", ttf),
|
||||
(
|
||||
"assets/minecraft/font/mono.ttf",
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let rendered = load(&root);
|
||||
let metrics = Font::metrics(&root);
|
||||
assert_eq!(metrics.width("a b"), 14.0);
|
||||
assert_eq!(metrics.width("§lab"), 12.0);
|
||||
let text = Text::literal("ab ab");
|
||||
assert_eq!(
|
||||
metrics.split_formatted(&text, 10.0),
|
||||
rendered.split_formatted(&text, 10.0)
|
||||
);
|
||||
let alt = Style {
|
||||
font: Some("minecraft:alt".into()),
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
for c in ['A', 'i', ' ', '\u{fffd}'] {
|
||||
assert_eq!(metrics.advance(c, &alt), rendered.advance(c, &alt));
|
||||
assert!(metrics.glyph_styled(c, &alt).rect.is_none());
|
||||
}
|
||||
let mut hex = HashMap::new();
|
||||
parse_hex(b"0041:0000000018242442427E424242420000\n", &mut hex);
|
||||
let h = hex[&0x41];
|
||||
let glyph = metrics
|
||||
.rasterize_hex(&mut metrics.cache.borrow_mut().region, h)
|
||||
.unwrap();
|
||||
assert_eq!(glyph.advance, 4.0);
|
||||
assert_eq!(glyph.offset, 0.5);
|
||||
assert!(glyph.rect.is_none());
|
||||
assert!(metrics.take_patches().is_empty());
|
||||
assert!(!rendered.take_patches().is_empty());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_type_glyphs_are_rasterized_at_the_provider_size() {
|
||||
let def = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2,"shift":[0.5,1],"skip":["B"]}]}"#;
|
||||
@@ -1547,13 +1406,12 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// `FontSet.computeGlyphInfo`: a provider glyph with an advance outside
|
||||
/// 0..32 is skipped, so the next provider that has the character is used.
|
||||
#[test]
|
||||
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.
|
||||
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.
|
||||
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"]}]}"#;
|
||||
@@ -1567,80 +1425,11 @@ mod tests {
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
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"
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
|
||||
|
||||
+293
-1897
File diff suppressed because it is too large.
Load diff
@@ -877,15 +877,12 @@ 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()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -933,12 +930,11 @@ mod tests {
|
||||
.iter()
|
||||
.map(|s| (s.page, s.start, s.translucent_at))
|
||||
.collect();
|
||||
// The sections with solid quads have records, on page 0, each with
|
||||
// its cell's box.
|
||||
// The sections with solid quads have records, on page 0.
|
||||
let mut records = gpu_culling::Records::default();
|
||||
for (i, s) in sections.iter_mut().enumerate() {
|
||||
if s.solid > 0 {
|
||||
s.gpu_id = Some(records.insert(s.record((i as i32, 0, 0), None)));
|
||||
s.gpu_id = Some(records.insert((i as i32, 0, 0), s.page, s.start, s.solid));
|
||||
}
|
||||
}
|
||||
let recorded = records.live() as u32;
|
||||
@@ -961,8 +957,9 @@ mod tests {
|
||||
let word =
|
||||
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
// Its record names its solid quads where they are now, its box kept.
|
||||
let record = (s.solid > 0).then(|| s.record((i as i32, 0, 0), None));
|
||||
// Its record names its solid quads where they are now.
|
||||
let record = (s.solid > 0)
|
||||
.then(|| gpu_culling::Record::new((i as i32, 0, 0), s.page, s.start, s.solid));
|
||||
let held = s.gpu_id.and_then(|id| records.at(id));
|
||||
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
|
||||
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
|
||||
@@ -1017,7 +1014,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),
|
||||
@@ -1074,15 +1071,12 @@ 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()),
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1301,15 +1295,12 @@ 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()),
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
//! `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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
//! A resource pack's `core/rendertype_lines` shader on the block outline (`RenderTypes.LINES`,
|
||||
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
|
||||
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
|
||||
|
||||
use glam::Mat4;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
|
||||
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
|
||||
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
|
||||
/// `gl_VertexIndex % 2`, as `vs_line` draws them.
|
||||
pub(crate) const GLUE: Glue = Glue {
|
||||
inputs: &[
|
||||
("Position", "vec3"),
|
||||
("Color", "vec4"),
|
||||
("Normal", "vec3"),
|
||||
("LineWidth", "float"),
|
||||
],
|
||||
prelude: "layout(location = 0) in vec3 hz_start;\n\
|
||||
layout(location = 1) in vec3 hz_end;\n\
|
||||
layout(location = 2) in vec4 hz_argb;\n\
|
||||
layout(location = 3) in float hz_width;",
|
||||
// ARGB in a little-endian u32 reads as B, G, R, A. ShapeRenderer's normal: the direction.
|
||||
setup: "Position = hz_start; if (int(gl_VertexIndex) >= 2) Position = hz_end; \
|
||||
Color = hz_argb.zyxw; Normal = normalize(hz_end - hz_start); LineWidth = hz_width;",
|
||||
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
|
||||
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
pub(super) struct CoreLines {
|
||||
lines: wgpu::RenderPipeline,
|
||||
secondary: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl CoreLines {
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
program: &CoreProgram,
|
||||
) -> Option<Self> {
|
||||
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = |label, module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let vertex = module("Resource pack lines (vertex)", &program.vertex);
|
||||
let fragment = module("Resource pack lines (fragment)", &program.fragment);
|
||||
// Vanilla's LINES pipeline has no samplers: a pack that reads one is not used. The
|
||||
// blocks' sizes are checked here too, not at the draw: a pack's own larger block fails
|
||||
// in this scope.
|
||||
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Resource pack lines"),
|
||||
entries: &entries,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Resource pack lines uniforms"),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
|
||||
.map(|i| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &uniform,
|
||||
offset: BLOCK_OFFSETS[i],
|
||||
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Resource pack lines"),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Resource pack lines"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
// The built-in outline's state: a strip per edge, reversed-Z depth test, translucent.
|
||||
let pipeline = |label, depth_write| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(super::pack_lines::layout())],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let lines = pipeline("Resource pack block outline", true);
|
||||
let secondary = pipeline("Resource pack block outline secondary", false);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("the resource pack's rendertype_lines shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
lines,
|
||||
secondary,
|
||||
group,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn uniform(&self) -> &wgpu::Buffer {
|
||||
&self.uniform
|
||||
}
|
||||
|
||||
/// Draw `edges` (instances of the outline buffer bound at slot 0) with the pack's shader:
|
||||
/// the secondary stroke without depth writes. The caller sets its own group 0 again after.
|
||||
pub(super) fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
secondary: bool,
|
||||
edges: std::ops::Range<u32>,
|
||||
) {
|
||||
pass.set_pipeline(if secondary {
|
||||
&self.secondary
|
||||
} else {
|
||||
&self.lines
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(0..4, edges);
|
||||
}
|
||||
}
|
||||
|
||||
/// The std140 blocks: `Globals` (`post_chain::Globals::bytes`), `Projection` (`jitter`, the lens
|
||||
/// and the view bob `pose`, as vanilla's projection matrix carries it), `DynamicTransforms` (the
|
||||
/// camera's rotation with the PERSPECTIVE `layering` scale, colour modulator white) and `Fog`
|
||||
/// (`fog`: environmental start and end, render distance start and end, sky end, clouds end).
|
||||
/// `ProjMat · ModelViewMat` is `vs_line`'s `view_proj` with its layering scale.
|
||||
pub(super) fn uniforms(
|
||||
globals: [u8; 48],
|
||||
view: &super::View,
|
||||
pose: Mat4,
|
||||
jitter: Mat4,
|
||||
layering: f32,
|
||||
fog_color: [f32; 4],
|
||||
fog: [f32; 6],
|
||||
) -> Vec<u8> {
|
||||
let projection = jitter * view.projection * pose;
|
||||
let model_view = view.view * Mat4::from_scale(glam::Vec3::splat(layering));
|
||||
let mut data = vec![0f32; 256];
|
||||
data[64..80].copy_from_slice(&projection.to_cols_array());
|
||||
let transforms = 128;
|
||||
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
|
||||
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
|
||||
data[transforms + 32..transforms + 36].fill(1.0);
|
||||
data[192..196].copy_from_slice(&fog_color);
|
||||
data[196..202].copy_from_slice(&fog);
|
||||
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
|
||||
bytes[..48].copy_from_slice(&globals);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::{Vec3, Vec4};
|
||||
|
||||
const INCLUDES: [(&str, &str); 3] = [
|
||||
(
|
||||
"minecraft:shaders/include/globals.glsl",
|
||||
"layout(std140) uniform Globals {\n ivec3 CameraBlockPos;\n float GlintAlpha;\n vec3 CameraOffset;\n float GameTime;\n vec2 ScreenSize;\n int MenuBlurRadius;\n int UseRgss;\n};\n",
|
||||
),
|
||||
(
|
||||
"minecraft:shaders/include/dynamictransforms.glsl",
|
||||
"layout(std140) uniform DynamicTransforms {\n mat4 ModelViewMat;\n mat4 TextureMat;\n vec4 ColorModulator;\n vec3 ModelOffset;\n};\n",
|
||||
),
|
||||
(
|
||||
"minecraft:shaders/include/projection.glsl",
|
||||
"layout(std140) uniform Projection {\n mat4 ProjMat;\n};\n",
|
||||
),
|
||||
];
|
||||
|
||||
fn include(id: &str) -> Option<String> {
|
||||
INCLUDES
|
||||
.iter()
|
||||
.find(|(path, _)| *path == id)
|
||||
.map(|(_, text)| (*text).to_owned())
|
||||
}
|
||||
|
||||
/// 26.3's `rendertype_lines.vsh` in shape: a stroke `LineWidth` pixels wide across the
|
||||
/// screen-space direction of `Normal`, its side from `gl_VertexIndex % 2`.
|
||||
const LINES_VSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
|
||||
#include <minecraft:globals.glsl>\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
|
||||
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
|
||||
layout(location = 2) in vec3 Normal;\nlayout(location = 3) in float LineWidth;\n\
|
||||
layout(location = 2) out vec4 vertexColor;\n\
|
||||
const mat4 SHRINK = mat4(0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 1.0);\n\
|
||||
void main() {\n vec4 from = ProjMat * SHRINK * ModelViewMat * vec4(Position, 1.0);\n\
|
||||
vec4 to = ProjMat * SHRINK * ModelViewMat * vec4(Position + Normal, 1.0);\n\
|
||||
vec2 along = normalize((to.xy / to.w - from.xy / from.w) * ScreenSize);\n\
|
||||
vec2 side = vec2(-along.y, along.x) * LineWidth / ScreenSize;\n\
|
||||
if (gl_VertexIndex % 2 == 0) {\n side = -side;\n }\n\
|
||||
gl_Position = vec4(from.xy / from.w + side, from.z / from.w, 1.0) * from.w;\n\
|
||||
vertexColor = Color;\n}\n";
|
||||
|
||||
/// An RGB outline pack's fragment shader in shape: the colour from `GameTime`.
|
||||
const RGB_FSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
|
||||
#include <minecraft:globals.glsl>\n\
|
||||
layout(location = 2) in vec4 vertexColor;\nlayout(location = 0) out vec4 fragColor;\n\
|
||||
void main() {\n fragColor = vec4(GameTime, 1.0 - GameTime, vertexColor.b, 1.0);\n}\n";
|
||||
|
||||
/// ProjMat · ModelViewMat (with the vertex shader's shrink between them) is `vs_line`'s
|
||||
/// view_proj on the layered, shrunk position: the pack's outline sits where the built-in one
|
||||
/// does, view bob and TAA jitter included.
|
||||
#[test]
|
||||
fn the_pack_matrices_put_the_outline_where_vs_line_does() {
|
||||
let view = super::super::View::screen(37.0, -21.0, 70.0, 16.0 / 9.0, 256.0);
|
||||
let pose =
|
||||
Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.01, -0.03, 0.0));
|
||||
let jitter = Mat4::from_translation(Vec3::new(0.0004, -0.0002, 0.0));
|
||||
let layering = 1.0 - 1.0 / 4096.0;
|
||||
let bytes = uniforms([0; 48], &view, pose, jitter, layering, [0.0; 4], [0.0; 6]);
|
||||
let data: Vec<f32> = bytes
|
||||
.as_chunks()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| f32::from_le_bytes(*b))
|
||||
.collect();
|
||||
let projection = Mat4::from_cols_slice(&data[64..80]);
|
||||
let model_view = Mat4::from_cols_slice(&data[128..144]);
|
||||
assert_eq!(&data[160..164], &[1.0; 4], "ColorModulator");
|
||||
let shrink = 1.0 - 1.0 / 256.0;
|
||||
for p in [Vec3::new(1.5, -0.2, 3.0), Vec3::new(-4.0, 2.0, -0.5)] {
|
||||
let pack =
|
||||
projection * Mat4::from_scale(Vec3::splat(shrink)) * model_view * p.extend(1.0);
|
||||
let native = jitter * view.view_proj(pose) * (p * layering * shrink).extend(1.0);
|
||||
let error: Vec4 = (pack - native).abs();
|
||||
assert!(
|
||||
error.max_element() < 1e-5 * native.w.abs(),
|
||||
"{pack} {native}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// One edge across the middle of a 64 by 64 target, 4 pixels wide, blue (ARGB `ff0000ff`),
|
||||
/// at `GameTime` 0.25, drawn with `program`: the RGBA rows read back.
|
||||
fn draw_edge(device: &wgpu::Device, queue: &wgpu::Queue, program: &CoreProgram) -> Vec<u8> {
|
||||
const SIZE: u32 = 64;
|
||||
let lines = CoreLines::new(device, wgpu::TextureFormat::Rgba8Unorm, program)
|
||||
.expect("the pack's pipelines");
|
||||
let target = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = target(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = target(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let view = super::super::View {
|
||||
view: Mat4::IDENTITY,
|
||||
projection: super::super::world_projection(70.0, 1.0, 64.0),
|
||||
aspect: 1.0,
|
||||
tan_half_fov: 35f32.to_radians().tan(),
|
||||
};
|
||||
let globals = crate::post_chain::Globals {
|
||||
game_time: 0.25,
|
||||
..Default::default()
|
||||
};
|
||||
let data = uniforms(
|
||||
globals.bytes([SIZE, SIZE], 0),
|
||||
&view,
|
||||
Mat4::IDENTITY,
|
||||
Mat4::IDENTITY,
|
||||
1.0,
|
||||
[0.0; 4],
|
||||
[0.0; 6],
|
||||
);
|
||||
queue.write_buffer(lines.uniform(), 0, &data);
|
||||
let mut edge = [-1.0, 0.0, -2.0, 1.0, 0.0, -2.0, 0.0, 4.0f32];
|
||||
edge[6] = f32::from_bits(0xff00_00ff);
|
||||
let edges = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&edge),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
},
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(SIZE * SIZE * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color_view = color.create_view(&Default::default());
|
||||
let depth_view = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_vertex_buffer(0, edges.slice(..));
|
||||
lines.draw(&mut pass, false, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The stroke's middle takes the pack's colour (`GameTime` red, its inverse green, the
|
||||
/// vertex colour's blue); a pixel far from it stays clear.
|
||||
fn assert_pack_coloured(pixels: &[u8]) {
|
||||
let at = |x: usize, y: usize| &pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4];
|
||||
for y in [31, 32] {
|
||||
let pixel = at(32, y);
|
||||
let want = [64, 191, 255, 255];
|
||||
assert!(
|
||||
pixel.iter().zip(want).all(|(&p, w)| p.abs_diff(w) <= 1),
|
||||
"row {y}: {pixel:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(at(32, 4), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_fragment_shader_colours_the_outline() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU: skipping");
|
||||
return;
|
||||
};
|
||||
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
|
||||
.expect("the shaders translate");
|
||||
assert_pack_coloured(&draw_edge(&device, &queue, &program));
|
||||
}
|
||||
|
||||
/// A pack's own include with a bigger block than vanilla's (an older `DynamicTransforms`)
|
||||
/// is not used, and the outline draws natively, rather than failing at the draw.
|
||||
#[test]
|
||||
fn a_pack_block_bigger_than_vanillas_falls_back() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let bigger = |id: &str| {
|
||||
include(id)
|
||||
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
|
||||
};
|
||||
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
|
||||
.expect("the shaders translate");
|
||||
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
|
||||
}
|
||||
|
||||
/// Vanilla's own `rendertype_lines.vsh` (from the prepared resources) under a pack's
|
||||
/// fragment shader.
|
||||
#[test]
|
||||
fn vanillas_lines_vertex_shader_takes_a_packs_fragment_shader() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
let Ok(vertex) = std::fs::read_to_string(
|
||||
root.join("assets/minecraft/shaders/core/rendertype_lines.vsh"),
|
||||
) else {
|
||||
eprintln!(
|
||||
"skipping resource-backed lines shader check: {}",
|
||||
root.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
|
||||
};
|
||||
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
|
||||
.expect("vanilla's vertex shader translates");
|
||||
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
|
||||
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
|
||||
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
|
||||
.expect("vanilla's fragment shader translates");
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU: skipping the draw");
|
||||
return;
|
||||
};
|
||||
assert_pack_coloured(&draw_edge(&device, &queue, &program));
|
||||
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &vanilla).is_some());
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,6 @@
|
||||
//! 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
|
||||
@@ -43,11 +39,6 @@ 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
|
||||
@@ -72,17 +63,6 @@ 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 {
|
||||
@@ -151,10 +131,8 @@ pub struct Cull {
|
||||
phase2_capacity: u64,
|
||||
tests: wgpu::Buffer,
|
||||
tests_capacity: u64,
|
||||
/// 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>,
|
||||
/// This frame's tests: visible index, first phase 2 draw, phase 2 draws.
|
||||
test_data: Vec<[u32; 4]>,
|
||||
test_slots: Vec<u32>,
|
||||
test_indices: Vec<u32>,
|
||||
total: u64,
|
||||
@@ -164,10 +142,8 @@ pub struct Cull {
|
||||
readbacks: Vec<Readback>,
|
||||
next: usize,
|
||||
current: Option<usize>,
|
||||
/// Per resident slot: whether the last result read back saw it; and per
|
||||
/// far tile's slot (`TILE`).
|
||||
/// Per resident slot: whether the last result read back saw it.
|
||||
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.
|
||||
@@ -349,7 +325,7 @@ impl Cull {
|
||||
),
|
||||
phase2: buffer(device, "Section draws, phase 2", WORDS, PHASE2_USAGE),
|
||||
phase2_capacity: 1,
|
||||
tests: buffer(device, "Section tests", TEST_BYTES, TESTS_USAGE),
|
||||
tests: buffer(device, "Section tests", 16, TESTS_USAGE),
|
||||
tests_capacity: 1,
|
||||
test_data: Vec::new(),
|
||||
test_slots: Vec::new(),
|
||||
@@ -371,7 +347,6 @@ impl Cull {
|
||||
next: 0,
|
||||
current: None,
|
||||
seen: Vec::new(),
|
||||
seen_tiles: Vec::new(),
|
||||
stamp: 0,
|
||||
hidden: ([0; 2], 1.0),
|
||||
always: false,
|
||||
@@ -403,15 +378,11 @@ 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 (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);
|
||||
let slot = slot as usize;
|
||||
if slot >= self.seen.len() {
|
||||
self.seen.resize(slot + 1, false);
|
||||
}
|
||||
seen[slot] = result == r.stamp;
|
||||
self.seen[slot] = result == r.stamp;
|
||||
}
|
||||
}
|
||||
r.buffer.unmap();
|
||||
@@ -426,61 +397,30 @@ impl Cull {
|
||||
!self.probe || self.stamp.is_multiple_of(PROBE)
|
||||
}
|
||||
|
||||
/// Whether phase 1 draws the section in resident `slot` (or the far tile
|
||||
/// in its `TILE` slot).
|
||||
/// Whether phase 1 draws the section in resident `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 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]) {
|
||||
/// solid indices: tested in turn.
|
||||
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32) {
|
||||
self.total += u64::from(indices);
|
||||
if indices > 0 && (self.probe || slot.wrapping_add(self.stamp).is_multiple_of(RETEST)) {
|
||||
self.push(index, bounds, slot, indices);
|
||||
self.push([index as u32, 0, 0, 0], slot, indices);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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) {
|
||||
self.total += u64::from(indices);
|
||||
(draws > 0).then(|| {
|
||||
self.push(index, bounds, slot, indices);
|
||||
self.test_data.len() as u32 - 1
|
||||
})
|
||||
if draws > 0 {
|
||||
self.push([index as u32, first as u32, draws as u32, 0], slot, indices);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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,
|
||||
});
|
||||
fn push(&mut self, test: [u32; 4], slot: u32, indices: u32) {
|
||||
self.test_data.push(test);
|
||||
self.test_slots.push(slot);
|
||||
self.test_indices.push(indices);
|
||||
}
|
||||
@@ -514,7 +454,7 @@ impl Cull {
|
||||
self.tests = buffer(
|
||||
device,
|
||||
"Section tests",
|
||||
self.tests_capacity * TEST_BYTES,
|
||||
self.tests_capacity * 16,
|
||||
TESTS_USAGE,
|
||||
);
|
||||
self.results = buffer(
|
||||
|
||||
@@ -11,19 +11,10 @@ struct Globals {
|
||||
view_proj: mat4x4<f32>,
|
||||
}
|
||||
|
||||
// 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>;
|
||||
// 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>>;
|
||||
// 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.
|
||||
@@ -45,27 +36,23 @@ struct Box {
|
||||
@location(0) @interpolate(flat) test: u32,
|
||||
}
|
||||
|
||||
fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f32> {
|
||||
fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
|
||||
return origin + vec3(
|
||||
select(low.x, high.x, (c & 1u) != 0u),
|
||||
select(low.y, high.y, (c & 2u) != 0u),
|
||||
select(low.z, high.z, (c & 4u) != 0u),
|
||||
select(-1.0, 17.0, (c & 1u) != 0u),
|
||||
select(-1.0, 17.0, (c & 2u) != 0u),
|
||||
select(-1.0, 17.0, (c & 4u) != 0u),
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
@vertex
|
||||
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
let test = tests[t];
|
||||
let origin = origins[test.data.x].xyz;
|
||||
let low = test.low.xyz;
|
||||
let high = test.high.xyz;
|
||||
let origin = origins[tests[t].x].xyz;
|
||||
var out: Box;
|
||||
out.test = t;
|
||||
// The box's screen rectangle, from the corners in front of the camera.
|
||||
@@ -73,7 +60,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, low, high, c), 1.0);
|
||||
let p = globals.view_proj * vec4(corner(origin, c), 1.0);
|
||||
behind = behind || p.w <= 0.0;
|
||||
let q = p.xy / max(p.w, 1e-6);
|
||||
lo = min(lo, q);
|
||||
@@ -84,7 +71,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(1.0) - low) && all(origin > vec3(-1.0) - high);
|
||||
let near = all(origin < vec3(2.0)) && all(origin > vec3(-18.0));
|
||||
if near || (!behind && size.x * size.y > params.big * 4.0) {
|
||||
var p = vec2(-1.0, 1.0);
|
||||
if v == 1u {
|
||||
@@ -95,7 +82,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
|
||||
return out;
|
||||
}
|
||||
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
|
||||
out.position = globals.view_proj * vec4(corner(origin, CORNERS[v]), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -104,13 +91,10 @@ 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].data;
|
||||
for (var k = 0u; k < (test.z & 0xffffu); k++) {
|
||||
let test = tests[t];
|
||||
for (var k = 0u; k < test.z; k++) {
|
||||
phase2[(test.y + k) * 5u + 1u] = 1u;
|
||||
}
|
||||
for (var k = 0u; k < (test.z >> 16u); k++) {
|
||||
phase2[(test.w + k) * 5u + 1u] = 1u;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
//! `[displacement]` of Horizon's own shader packs (docs/api/shaders.md): near the camera the
|
||||
//! solid faces whose textures have LabPBR heights are drawn again as a grid of cells, each
|
||||
//! corner moved along the face's normal by the pack's `displacement`, while the flat quads
|
||||
//! leave those pixels to them (`native_material.rs`'s `horizon_displaced_layer`).
|
||||
//!
|
||||
//! Each frame the solid quads of the sections around the camera are copied out of the arena
|
||||
//! into one buffer, a compute pass picks the ones the grid draws (opaque, with relief, within
|
||||
//! the distance, facing the eye and in view) into an indirect draw, and the material's
|
||||
//! displaced pipeline draws one instance a quad.
|
||||
|
||||
use crate::mesher::TerrainVertex;
|
||||
|
||||
/// The solid quads the grid can draw a frame, and the sections they come from.
|
||||
const MAX_QUADS: u64 = 1 << 16;
|
||||
const MAX_SECTIONS: usize = 64;
|
||||
/// Bytes of one quad's corners.
|
||||
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
|
||||
/// How far past the flat face the relief may reach, in blocks, for the view test.
|
||||
const RELIEF_MARGIN: f32 = 0.5;
|
||||
|
||||
/// Which quads the grid takes over, for the flat terrain's pipelines (the material's and the
|
||||
/// shadow's) and the grid's: `horizon_displace_distance` and the LabPBR normal maps
|
||||
/// `horizon_pbr_normals` (group 3, binding 12), declared by the module.
|
||||
pub const RELIEF: &str = "
|
||||
// `[displacement] distance` (0 without): the flat quads leave the pixels within it on faces
|
||||
// with relief to the grid.
|
||||
override horizon_displace_distance: f32 = 0.0;
|
||||
// The mean LabPBR height of a sprite: its normal map's last mip level (1 without one).
|
||||
fn horizon_relief_mean(layer: u32) -> f32 {
|
||||
if textureDimensions(horizon_pbr_normals).x <= 1u
|
||||
|| layer >= textureNumLayers(horizon_pbr_normals) {
|
||||
return 1.0;
|
||||
}
|
||||
let top = i32(textureNumLevels(horizon_pbr_normals)) - 1;
|
||||
return textureLoad(horizon_pbr_normals, vec2(0i), i32(layer), top).a;
|
||||
}
|
||||
// The quads the grid redraws near the camera (`PICK`): opaque, on a texture whose height
|
||||
// varies.
|
||||
fn horizon_displaced_layer(layer: u32) -> bool {
|
||||
return horizon_displace_distance > 0.0 && (layer & 0x8000u) != 0u
|
||||
&& horizon_relief_mean(layer & 0x7fffu) < 0.998;
|
||||
}
|
||||
";
|
||||
|
||||
/// The grid's points (`horizon_displace_point`): one instance a quad the pick chose, its
|
||||
/// corners read from the copy of the arena, each of its `horizon_displace_cells` squared
|
||||
/// cells two triangles moved along the face's normal by the pack's `displacement`, measured
|
||||
/// from the texture's mean height so that the face's edges, pinned there, meet their
|
||||
/// neighbours whatever their relief. Needs `RELIEF`, the pack's `displacement` and
|
||||
/// `horizon_material_displace` (its vertex stage).
|
||||
pub const GRID: &str = "
|
||||
struct HorizonDisplaceSection {
|
||||
origin: vec4<f32>,
|
||||
first: u32,
|
||||
quads: u32,
|
||||
pad0: u32,
|
||||
pad1: u32,
|
||||
}
|
||||
@group(1) @binding(8) var<storage, read> horizon_displace_vertices: array<u32>;
|
||||
@group(1) @binding(9) var<storage, read> horizon_displace_picked: array<vec2<u32>>;
|
||||
@group(1) @binding(10) var<storage, read> horizon_displace_sections: array<HorizonDisplaceSection>;
|
||||
// The grid's cells along a face's side.
|
||||
override horizon_displace_cells: u32 = 16u;
|
||||
// Each cell a flat step with walls to its neighbours (`grid_vertices`), not a smooth surface.
|
||||
override horizon_displace_steps: bool = false;
|
||||
struct HorizonDisplaceCorner {
|
||||
position: vec3<f32>,
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
light: vec2<f32>,
|
||||
color: vec4<f32>,
|
||||
}
|
||||
// A corner of a quad as `TerrainVertex` holds it (24 bytes); light: sky, block.
|
||||
fn horizon_displace_corner(quad: u32, i: u32, step: f32) -> HorizonDisplaceCorner {
|
||||
let b = (quad * 4u + i) * 6u;
|
||||
var c: HorizonDisplaceCorner;
|
||||
c.position = vec3(bitcast<f32>(horizon_displace_vertices[b]),
|
||||
bitcast<f32>(horizon_displace_vertices[b + 1u]),
|
||||
bitcast<f32>(horizon_displace_vertices[b + 2u]));
|
||||
let uv = horizon_displace_vertices[b + 3u];
|
||||
c.uv = vec2<f32>(vec2(uv & 0xffffu, uv >> 16u)) * step;
|
||||
let w = horizon_displace_vertices[b + 4u];
|
||||
c.layer = w & 0xffffu;
|
||||
c.light = vec2(f32((w >> 16u) & 0xffu), f32(w >> 24u));
|
||||
c.color = unpack4x8unorm(horizon_displace_vertices[b + 5u]);
|
||||
return c;
|
||||
}
|
||||
// The height map at `uv`, filtered between the texels of mip level `lod`.
|
||||
fn horizon_displace_height(uv: vec2<f32>, layer: u32, lod: i32) -> f32 {
|
||||
let size = vec2<i32>(textureDimensions(horizon_pbr_normals, lod));
|
||||
let p = clamp(uv, vec2(0.0), vec2(1.0)) * vec2<f32>(size) - 0.5;
|
||||
let i = vec2<i32>(floor(p));
|
||||
let f = p - floor(p);
|
||||
let m = size - 1;
|
||||
let l = i32(layer);
|
||||
let a = textureLoad(horizon_pbr_normals, clamp(i, vec2(0), m), l, lod).a;
|
||||
let b = textureLoad(horizon_pbr_normals, clamp(i + vec2(1, 0), vec2(0), m), l, lod).a;
|
||||
let c = textureLoad(horizon_pbr_normals, clamp(i + vec2(0, 1), vec2(0), m), l, lod).a;
|
||||
let d = textureLoad(horizon_pbr_normals, clamp(i + vec2(1, 1), vec2(0), m), l, lod).a;
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
// The height map's texel at `uv` of mip level `lod`.
|
||||
fn horizon_displace_texel(uv: vec2<f32>, layer: u32, lod: i32) -> f32 {
|
||||
let size = vec2<i32>(textureDimensions(horizon_pbr_normals, lod));
|
||||
let i = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2(0), size - 1);
|
||||
return textureLoad(horizon_pbr_normals, i, i32(layer), lod).a;
|
||||
}
|
||||
struct HorizonDisplacePoint {
|
||||
// Where it is drawn, and where it lies on the flat face (both camera-relative).
|
||||
position: vec3<f32>,
|
||||
base: vec3<f32>,
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
light: vec2<f32>,
|
||||
color: vec4<f32>,
|
||||
// The face's normal, and how much of the relief the point has by its distance.
|
||||
normal: vec3<f32>,
|
||||
near: f32,
|
||||
}
|
||||
// A quad's corners, for the steps.
|
||||
struct HorizonDisplaceQuad {
|
||||
c0: HorizonDisplaceCorner,
|
||||
c1: HorizonDisplaceCorner,
|
||||
c2: HorizonDisplaceCorner,
|
||||
c3: HorizonDisplaceCorner,
|
||||
origin: vec3<f32>,
|
||||
normal: vec3<f32>,
|
||||
}
|
||||
fn horizon_displace_at(q: HorizonDisplaceQuad, s: f32, t: f32) -> vec3<f32> {
|
||||
return mix(mix(q.c0.position, q.c1.position, s), mix(q.c3.position, q.c2.position, s), t)
|
||||
+ q.origin;
|
||||
}
|
||||
fn horizon_displace_uv(q: HorizonDisplaceQuad, s: f32, t: f32) -> vec2<f32> {
|
||||
return mix(mix(q.c0.uv, q.c1.uv, s), mix(q.c3.uv, q.c2.uv, s), t);
|
||||
}
|
||||
// How far cell (`i`, `j`) sinks: nothing outside the face, whose edges keep the mean height so
|
||||
// that they meet their neighbours.
|
||||
fn horizon_displace_cell_depth(q: HorizonDisplaceQuad, i: i32, j: i32) -> f32 {
|
||||
let g = i32(horizon_displace_cells);
|
||||
if i < 0 || j < 0 || i >= g || j >= g {
|
||||
return 0.0;
|
||||
}
|
||||
let s = (f32(i) + 0.5) / f32(g);
|
||||
let t = (f32(j) + 0.5) / f32(g);
|
||||
let base = horizon_displace_at(q, s, t);
|
||||
let uv = horizon_displace_uv(q, s, t);
|
||||
let layer = q.c0.layer & 0x7fffu;
|
||||
let lods = i32(textureNumLevels(horizon_pbr_normals)) - 1;
|
||||
let size = f32(textureDimensions(horizon_pbr_normals).x);
|
||||
let lod = clamp(i32(floor(log2(size / f32(g)))), 0, lods);
|
||||
let material_id = horizon_sprite_material(layer);
|
||||
let height = horizon_displace_texel(uv, layer, lod);
|
||||
let mean = horizon_relief_mean(layer);
|
||||
let depth = displacement(DisplacementInput(base, q.normal, uv, material_id, height))
|
||||
- displacement(DisplacementInput(base, q.normal, uv, material_id, mean));
|
||||
let r = horizon_displace_distance;
|
||||
return depth * (1.0 - smoothstep(0.75 * r, r - 0.1, length(base)));
|
||||
}
|
||||
// Vertex `v` of a stepped grid: per cell, from -1 so that the first walls have a cell before
|
||||
// them, its flat top and its walls to the next cell along each side. A wall faces the way
|
||||
// out of the higher cell and takes that cell's texture.
|
||||
fn horizon_displace_step(v: u32, q: HorizonDisplaceQuad) -> HorizonDisplacePoint {
|
||||
let g = horizon_displace_cells;
|
||||
let cell = v / 18u;
|
||||
let part = (v / 6u) % 3u;
|
||||
let i = i32(cell % (g + 1u)) - 1;
|
||||
let j = i32(cell / (g + 1u)) - 1;
|
||||
let cells = f32(g);
|
||||
var k = v % 6u;
|
||||
var s = 0.0;
|
||||
var t = 0.0;
|
||||
var uv_s = 0.0;
|
||||
var uv_t = 0.0;
|
||||
var depth = 0.0;
|
||||
if part == 0u {
|
||||
if i >= 0 && j >= 0 {
|
||||
s = (f32(i) + f32((0x16u >> k) & 1u)) / cells;
|
||||
t = (f32(j) + f32((0x34u >> k) & 1u)) / cells;
|
||||
depth = horizon_displace_cell_depth(q, i, j);
|
||||
}
|
||||
uv_s = s;
|
||||
uv_t = t;
|
||||
} else if part == 1u {
|
||||
if j >= 0 {
|
||||
let here = horizon_displace_cell_depth(q, i, j);
|
||||
let next = horizon_displace_cell_depth(q, i + 1, j);
|
||||
if next < here {
|
||||
k = (0x453120u >> (k * 4u)) & 15u;
|
||||
}
|
||||
s = f32(i + 1) / cells;
|
||||
t = (f32(j) + f32((0x16u >> k) & 1u)) / cells;
|
||||
depth = select(max(here, next), min(here, next), ((0x34u >> k) & 1u) == 1u);
|
||||
uv_s = s + select(0.25, -0.25, here <= next) / cells;
|
||||
uv_t = t;
|
||||
}
|
||||
} else {
|
||||
if i >= 0 {
|
||||
let here = horizon_displace_cell_depth(q, i, j);
|
||||
let next = horizon_displace_cell_depth(q, i, j + 1);
|
||||
if here < next {
|
||||
k = (0x453120u >> (k * 4u)) & 15u;
|
||||
}
|
||||
s = (f32(i) + f32((0x16u >> k) & 1u)) / cells;
|
||||
t = f32(j + 1) / cells;
|
||||
depth = select(max(here, next), min(here, next), ((0x34u >> k) & 1u) == 1u);
|
||||
uv_s = s;
|
||||
uv_t = t + select(0.25, -0.25, here <= next) / cells;
|
||||
}
|
||||
}
|
||||
var p: HorizonDisplacePoint;
|
||||
let position = horizon_displace_at(q, s, t);
|
||||
let c = clamp(vec2(uv_s, uv_t), vec2(0.0), vec2(1.0));
|
||||
p.uv = horizon_displace_uv(q, c.x, c.y);
|
||||
p.light = mix(mix(q.c0.light, q.c1.light, s), mix(q.c3.light, q.c2.light, s), t);
|
||||
p.color = mix(mix(q.c0.color, q.c1.color, s), mix(q.c3.color, q.c2.color, s), t);
|
||||
p.layer = q.c0.layer;
|
||||
p.normal = q.normal;
|
||||
p.base = horizon_material_displace(position, p.uv, q.c0.layer, p.color,
|
||||
vec2<u32>(p.light + 0.5));
|
||||
p.near = 1.0 - smoothstep(0.75 * horizon_displace_distance, horizon_displace_distance - 0.1,
|
||||
length(p.base));
|
||||
p.position = p.base - p.normal * depth;
|
||||
return p;
|
||||
}
|
||||
// Vertex `v` of instance `instance`'s grid.
|
||||
fn horizon_displace_point(v: u32, instance: u32) -> HorizonDisplacePoint {
|
||||
let pick = horizon_displace_picked[instance];
|
||||
let section = horizon_displace_sections[pick.y];
|
||||
let c0 = horizon_displace_corner(pick.x, 0u, section.origin.w);
|
||||
let c1 = horizon_displace_corner(pick.x, 1u, section.origin.w);
|
||||
let c2 = horizon_displace_corner(pick.x, 2u, section.origin.w);
|
||||
let c3 = horizon_displace_corner(pick.x, 3u, section.origin.w);
|
||||
if horizon_displace_steps {
|
||||
return horizon_displace_step(v, HorizonDisplaceQuad(c0, c1, c2, c3, section.origin.xyz,
|
||||
normalize(cross(c1.position - c0.position, c2.position - c0.position))));
|
||||
}
|
||||
// The cell's two triangles wind as the quad's (0, 1, 2) and (0, 2, 3).
|
||||
let g = horizon_displace_cells;
|
||||
let cell = v / 6u;
|
||||
let k = v % 6u;
|
||||
let s = f32(cell % g + ((0x16u >> k) & 1u)) / f32(g);
|
||||
let t = f32(cell / g + ((0x34u >> k) & 1u)) / f32(g);
|
||||
var p: HorizonDisplacePoint;
|
||||
let position = mix(mix(c0.position, c1.position, s), mix(c3.position, c2.position, s), t);
|
||||
p.uv = mix(mix(c0.uv, c1.uv, s), mix(c3.uv, c2.uv, s), t);
|
||||
p.light = mix(mix(c0.light, c1.light, s), mix(c3.light, c2.light, s), t);
|
||||
p.color = mix(mix(c0.color, c1.color, s), mix(c3.color, c2.color, s), t);
|
||||
p.layer = c0.layer;
|
||||
p.normal = normalize(cross(c1.position - c0.position, c2.position - c0.position));
|
||||
p.base = horizon_material_displace(position + section.origin.xyz, p.uv, c0.layer, p.color,
|
||||
vec2<u32>(p.light + 0.5));
|
||||
let layer = c0.layer & 0x7fffu;
|
||||
// The heights at about a texel a cell.
|
||||
let lods = i32(textureNumLevels(horizon_pbr_normals)) - 1;
|
||||
let size = f32(textureDimensions(horizon_pbr_normals).x);
|
||||
let lod = clamp(i32(floor(log2(size / f32(g)))), 0, lods);
|
||||
let material_id = horizon_sprite_material(layer);
|
||||
let height = horizon_displace_height(p.uv, layer, lod);
|
||||
let mean = horizon_relief_mean(layer);
|
||||
let depth = displacement(DisplacementInput(p.base, p.normal, p.uv, material_id, height))
|
||||
- displacement(DisplacementInput(p.base, p.normal, p.uv, material_id, mean));
|
||||
// Pinned at the face's edges, easing out toward the distance.
|
||||
let edge = min(min(s, 1.0 - s), min(t, 1.0 - t)) * f32(g);
|
||||
let r = horizon_displace_distance;
|
||||
p.near = 1.0 - smoothstep(0.75 * r, r - 0.1, length(p.base));
|
||||
p.position = p.base - p.normal * depth * clamp(edge, 0.0, 1.0) * p.near;
|
||||
return p;
|
||||
}
|
||||
";
|
||||
|
||||
const PICK: &str = "
|
||||
struct Params {
|
||||
view_proj: mat4x4<f32>,
|
||||
distance: f32,
|
||||
sections: u32,
|
||||
margin: f32,
|
||||
capacity: u32,
|
||||
// 1 for the shadow map's list: every side, wherever the eye looks.
|
||||
shadow: u32,
|
||||
pad0: u32,
|
||||
pad1: u32,
|
||||
pad2: u32,
|
||||
}
|
||||
struct Section {
|
||||
origin: vec4<f32>,
|
||||
first: u32,
|
||||
quads: u32,
|
||||
pad0: u32,
|
||||
pad1: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> sections: array<Section>;
|
||||
@group(0) @binding(2) var<storage, read> vertices: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> picked: array<vec2<u32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> draw: array<atomic<u32>, 4>;
|
||||
@group(0) @binding(5) var normals: texture_2d_array<f32>;
|
||||
|
||||
fn corner(quad: u32, i: u32, origin: vec3<f32>) -> vec3<f32> {
|
||||
let b = (quad * 4u + i) * 6u;
|
||||
return vec3(bitcast<f32>(vertices[b]), bitcast<f32>(vertices[b + 1u]),
|
||||
bitcast<f32>(vertices[b + 2u])) + origin;
|
||||
}
|
||||
|
||||
// Every corner of the box beyond one side of the view.
|
||||
fn outside(lo: vec3<f32>, hi: vec3<f32>) -> bool {
|
||||
var out = vec4(true);
|
||||
var behind = true;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let p = select(lo, hi, vec3((i & 1u) != 0u, (i & 2u) != 0u, (i & 4u) != 0u));
|
||||
let c = params.view_proj * vec4(p, 1.0);
|
||||
out = out & vec4((c.x < -c.w), (c.x > c.w), (c.y < -c.w), (c.y > c.w));
|
||||
behind = behind && c.w <= 0.0;
|
||||
}
|
||||
return any(out) || behind;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64) fn pick(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if id.y >= params.sections {
|
||||
return;
|
||||
}
|
||||
let section = sections[id.y];
|
||||
if id.x >= section.quads {
|
||||
return;
|
||||
}
|
||||
let quad = section.first + id.x;
|
||||
// As `horizon_displaced_layer`: opaque, with a LabPBR height that varies.
|
||||
let layer = vertices[quad * 24u + 4u] & 0xffffu;
|
||||
let l = layer & 0x7fffu;
|
||||
if (layer & 0x8000u) == 0u || textureDimensions(normals).x <= 1u
|
||||
|| l >= textureNumLayers(normals) {
|
||||
return;
|
||||
}
|
||||
let top = i32(textureNumLevels(normals)) - 1;
|
||||
if textureLoad(normals, vec2(0i), i32(l), top).a >= 0.998 {
|
||||
return;
|
||||
}
|
||||
let p0 = corner(quad, 0u, section.origin.xyz);
|
||||
let p1 = corner(quad, 1u, section.origin.xyz);
|
||||
let p2 = corner(quad, 2u, section.origin.xyz);
|
||||
let p3 = corner(quad, 3u, section.origin.xyz);
|
||||
let lo = min(min(p0, p1), min(p2, p3));
|
||||
let hi = max(max(p0, p1), max(p2, p3));
|
||||
if length(clamp(vec3(0.0), lo, hi)) >= params.distance {
|
||||
return;
|
||||
}
|
||||
// Facing away from the eye (at the origin), where the flat quad is culled too, or out of
|
||||
// its view; the shadow map takes them all, as the flat terrain draws both sides there.
|
||||
if params.shadow == 0u && (dot(cross(p1 - p0, p2 - p0), p0) >= 0.0
|
||||
|| outside(lo - params.margin, hi + params.margin)) {
|
||||
return;
|
||||
}
|
||||
let i = atomicAdd(&draw[1], 1u);
|
||||
if i >= params.capacity {
|
||||
atomicSub(&draw[1], 1u);
|
||||
return;
|
||||
}
|
||||
picked[i] = vec2(quad, id.y);
|
||||
}
|
||||
";
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
view_proj: [[f32; 4]; 4],
|
||||
distance: f32,
|
||||
sections: u32,
|
||||
margin: f32,
|
||||
capacity: u32,
|
||||
shadow: u32,
|
||||
pad: [u32; 3],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Section {
|
||||
origin: [f32; 4],
|
||||
first: u32,
|
||||
quads: u32,
|
||||
pad: [u32; 2],
|
||||
}
|
||||
|
||||
/// A section the grid may draw from this frame: its arena range of solid quads and its
|
||||
/// camera-relative origin.
|
||||
pub struct Near<'a> {
|
||||
pub buffer: &'a wgpu::Buffer,
|
||||
pub start: u64,
|
||||
pub quads: u32,
|
||||
pub origin: [f32; 3],
|
||||
}
|
||||
|
||||
/// One list of picked quads and its indirect draw.
|
||||
pub struct Picks {
|
||||
/// The grid's cells along a face's side.
|
||||
pub cells: u32,
|
||||
/// Each cell a flat step with walls.
|
||||
pub steps: bool,
|
||||
params: wgpu::Buffer,
|
||||
/// The indirect draw: the grid's vertices, then the quads picked.
|
||||
draw: wgpu::Buffer,
|
||||
pick_group: wgpu::BindGroup,
|
||||
/// Group 1 of the pipelines drawing it (bindings 8 to 10).
|
||||
pub draw_group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Displacement {
|
||||
/// `[displacement] distance`, in blocks.
|
||||
pub distance: f32,
|
||||
vertices: wgpu::Buffer,
|
||||
sections: wgpu::Buffer,
|
||||
pick: wgpu::ComputePipeline,
|
||||
/// The quads the eye sees, and the ones the shadow map draws (when the pack has one).
|
||||
pub view: Picks,
|
||||
pub shadow: Option<Picks>,
|
||||
/// Group 1's layout for the grid's pipelines.
|
||||
pub draw_layout: wgpu::BindGroupLayout,
|
||||
/// Sections the pick ran over this frame (0: nothing to draw).
|
||||
pub drawn: usize,
|
||||
}
|
||||
|
||||
fn storage(
|
||||
binding: u32,
|
||||
read_only: bool,
|
||||
visibility: wgpu::ShaderStages,
|
||||
) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow map's grid is coarser: its texels near the camera are a 32nd of a block.
|
||||
pub const SHADOW_CELLS: u32 = 16;
|
||||
|
||||
/// The vertices of one quad's grid: two triangles a cell, or in `steps` also two walls a cell,
|
||||
/// with a row and a column more for the walls at the face's first edges.
|
||||
fn grid_vertices(cells: u32, steps: bool) -> u32 {
|
||||
if steps {
|
||||
(cells + 1) * (cells + 1) * 18
|
||||
} else {
|
||||
cells * cells * 6
|
||||
}
|
||||
}
|
||||
|
||||
impl Displacement {
|
||||
/// The grid's buffers and pick pass for `[displacement]` with `distance` and `cells`, in
|
||||
/// `steps` or not, reading the LabPBR normal maps `normals`; with a list for the shadow map
|
||||
/// when `shadow`.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
distance: f32,
|
||||
cells: u32,
|
||||
steps: bool,
|
||||
normals: &wgpu::TextureView,
|
||||
shadow: bool,
|
||||
) -> Self {
|
||||
let buffer = |label, size, usage| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
use wgpu::BufferUsages as U;
|
||||
let vertices = buffer(
|
||||
"Displacement quads",
|
||||
MAX_QUADS * QUAD_BYTES,
|
||||
U::STORAGE | U::COPY_DST,
|
||||
);
|
||||
let sections = buffer(
|
||||
"Displacement sections",
|
||||
(MAX_SECTIONS * std::mem::size_of::<Section>()) as u64,
|
||||
U::STORAGE | U::COPY_DST,
|
||||
);
|
||||
let compute = wgpu::ShaderStages::COMPUTE;
|
||||
let pick_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: compute,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
storage(1, true, compute),
|
||||
storage(2, true, compute),
|
||||
storage(3, false, compute),
|
||||
storage(4, false, compute),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 5,
|
||||
visibility: compute,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let vertex = wgpu::ShaderStages::VERTEX;
|
||||
let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Displacement draw"),
|
||||
entries: &[
|
||||
storage(8, true, vertex),
|
||||
storage(9, true, vertex),
|
||||
storage(10, true, vertex),
|
||||
],
|
||||
});
|
||||
fn entry(binding: u32, resource: wgpu::BindingResource<'_>) -> wgpu::BindGroupEntry<'_> {
|
||||
wgpu::BindGroupEntry { binding, resource }
|
||||
}
|
||||
let picks = |cells: u32| {
|
||||
let params = buffer(
|
||||
"Displacement parameters",
|
||||
std::mem::size_of::<Params>() as u64,
|
||||
U::UNIFORM | U::COPY_DST,
|
||||
);
|
||||
let picked = buffer("Displacement picks", MAX_QUADS * 8, U::STORAGE);
|
||||
let draw = buffer(
|
||||
"Displacement draw",
|
||||
16,
|
||||
U::STORAGE | U::INDIRECT | U::COPY_DST,
|
||||
);
|
||||
let pick_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
layout: &pick_layout,
|
||||
entries: &[
|
||||
entry(0, params.as_entire_binding()),
|
||||
entry(1, sections.as_entire_binding()),
|
||||
entry(2, vertices.as_entire_binding()),
|
||||
entry(3, picked.as_entire_binding()),
|
||||
entry(4, draw.as_entire_binding()),
|
||||
entry(5, wgpu::BindingResource::TextureView(normals)),
|
||||
],
|
||||
});
|
||||
let draw_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Displacement draw"),
|
||||
layout: &draw_layout,
|
||||
entries: &[
|
||||
entry(8, vertices.as_entire_binding()),
|
||||
entry(9, picked.as_entire_binding()),
|
||||
entry(10, sections.as_entire_binding()),
|
||||
],
|
||||
});
|
||||
Picks {
|
||||
cells,
|
||||
steps,
|
||||
params,
|
||||
draw,
|
||||
pick_group,
|
||||
draw_group,
|
||||
}
|
||||
};
|
||||
let view = picks(cells);
|
||||
// A step's flat top has to lie where its shadow was cast from, or it shadows itself in
|
||||
// grain; a smooth surface hides the difference and can take fewer cells.
|
||||
let shadow = shadow.then(|| {
|
||||
picks(if steps {
|
||||
cells
|
||||
} else {
|
||||
cells.min(SHADOW_CELLS)
|
||||
})
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
source: wgpu::ShaderSource::Wgsl(PICK.into()),
|
||||
});
|
||||
let pick = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
bind_group_layouts: &[Some(&pick_layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some("pick"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
distance,
|
||||
vertices,
|
||||
sections,
|
||||
pick,
|
||||
view,
|
||||
shadow,
|
||||
draw_layout,
|
||||
drawn: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the solid quads of `near` (nearest first) and picks this frame's in `encoder`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
near: &[Near<'_>],
|
||||
view_proj: glam::Mat4,
|
||||
) {
|
||||
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
|
||||
let mut first = 0u64;
|
||||
let mut most = 0u32;
|
||||
for n in near.iter().take(MAX_SECTIONS) {
|
||||
let quads = u64::from(n.quads).min(MAX_QUADS - first);
|
||||
if quads == 0 {
|
||||
break;
|
||||
}
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
encoder.copy_buffer_to_buffer(
|
||||
n.buffer,
|
||||
n.start * stride,
|
||||
&self.vertices,
|
||||
first * QUAD_BYTES,
|
||||
quads * QUAD_BYTES,
|
||||
);
|
||||
table.push(Section {
|
||||
origin: [
|
||||
n.origin[0],
|
||||
n.origin[1],
|
||||
n.origin[2],
|
||||
1.0 / crate::mesher::UV_STEPS,
|
||||
],
|
||||
first: first as u32,
|
||||
quads: quads as u32,
|
||||
pad: [0; 2],
|
||||
});
|
||||
first += quads;
|
||||
most = most.max(quads as u32);
|
||||
}
|
||||
self.drawn = table.len();
|
||||
if table.is_empty() {
|
||||
return;
|
||||
}
|
||||
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
|
||||
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pick);
|
||||
for (picks, shadow) in lists {
|
||||
let params = Params {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
distance: self.distance,
|
||||
sections: table.len() as u32,
|
||||
margin: RELIEF_MARGIN,
|
||||
capacity: MAX_QUADS as u32,
|
||||
shadow,
|
||||
pad: [0; 3],
|
||||
};
|
||||
queue.write_buffer(&picks.params, 0, bytemuck::bytes_of(¶ms));
|
||||
let vertices = grid_vertices(picks.cells, picks.steps);
|
||||
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
|
||||
pass.set_bind_group(0, &picks.pick_group, &[]);
|
||||
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `picks`' grids with `pipeline`, its other groups bound.
|
||||
pub fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
picks: &Picks,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
) {
|
||||
if self.drawn == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(1, &picks.draw_group, &[]);
|
||||
pass.draw_indirect(&picks.draw, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_stepped_grid_has_a_top_and_two_walls_a_cell_and_the_first_edges() {
|
||||
assert_eq!(super::grid_vertices(16, false), 16 * 16 * 6);
|
||||
assert_eq!(super::grid_vertices(16, true), 17 * 17 * 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_validates() {
|
||||
let module = naga::front::wgsl::parse_str(super::PICK)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::PICK)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::PICK)));
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,8 @@ 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,
|
||||
/// and where its cutout pass begins in phase 2's.
|
||||
pass_calls: [usize; 3],
|
||||
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
|
||||
pass_calls: [usize; 2],
|
||||
total: u64,
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
@@ -54,15 +53,10 @@ 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.
|
||||
@@ -87,9 +81,8 @@ 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, 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.
|
||||
/// 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.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: wgpu::RenderPipeline,
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
@@ -135,9 +128,8 @@ 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]`;
|
||||
/// over phase 2, `calls[phase1_calls..pass_calls[2]]` and the rest.
|
||||
pub pass_calls: [usize; 3],
|
||||
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
|
||||
pub pass_calls: [usize; 2],
|
||||
/// 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.
|
||||
@@ -145,10 +137,6 @@ 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.
|
||||
@@ -259,8 +247,7 @@ 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,
|
||||
/// and phase 2's from `passes[2]` on (its cutout pass's).
|
||||
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
@@ -268,7 +255,7 @@ fn plan(
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 3],
|
||||
passes: [usize; 2],
|
||||
align: u64,
|
||||
key: impl Fn(u32, Option<u32>) -> u32,
|
||||
calls: &mut Vec<Call>,
|
||||
@@ -280,7 +267,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; 3];
|
||||
let mut pass_calls = [None; 2];
|
||||
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() {
|
||||
@@ -342,22 +329,14 @@ fn plan(
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
|
||||
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),
|
||||
]
|
||||
},
|
||||
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
/// `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`).
|
||||
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
|
||||
/// terrain pipeline as the per-section ones, without the origin buffer.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
@@ -370,18 +349,13 @@ 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),
|
||||
storage(ARENA_BINDING, vertex, true),
|
||||
],
|
||||
entries: &[storage(0, vertex, true), storage(1, vertex, true)],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Flat terrain layout"),
|
||||
@@ -430,44 +404,38 @@ 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_section_flat",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_section_flat_opaque",
|
||||
"vs_terrain_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_section_flat_cutout",
|
||||
"vs_terrain_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_section_flat",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid_mirror",
|
||||
None,
|
||||
Some(wgpu::Face::Front),
|
||||
true,
|
||||
),
|
||||
translucent: pipeline(
|
||||
&layout,
|
||||
@@ -476,7 +444,6 @@ impl Flat {
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
false,
|
||||
),
|
||||
translucent_mark: pipeline(
|
||||
&layout,
|
||||
@@ -485,7 +452,6 @@ impl Flat {
|
||||
"fs_translucent_mark",
|
||||
Some(ALPHA_ONLY),
|
||||
back,
|
||||
false,
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
@@ -524,12 +490,11 @@ impl Flat {
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
pass_calls: [0; 3],
|
||||
pass_calls: [0; 2],
|
||||
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`:
|
||||
@@ -574,15 +539,13 @@ 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::COPY_SRC,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
),
|
||||
group: None,
|
||||
arena: None,
|
||||
});
|
||||
let first = start / GRAIN;
|
||||
let ids = vec![id; vertices.div_ceil(GRAIN) as usize];
|
||||
@@ -643,7 +606,7 @@ impl Flat {
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 3],
|
||||
passes: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
translucent_arena: bool,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
@@ -717,7 +680,7 @@ impl Flat {
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
self.shadow_calls = [0; 2];
|
||||
self.pass_calls = [0; 3];
|
||||
self.pass_calls = [0; 2];
|
||||
return false;
|
||||
}
|
||||
if self.range_data.len() as u64 > self.range_capacity {
|
||||
@@ -746,11 +709,10 @@ 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, if page_instances { c.page } else { 0 }]),
|
||||
.map(|c| [0, 1, c.first, 0, 0]),
|
||||
);
|
||||
let phase2_calls = self.phase2_call_data.len() as u64;
|
||||
if phase2_calls > self.phase2_call_capacity {
|
||||
@@ -845,52 +807,28 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
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;
|
||||
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(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
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()
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(
|
||||
@@ -941,46 +879,6 @@ 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.
|
||||
@@ -998,22 +896,19 @@ impl Flat {
|
||||
pass.draw(call.first..call.first + call.count, 0..instances);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's `calls` (`calls[phase1_calls..solid_calls]` counted from 0), as
|
||||
/// `flatten_phase2` left them.
|
||||
/// Draws phase 2's calls, 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);
|
||||
let first = self.phase1_calls + calls.start;
|
||||
for (k, call) in self.calls[first..self.phase1_calls + calls.end]
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, (calls.start + k) as u64 * 20);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
fn bind_page<'a>(
|
||||
@@ -1073,7 +968,7 @@ mod tests {
|
||||
solid,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0, solid],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
@@ -1122,14 +1017,14 @@ mod tests {
|
||||
solid,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[3, 1, solid],
|
||||
[3, 1],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1, 4], 4));
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
assert_eq!(
|
||||
calls,
|
||||
@@ -1158,7 +1053,7 @@ mod tests {
|
||||
solid,
|
||||
solid,
|
||||
[camera, camera + 2],
|
||||
[camera, 0, solid],
|
||||
[camera, 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
@@ -1187,7 +1082,7 @@ mod tests {
|
||||
2,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0, solid],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
@@ -1200,7 +1095,7 @@ mod tests {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
shadow_calls: [4, 4],
|
||||
pass_calls: [1, 0, 3],
|
||||
pass_calls: [1, 0],
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
@@ -1305,7 +1200,7 @@ mod tests {
|
||||
phase1,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0, solid],
|
||||
[draws.len(), 0],
|
||||
align,
|
||||
|page, _| page,
|
||||
&mut calls,
|
||||
|
||||
@@ -60,7 +60,6 @@ struct Pipelines {
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
scd_finalize: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
@@ -85,7 +84,6 @@ pub struct OpticalFlow {
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_finalize: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
@@ -185,7 +183,6 @@ impl OpticalFlow {
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
scd_finalize: pipeline("scd_finalize_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
@@ -238,7 +235,6 @@ impl OpticalFlow {
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
@@ -302,22 +298,11 @@ impl OpticalFlow {
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
let scd_finalize = bind(
|
||||
device,
|
||||
&pipelines.scd_finalize,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
@@ -327,7 +312,6 @@ impl OpticalFlow {
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_finalize,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
@@ -422,7 +406,6 @@ impl OpticalFlow {
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
@@ -614,22 +597,6 @@ pub(super) mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
@@ -663,40 +630,24 @@ pub(super) mod tests {
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cuts_and_stable_frames_update_the_history() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [384, 216];
|
||||
let bright = upload(
|
||||
&device,
|
||||
&queue,
|
||||
size,
|
||||
&vec![255; (size[0] * size[1] * 4) as usize],
|
||||
);
|
||||
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
|
||||
.flat_map(|_| [0, 0, 0, 255])
|
||||
.collect();
|
||||
let dark = upload(&device, &queue, size, &dark_pixels);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
run(&device, &queue, &mut flow, &bright, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &bright, false);
|
||||
}
|
||||
for frame in 0..16 {
|
||||
let color = if frame % 2 == 0 { &dark } else { &bright };
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ struct Params {
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
@@ -266,6 +266,7 @@ fn scd_divergence_pass(
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
@@ -325,32 +326,27 @@ fn scd_divergence_pass(
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_next_histogram[gid.x] = current;
|
||||
}
|
||||
}
|
||||
|
||||
// All divergence workgroups must finish reading both histograms before either is
|
||||
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
if gid.x == 0u {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,46 +1,30 @@
|
||||
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
|
||||
// `cull`: one invocation per section id keeps the sections whose column is
|
||||
// within the render distance, that cave culling's graph reaches (occlusion.rs)
|
||||
// and whose box is in the frustum, writes each one's origin and the runs of its
|
||||
// solid quads that can face the eye, a draw each, and counts them in its page,
|
||||
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
|
||||
// into where each block's draws start, and the page's count. `scatter`: each
|
||||
// block writes its sections' draws there, in id order and each section's in its
|
||||
// runs' order, so a page's draws come in the same order every frame (atomics
|
||||
// alone would order them as the GPU schedules, and where faces of two sections
|
||||
// meet at the same depth the later draw keeps the pixel). Nothing here computes
|
||||
// with floats: the origins are the CPU's bits, copied, the frustum test adds
|
||||
// integers the CPU computed, and facing compares keys that order as the floats
|
||||
// the CPU compares. `cull` leaves sections fading in or out to the CPU
|
||||
// (`FADING`).
|
||||
// `cull`: one invocation per section id keeps the sections the CPU's walk
|
||||
// chose, writes each one's origin and counts it in its page, per block of 64
|
||||
// ids. `scan`: one workgroup per page turns the blocks' counts into where
|
||||
// each block's draws start, and the page's count. `scatter`: each block
|
||||
// writes its sections' draws there, in id order, so a page's draws come in
|
||||
// the same order every frame (atomics alone would order them as the GPU
|
||||
// schedules, and where faces of two sections meet at the same depth the later
|
||||
// draw keeps the pixel). Nothing here computes with floats: the origins are
|
||||
// the CPU's bits, copied.
|
||||
|
||||
struct Params {
|
||||
// Ids to test, records `apply` writes, the arena pages the counts and
|
||||
// regions cover, and where the regions start in `tables`.
|
||||
// regions cover, and where the regions and the chosen ids' bits start in
|
||||
// `tables`.
|
||||
ids: u32,
|
||||
updates: u32,
|
||||
pages: u32,
|
||||
regions: u32,
|
||||
// Bits 3j, 3j + 1 and 3j + 2: frustum plane j's x, y and z are at least 0.
|
||||
signs: u32,
|
||||
// The render distance in columns, and the camera's column.
|
||||
near: i32,
|
||||
cam_x: i32,
|
||||
cam_z: i32,
|
||||
mask: u32,
|
||||
pad0: u32,
|
||||
pad1: u32,
|
||||
pad2: u32,
|
||||
// The tables' first column: x, 0, z, 0.
|
||||
first: vec4<i32>,
|
||||
// Their entries along x, y and z, and 0.
|
||||
cells: vec4<u32>,
|
||||
// Each frustum plane's w term, its margin in, then 0, 0.
|
||||
w: array<vec4<i32>, 2>,
|
||||
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
|
||||
// section y; its height in sections and the level's; REACH, SKY and EYE;
|
||||
// where the eye's keys start in `tables`.
|
||||
reach: vec4<i32>,
|
||||
reach_height: i32,
|
||||
reach_sections: i32,
|
||||
flags: u32,
|
||||
eyes: u32,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Record`.
|
||||
@@ -48,16 +32,9 @@ struct Section {
|
||||
// Section x, y (from the level's bottom), z, and its arena page (-1: a
|
||||
// free id).
|
||||
key: vec4<i32>,
|
||||
// The first vertex of its solid quads, its solid quads (where its last
|
||||
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
|
||||
// its flags (`FADING`).
|
||||
// The first vertex of its solid quads, its solid quads, then two words
|
||||
// unused here.
|
||||
a: vec4<u32>,
|
||||
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
|
||||
// other bucket, +Y, -Z, -X.
|
||||
ends: array<u32, 6>,
|
||||
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
|
||||
// with (gpu_culling.rs `Record::facing`).
|
||||
keys: array<u32, 6>,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Update`: the record for id `id.x`.
|
||||
@@ -68,11 +45,9 @@ struct Update {
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
|
||||
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
|
||||
// per frustum plane its terms for those origins, n each; per plane and axis its
|
||||
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
|
||||
// each origin, n of them; then each page's region of `draws` (its first draw
|
||||
// and its size).
|
||||
// gpu_culling.rs `GpuCulling::prepare`: the origins' f32 bits along x, y and
|
||||
// z, each page's region of `draws` (its first draw and its size), then a bit
|
||||
// per id the CPU's walk chose.
|
||||
@group(0) @binding(2) var<storage, read> tables: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read> updates: array<Update>;
|
||||
// Sections kept, draws, each page's draws, then per block of ids and page its
|
||||
@@ -82,12 +57,8 @@ struct Update {
|
||||
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
|
||||
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
|
||||
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
|
||||
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
|
||||
// frame, else 0; then each draw's first quad and quads.
|
||||
// Per id its page + 1 when it is drawn this frame, else 0.
|
||||
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
|
||||
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
|
||||
// a bit each (32 a word, lowest first).
|
||||
@group(0) @binding(8) var<storage, read> reached_cells: array<u32>;
|
||||
|
||||
// gfx.rs `section_instance`'s w for a whole section: 1 / mesher::UV_STEPS.
|
||||
const STEP_BITS: u32 = 0x38000000u;
|
||||
@@ -97,22 +68,6 @@ const ROW: u32 = 65535u;
|
||||
const BLOCK: u32 = 64u;
|
||||
// Counts before the pages'.
|
||||
const STATS: u32 = 2u;
|
||||
// gpu_culling.rs `rounded_box` for a box past what its codes hold: no plane
|
||||
// rejects it.
|
||||
const UNBOUNDED: u32 = 0x80000000u;
|
||||
// In `params.flags`: cave culling's graph is in `params.reach`, and its search
|
||||
// reached the box's top layer (the sky band).
|
||||
const REACH: u32 = 1u;
|
||||
const SKY: u32 = 2u;
|
||||
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
|
||||
const FADING: u32 = 1u;
|
||||
// In `params.flags`: solid quads facing away from the eye are left out, its
|
||||
// keys in `tables` from `params.eyes`.
|
||||
const EYE: u32 = 4u;
|
||||
// Words per id in `runs`, and where its page starts in the first, its draws
|
||||
// (at most 4: gpu_culling.rs `RUNS`) below.
|
||||
const RUN_WORDS: u32 = 9u;
|
||||
const PAGE_SHIFT: u32 = 3u;
|
||||
|
||||
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
|
||||
fn block_count(block: u32, page: u32) -> u32 {
|
||||
@@ -125,97 +80,9 @@ fn region(page: u32) -> vec2<u32> {
|
||||
return vec2<u32>(tables[at], tables[at + 1u]);
|
||||
}
|
||||
|
||||
// streaming.rs `in_view_distance` around the camera's column. A gap at or past
|
||||
// the distance cannot pass, so testing it first keeps the squares in range.
|
||||
fn near(x: i32, z: i32) -> bool {
|
||||
let r = params.near;
|
||||
let gx = max(abs(x - params.cam_x) - 1, 0);
|
||||
let gz = max(abs(z - params.cam_z) - 1, 0);
|
||||
if gx >= r || gz >= r {
|
||||
return false;
|
||||
}
|
||||
return gx * gx + gz * gz < r * r;
|
||||
}
|
||||
|
||||
// occlusion.rs `Occlusion::contains` (`Graph::contains`): with no graph, or with
|
||||
// cave culling off, every section; in the searched box, whether the search
|
||||
// reached it; above a clamped box, over its columns and within the level, the
|
||||
// sky band (all of it or none); else none. `Reach::contains` on the CPU.
|
||||
fn reached(key: vec3<i32>) -> bool {
|
||||
if (params.flags & REACH) == 0u {
|
||||
return true;
|
||||
}
|
||||
let r = params.reach.z;
|
||||
let dx = key.x - params.reach.x;
|
||||
let dz = key.z - params.reach.y;
|
||||
let dy = key.y - params.reach.w;
|
||||
if dx < -r || dx > r || dz < -r || dz > r || dy < 0 {
|
||||
return false;
|
||||
}
|
||||
if dy >= params.reach_height {
|
||||
return (params.flags & SKY) != 0u && key.y >= 0 && key.y <= params.reach_sections;
|
||||
}
|
||||
let i = u32(((dz + r) * (2 * r + 1) + dx + r) * params.reach_height + dy);
|
||||
return ((reached_cells[i >> 5u] >> (i & 31u)) & 1u) != 0u;
|
||||
}
|
||||
|
||||
fn term(at: u32) -> i32 {
|
||||
return bitcast<i32>(tables[at]);
|
||||
}
|
||||
|
||||
// gfx.rs `Frustum::intersects` for the box `b` of the section in cell `c` of the
|
||||
// tables, in integers (gpu_culling.rs `tables`): it keeps every box the CPU keeps.
|
||||
fn in_frustum(c: vec3<u32>, b: u32) -> bool {
|
||||
if (b & UNBOUNDED) != 0u {
|
||||
return true;
|
||||
}
|
||||
let nx = params.cells.x;
|
||||
let ny = params.cells.y;
|
||||
let n = nx + ny + params.cells.z;
|
||||
for (var j = 0u; j < 6u; j++) {
|
||||
// The box's codes at the corner the plane looks at.
|
||||
let s = params.signs >> (3u * j);
|
||||
let kx = (b >> select(0u, 15u, (s & 1u) != 0u)) & 31u;
|
||||
let ky = (b >> select(5u, 20u, (s & 2u) != 0u)) & 31u;
|
||||
let kz = (b >> select(10u, 25u, (s & 4u) != 0u)) & 31u;
|
||||
let origin_terms = n + j * n;
|
||||
let code_terms = 7u * n + 96u * j;
|
||||
let sum = params.w[j / 4u][j % 4u] + term(origin_terms + c.x)
|
||||
+ term(origin_terms + nx + c.y) + term(origin_terms + nx + ny + c.z)
|
||||
+ term(code_terms + kx) + term(code_terms + 32u + ky) + term(code_terms + 64u + kz);
|
||||
if sum < 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
|
||||
// solid quads for the last.
|
||||
fn bucket_end(id: u32, b: u32) -> u32 {
|
||||
if b == 6u {
|
||||
return sections[id].a.y;
|
||||
}
|
||||
return sections[id].ends[b];
|
||||
}
|
||||
|
||||
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
|
||||
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
|
||||
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
|
||||
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
|
||||
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
|
||||
if b == 2u {
|
||||
return true;
|
||||
}
|
||||
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
|
||||
switch b {
|
||||
case 0u: { return eye.x > t; }
|
||||
case 1u: { return eye.z > t; }
|
||||
case 3u: { return eye.y > t; }
|
||||
case 4u: { return eye.z < t; }
|
||||
case 5u: { return eye.x < t; }
|
||||
default: { return eye.y < t; }
|
||||
}
|
||||
// The CPU's walk chose it (gpu_culling.rs `GpuCulling::choose`).
|
||||
fn chosen(id: u32) -> bool {
|
||||
return ((tables[params.mask + id / 32u] >> (id % 32u)) & 1u) != 0u;
|
||||
}
|
||||
|
||||
// One invocation per changed record; the CPU lists each id once.
|
||||
@@ -230,70 +97,26 @@ fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
@compute @workgroup_size(64)
|
||||
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let id = gid.y * (ROW * BLOCK) + gid.x;
|
||||
// Past the table's end the records are stale: the CPU never writes them free.
|
||||
if id >= params.ids {
|
||||
return;
|
||||
}
|
||||
let at = id * RUN_WORDS;
|
||||
runs[at] = 0u;
|
||||
runs[id] = 0u;
|
||||
let s = sections[id];
|
||||
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|
||||
|| !reached(s.key.xyz) {
|
||||
if s.key.w < 0 || s.a.y == 0u || !chosen(id) {
|
||||
return;
|
||||
}
|
||||
let page = u32(s.key.w);
|
||||
// The tables hold every column within the distance; a key before their
|
||||
// first wraps past their end.
|
||||
let c = vec3<u32>(s.key.xyz - params.first.xyz);
|
||||
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
|
||||
if page >= params.pages || any(c >= params.cells.xyz) {
|
||||
return;
|
||||
}
|
||||
// Kept, whether or not a quad of it can face the eye.
|
||||
atomicAdd(&counts[0], 1u);
|
||||
let nx = params.cells.x;
|
||||
let ny = params.cells.y;
|
||||
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
|
||||
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
|
||||
// empty bucket breaking none, a draw each; without an eye, its solid quads.
|
||||
var n = 0u;
|
||||
if (params.flags & EYE) == 0u {
|
||||
runs[at + 1u] = 0u;
|
||||
runs[at + 2u] = s.a.y;
|
||||
n = 1u;
|
||||
} else {
|
||||
let e = params.eyes;
|
||||
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
|
||||
var start = 0u;
|
||||
var first = 0u;
|
||||
var open = false;
|
||||
for (var b = 0u; b < 7u; b++) {
|
||||
let end = bucket_end(id, b);
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let shown = seen(id, b, eye);
|
||||
if shown && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !shown && open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
}
|
||||
}
|
||||
if n == 0u {
|
||||
return;
|
||||
}
|
||||
runs[at] = (page << PAGE_SHIFT) | n;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
|
||||
runs[id] = page + 1u;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
|
||||
}
|
||||
|
||||
var<workgroup> partial: array<u32, 256>;
|
||||
@@ -332,54 +155,49 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
|
||||
start += count;
|
||||
}
|
||||
if lane == 255u {
|
||||
// A region has room for every draw of its page's records; were one
|
||||
// short, its draws past it go.
|
||||
// A region has room for every record of its page; were one short,
|
||||
// its draws past it go.
|
||||
let total = min(partial[255], region(page).y);
|
||||
atomicStore(&counts[STATS + page], total);
|
||||
atomicAdd(&counts[0], partial[255]);
|
||||
atomicAdd(&counts[1], total);
|
||||
}
|
||||
}
|
||||
|
||||
var<workgroup> held: array<u32, 64>;
|
||||
|
||||
// One workgroup per block of ids: each section's draws go after those of the
|
||||
// block's earlier sections in its page, in its runs' order.
|
||||
// One workgroup per block of ids: each section's draw goes after those of the
|
||||
// block's earlier sections in its page.
|
||||
@compute @workgroup_size(64)
|
||||
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
|
||||
let block = group.y * ROW + group.x;
|
||||
let id = block * BLOCK + lane;
|
||||
var mine = 0u;
|
||||
if id < params.ids {
|
||||
mine = runs[id * RUN_WORDS];
|
||||
mine = runs[id];
|
||||
}
|
||||
held[lane] = mine;
|
||||
workgroupBarrier();
|
||||
// Its draws below PAGE_SHIFT, its page above.
|
||||
let n = mine & 7u;
|
||||
if n == 0u {
|
||||
if mine == 0u {
|
||||
return;
|
||||
}
|
||||
let page = mine >> PAGE_SHIFT;
|
||||
let page = mine - 1u;
|
||||
var slot = atomicLoad(&counts[block_count(block, page)]);
|
||||
for (var other = 0u; other < lane; other++) {
|
||||
// A section not drawn holds 0: no draws.
|
||||
if (held[other] >> PAGE_SHIFT) == page {
|
||||
slot += held[other] & 7u;
|
||||
if held[other] == mine {
|
||||
slot += 1u;
|
||||
}
|
||||
}
|
||||
let r = region(page);
|
||||
let at = id * RUN_WORDS;
|
||||
let base = sections[id].a.x;
|
||||
for (var k = 0u; k < n; k++) {
|
||||
if slot + k >= r.y {
|
||||
return;
|
||||
}
|
||||
// A run of its solid quads, from its first vertex, with its origin.
|
||||
let d = (r.x + slot + k) * 5u;
|
||||
draws[d] = runs[at + 2u + 2u * k] * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
|
||||
draws[d + 4u] = id;
|
||||
if slot >= r.y {
|
||||
return;
|
||||
}
|
||||
// All of its solid quads, from its first vertex, with its origin.
|
||||
let s = sections[id];
|
||||
let d = (r.x + slot) * 5u;
|
||||
draws[d] = s.a.y * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = s.a.x;
|
||||
draws[d + 4u] = id;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -42,32 +40,18 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
|
||||
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
NAMES
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
|
||||
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
|
||||
pub fn offscreen_output() -> Option<Output> {
|
||||
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn named(name: Option<&str>) -> Option<Output> {
|
||||
NAMES
|
||||
.into_iter()
|
||||
.find(|(_, n)| name == Some(*n))
|
||||
.map(|(output, _)| output)
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
@@ -168,9 +152,6 @@ pub struct Hdr {
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
pub look: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -316,12 +297,11 @@ impl Hdr {
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
look: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame every pass draws into this frame, sized like the window.
|
||||
/// The 8-bit 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| {
|
||||
@@ -557,7 +537,7 @@ impl Hdr {
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world && self.look)),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
@@ -655,7 +635,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, named, pick};
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
@@ -705,14 +685,6 @@ mod tests {
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_offers_only_the_named_output() {
|
||||
assert_eq!(named(Some("pq")), Some(Output::Pq));
|
||||
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
|
||||
assert_eq!(named(Some("hdr10")), None);
|
||||
assert_eq!(named(None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
//! 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,9 +63,6 @@ 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.
|
||||
@@ -163,18 +160,6 @@ 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))
|
||||
@@ -207,10 +192,6 @@ 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) => {
|
||||
@@ -231,12 +212,6 @@ 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 {
|
||||
@@ -246,7 +221,6 @@ impl Renderer {
|
||||
dying: None,
|
||||
alone: false,
|
||||
previous,
|
||||
cull_slot,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -254,7 +228,6 @@ impl Renderer {
|
||||
if let Some((previous, _)) = previous {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
self.lod_cull_free.extend(old_slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,19 +240,12 @@ 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) {
|
||||
@@ -294,8 +260,6 @@ 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());
|
||||
}
|
||||
|
||||
@@ -473,15 +437,6 @@ 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)>) {
|
||||
|
||||
@@ -41,28 +41,17 @@ struct HorizonMaterialOut {
|
||||
@location(10) @interpolate(flat) emission: f32,
|
||||
// terrain.wgsl's `emission`, for HDR output's mark.
|
||||
@location(11) @interpolate(flat) hdr_code: u32,
|
||||
// The point on the flat face (`position` without `[displacement]`'s relief), how much of
|
||||
// that relief it has (`MaterialInput.displaced`) and the face's normal (0 when the
|
||||
// fragment finds it).
|
||||
@location(12) base: vec3<f32>,
|
||||
@location(13) displaced: f32,
|
||||
@location(14) @interpolate(flat) normal: vec3<f32>,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
}
|
||||
// `light`: sky, block, 16 per level.
|
||||
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<f32>) -> HorizonMaterialOut {
|
||||
var out: HorizonMaterialOut;
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = vec4(color.rgb, 1.0);
|
||||
let block = max(light.y, horizon_terrain_dynamic_block_light(pos));
|
||||
let sky = light.x;
|
||||
let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos));
|
||||
let sky = f32(light.x);
|
||||
// 16 per level, 15 levels.
|
||||
out.light = min(vec2(block, sky) / 240.0, vec2(1.0));
|
||||
out.lightmap = horizon_terrain_sample_lightmap(sky / 16.0, block / 16.0).rgb;
|
||||
@@ -84,9 +73,6 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
|
||||
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
|
||||
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
|
||||
out.base = pos;
|
||||
out.displaced = 0.0;
|
||||
out.normal = vec3(0.0);
|
||||
return out;
|
||||
}
|
||||
@vertex fn horizon_material_vs(
|
||||
@@ -156,17 +142,15 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
// `[displacement]`'s grid hands down its face's normal: its relief is the pack's to shade.
|
||||
let normal = select(horizon_material_normal(in.position), in.normal,
|
||||
dot(in.normal, in.normal) > 0.5);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.base, in.uv, normal);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
|
||||
horizon_terrain_native_colored_light(in.position))).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -192,13 +176,6 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
return 1.0;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
// `[displacement]`'s grid draws these pixels.
|
||||
if length(in.base) < horizon_displace_distance - 0.05 && horizon_displaced_layer(in.layer) {
|
||||
discard;
|
||||
}
|
||||
return horizon_material_solid(in);
|
||||
}
|
||||
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
@@ -219,37 +196,6 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
}
|
||||
";
|
||||
|
||||
/// `[displacement]`'s grid (gfx/displace.rs): one instance a quad the pick chose, its corners
|
||||
/// read from the copy of the arena, each of its cells two triangles moved along the face's
|
||||
/// normal by the pack's `displacement`, measured from the texture's mean height so that the
|
||||
/// face's edges, pinned there, meet their neighbours whatever their relief.
|
||||
const DISPLACED_ENTRIES: &str = "
|
||||
@vertex fn horizon_material_vs_displaced(@builtin(vertex_index) v: u32,
|
||||
@builtin(instance_index) instance: u32) -> HorizonMaterialOut {
|
||||
let p = horizon_displace_point(v, instance);
|
||||
var out = horizon_material_out(p.position, p.uv, p.layer, p.color, p.light);
|
||||
out.base = p.base;
|
||||
out.displaced = p.near;
|
||||
out.normal = p.normal;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_displaced(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
if length(in.base) >= horizon_displace_distance + 0.05 {
|
||||
discard;
|
||||
}
|
||||
return horizon_material_solid(in);
|
||||
}
|
||||
";
|
||||
|
||||
/// What the material's displaced pipeline is built against: `[displacement]`'s values and
|
||||
/// its group 1.
|
||||
pub struct DisplacedShape<'a> {
|
||||
pub distance: f32,
|
||||
pub cells: u32,
|
||||
pub steps: bool,
|
||||
pub layout: &'a wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
/// What terrain pipelines are built against: the renderer's layouts and constants.
|
||||
pub struct TerrainShape<'a> {
|
||||
/// The terrain's group 0.
|
||||
@@ -281,8 +227,6 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// `[displacement]`'s grid of the solid terrain near the camera (gfx/displace.rs).
|
||||
pub displaced: Option<wgpu::RenderPipeline>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
@@ -346,10 +290,9 @@ fn shader_source(
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
@@ -357,13 +300,7 @@ fn shader_source(
|
||||
DISPLACE_VANILLA
|
||||
},
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials),
|
||||
super::displace::RELIEF,
|
||||
if displaced {
|
||||
super::displace::GRID.to_owned() + DISPLACED_ENTRIES
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,9 +310,8 @@ fn module(
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
|
||||
let text = shader_source(&stage.source, tags, materials, vertex);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -390,16 +326,9 @@ impl NativeMaterial {
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
vertex: bool,
|
||||
displaced: Option<&DisplacedShape<'_>>,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(
|
||||
stage,
|
||||
shape.sprite_tags,
|
||||
shape.sprite_materials,
|
||||
vertex,
|
||||
displaced.is_some(),
|
||||
)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
@@ -407,14 +336,6 @@ impl NativeMaterial {
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
constants.push((
|
||||
"horizon_displace_distance",
|
||||
displaced.map_or(0.0, |d| f64::from(d.distance)),
|
||||
));
|
||||
if let Some(d) = displaced {
|
||||
constants.push(("horizon_displace_cells", f64::from(d.cells)));
|
||||
constants.push(("horizon_displace_steps", f64::from(u8::from(d.steps))));
|
||||
}
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -490,7 +411,7 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: if fragment != "horizon_material_fs_translucent" && writes_surface {
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
@@ -532,16 +453,6 @@ impl NativeMaterial {
|
||||
),
|
||||
)
|
||||
});
|
||||
// The grid's corners come from its group 1, not from vertex buffers.
|
||||
let displaced = displaced.map(|d| {
|
||||
pipeline(
|
||||
&layout(Some(d.layout)),
|
||||
"horizon_material_vs_displaced",
|
||||
&[],
|
||||
"horizon_material_fs_displaced",
|
||||
None,
|
||||
)
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
}
|
||||
@@ -549,7 +460,6 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
displaced,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
@@ -605,7 +515,7 @@ mod tests {
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false, false).unwrap();
|
||||
let module = module(&stage, &tags, &materials, false).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -614,13 +524,7 @@ mod tests {
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(
|
||||
&stage.source,
|
||||
&tags,
|
||||
&materials,
|
||||
false,
|
||||
false
|
||||
))
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
|
||||
)
|
||||
});
|
||||
let entries: Vec<&str> = module
|
||||
@@ -642,39 +546,4 @@ mod tests {
|
||||
[("gain", 1.5)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_displaced_grid_validates_with_a_pack() {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[material]\nfile = \"material.wgsl\"\n[displacement]\n";
|
||||
let material = "fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * (1.0 - 0.5 * input.displaced));\n}\n\
|
||||
fn displacement(input: DisplacementInput) -> f32 {\n \
|
||||
return (1.0 - input.height) * 0.125 * f32(input.material + 1u);\n}\n";
|
||||
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report.stages.remove(0);
|
||||
let materials = [(5, 1000)];
|
||||
let module = module(&stage, &[], &materials, false, true).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| {
|
||||
let text = shader_source(&stage.source, &[], &materials, false, true);
|
||||
panic!("{}", e.emit_to_string(&text))
|
||||
});
|
||||
for entry in [
|
||||
"horizon_material_vs_displaced",
|
||||
"horizon_material_fs_displaced",
|
||||
] {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|e| e.name == entry),
|
||||
"{entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::displace::Displacement;
|
||||
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
|
||||
use super::native_material::{NativeMaterial, TerrainShape};
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_shadow::{NativeShadow, ShadowGrid};
|
||||
use super::native_shadow::NativeShadow;
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
@@ -22,8 +21,6 @@ pub struct NativePack {
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[displacement]`'s grid, while it is on and the resource packs have height maps.
|
||||
pub displacement: Option<Displacement>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// `[[lights]]`, for the colour of held and dropped items' light.
|
||||
@@ -32,52 +29,6 @@ pub struct NativePack {
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
|
||||
/// Whether the option `id` (a bool, or a number above 0) is on, or there is none to ask.
|
||||
fn option_on(constants: &[(String, f64)], id: Option<&String>) -> bool {
|
||||
id.is_none_or(|id| {
|
||||
constants
|
||||
.iter()
|
||||
.find(|(k, _)| k == id)
|
||||
.is_none_or(|(_, v)| *v != 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `[displacement]`'s cells: the value of its `cells_option`, if any.
|
||||
fn chosen_cells(
|
||||
d: &horizon_shader_api::manifest::Displacement,
|
||||
options: &[horizon_shader_api::manifest::PackOption],
|
||||
constants: &[(String, f64)],
|
||||
) -> u32 {
|
||||
let chosen = d.cells_option.as_ref().and_then(|id| {
|
||||
let option = options.iter().find(|o| o.id == *id)?;
|
||||
let horizon_shader_api::manifest::OptionKind::Choice { values, .. } = &option.kind else {
|
||||
return None;
|
||||
};
|
||||
let (_, index) = constants.iter().find(|(k, _)| k == id)?;
|
||||
values.get(*index as usize)?.parse().ok()
|
||||
});
|
||||
chosen.unwrap_or(d.cells)
|
||||
}
|
||||
|
||||
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
|
||||
/// resolution and distance its `resolution_option` and `distance_option` pick.
|
||||
fn chosen_shadow(
|
||||
s: &horizon_shader_api::manifest::Shadow,
|
||||
options: &[horizon_shader_api::manifest::PackOption],
|
||||
constants: &[(String, f64)],
|
||||
) -> Option<horizon_shader_api::manifest::Shadow> {
|
||||
let number = |id: Option<&String>| {
|
||||
let option = options.iter().find(|o| Some(&o.id) == id)?;
|
||||
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
|
||||
option.kind.number(*value)
|
||||
};
|
||||
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
|
||||
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
|
||||
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
|
||||
..s.clone()
|
||||
})
|
||||
}
|
||||
|
||||
impl NativePack {
|
||||
/// The pack of the checked stages `report` with the option values `constants`, drawing
|
||||
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
|
||||
@@ -93,56 +44,16 @@ impl NativePack {
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
// `[shadow]`, unless its option is off, at the size its options pick.
|
||||
let chosen = report
|
||||
let settings = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
// `[displacement]`, unless its option is off; nothing to displace without heights.
|
||||
let displacement = report
|
||||
.manifest
|
||||
.displacement
|
||||
.as_ref()
|
||||
.filter(|d| option_on(constants, d.option.as_ref()))
|
||||
.filter(|_| terrain.pbr.is_some())
|
||||
.map(|d| {
|
||||
// No more cells than the height maps have texels.
|
||||
let texels = terrain.pbr.map_or(16, |p| p[0].width());
|
||||
let cells = chosen_cells(d, &report.manifest.options, constants)
|
||||
.min(texels)
|
||||
.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],
|
||||
report.shadow.is_some(),
|
||||
)
|
||||
});
|
||||
let shadow = chosen
|
||||
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,
|
||||
@@ -150,7 +61,6 @@ impl NativePack {
|
||||
&s.solid_materials,
|
||||
vertex,
|
||||
constants,
|
||||
grid,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
@@ -161,7 +71,12 @@ impl NativePack {
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.is_some_and(|s| s.reads("reflection"))
|
||||
&& option_on(constants, option);
|
||||
&& option.is_none_or(|id| {
|
||||
constants
|
||||
.iter()
|
||||
.find(|(k, _)| k == id)
|
||||
.is_none_or(|(_, v)| *v != 0.0)
|
||||
});
|
||||
let fog = report.material_fog;
|
||||
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
|
||||
report.sky.as_ref().and_then(key).unwrap_or(true)
|
||||
@@ -204,22 +119,7 @@ impl NativePack {
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| {
|
||||
let displaced = displacement.as_ref().map(|d| DisplacedShape {
|
||||
distance: d.distance,
|
||||
cells: d.view.cells,
|
||||
steps: d.view.steps,
|
||||
layout: &d.draw_layout,
|
||||
});
|
||||
NativeMaterial::new(
|
||||
device,
|
||||
terrain,
|
||||
&frame,
|
||||
stage,
|
||||
constants,
|
||||
fog,
|
||||
has_vertex,
|
||||
displaced.as_ref(),
|
||||
)
|
||||
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
|
||||
})
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
@@ -248,7 +148,6 @@ impl NativePack {
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
displacement,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
@@ -262,61 +161,3 @@ impl NativePack {
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_map_follows_its_options() {
|
||||
let options = [
|
||||
option("shadows", OptionKind::Bool { default: true }),
|
||||
option(
|
||||
"quality",
|
||||
OptionKind::Choice {
|
||||
values: vec!["1024".into(), "4096".into()],
|
||||
default: 0,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"reach",
|
||||
OptionKind::Int {
|
||||
default: 96,
|
||||
min: 32,
|
||||
max: 512,
|
||||
},
|
||||
),
|
||||
];
|
||||
let shadow = Shadow {
|
||||
option: Some("shadows".into()),
|
||||
resolution_option: Some("quality".into()),
|
||||
distance_option: Some("reach".into()),
|
||||
..Shadow::default()
|
||||
};
|
||||
let values = |on: f64, quality: f64, reach: f64| {
|
||||
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
|
||||
};
|
||||
let size = |values: &[(String, f64)]| {
|
||||
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
|
||||
};
|
||||
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
|
||||
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
|
||||
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
|
||||
// Without the options' keys: the manifest's own numbers, always drawn.
|
||||
assert_eq!(
|
||||
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
|
||||
.map(|s| (s.resolution, s.distance)),
|
||||
Some((2048, 128.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ pub struct NativeFrame {
|
||||
pub opaque_color: wgpu::Texture,
|
||||
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
|
||||
/// without them.
|
||||
pub(super) pbr: [wgpu::TextureView; 2],
|
||||
pbr: [wgpu::TextureView; 2],
|
||||
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
|
||||
/// `blank`; and its sampler (binding 2).
|
||||
reflection: wgpu::TextureView,
|
||||
@@ -366,7 +366,6 @@ impl NativeFrame {
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,8 +623,7 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
|
||||
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
// `[displacement]`'s grid moves its corners by the heights (gfx/displace.rs).
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
|
||||
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API 371).
|
||||
/// frame to frame (API @API:world_drawn_motion@).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
/// The block textures the terrain samples, for drawing blocks with the player's own
|
||||
/// textures (`native_block_sprites` says where a block's are). API 380.
|
||||
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
|
||||
pub block_sprites: BlockSpriteArrays<'a>,
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
}
|
||||
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API 364.
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
#[inline(never)]
|
||||
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
|
||||
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
|
||||
|
||||
@@ -23,9 +23,6 @@ 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`).
|
||||
@@ -40,7 +37,6 @@ 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(
|
||||
@@ -62,10 +58,6 @@ 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; }
|
||||
@@ -113,45 +105,6 @@ 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;
|
||||
|
||||
@@ -373,8 +326,6 @@ 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.
|
||||
@@ -467,36 +418,17 @@ 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`. 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}"),
|
||||
/// `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),
|
||||
super::native_material::DISPLACE_PACK,
|
||||
),
|
||||
(None, None) => (
|
||||
None => (
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
@@ -520,13 +452,11 @@ 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)))?;
|
||||
@@ -535,59 +465,31 @@ 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: &layout_entries,
|
||||
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,
|
||||
}],
|
||||
});
|
||||
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: &group_entries,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frame.buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
|
||||
@@ -617,11 +519,10 @@ impl NativeShadow {
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
let pipeline_with = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
fragment: &str| {
|
||||
let translucent = fragment == "horizon_shadow_fs_translucent";
|
||||
let pipeline = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
translucent: bool| {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -655,7 +556,11 @@ impl NativeShadow {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
entry_point: Some(if translucent {
|
||||
"horizon_shadow_fs_translucent"
|
||||
} else {
|
||||
"horizon_shadow_fs"
|
||||
}),
|
||||
compilation_options: options,
|
||||
targets: if translucent { &color_target } else { &[] },
|
||||
}),
|
||||
@@ -663,17 +568,6 @@ 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,
|
||||
@@ -701,15 +595,6 @@ 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}"));
|
||||
}
|
||||
@@ -720,7 +605,6 @@ impl NativeShadow {
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
displaced,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -732,7 +616,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, None);
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
@@ -743,47 +627,6 @@ 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)];
|
||||
@@ -806,7 +649,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let shadow = camera(&Settings::default());
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
@@ -822,7 +670,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings::default());
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
//! for the next one. A section meshed in between propagates into the graph the
|
||||
//! frame has (vanilla's partial update), so what it opens shows at once; what
|
||||
//! it closes goes at the next full search.
|
||||
//!
|
||||
//! GPU culling (gfx/gpu_culling.rs) answers as `contains` does from a copy of
|
||||
//! the graph (`Reach`): the cells it reached, kept as bits too, sent again
|
||||
//! whenever the generation moves, which it does whenever an answer may change.
|
||||
use super::SectionKey;
|
||||
use crate::fast_hash::FxHashSet;
|
||||
use crate::mesher::ALL_VISIBLE;
|
||||
@@ -133,10 +129,6 @@ struct Graph {
|
||||
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
|
||||
/// the camera's.
|
||||
entered: Vec<u8>,
|
||||
/// `REACHED` of each cell again, a bit each at `index` (32 a word, lowest
|
||||
/// bit first): the GPU's copy (`Occlusion::reach`), written with it
|
||||
/// (`set_reached`).
|
||||
bits: Vec<u32>,
|
||||
/// Whether any section in the searched top layer was reached. Sections
|
||||
/// above a clamped box use this conservative sky-band answer.
|
||||
sky_reached: bool,
|
||||
@@ -185,13 +177,6 @@ impl Graph {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Cell `i` is reached, having travelled `directions` (`REACHED` among
|
||||
/// them): the one writer of `REACHED`, so `bits` say what `directions` do.
|
||||
fn set_reached(&mut self, i: usize, directions: u8) {
|
||||
debug_assert!(directions & REACHED != 0);
|
||||
self.directions[i] = directions;
|
||||
self.bits[i / 32] |= 1 << (i % 32);
|
||||
}
|
||||
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
|
||||
fn search(
|
||||
&mut self,
|
||||
@@ -235,8 +220,6 @@ impl Graph {
|
||||
let size = side * side * self.height as usize;
|
||||
self.directions.clear();
|
||||
self.directions.resize(size, 0);
|
||||
self.bits.clear();
|
||||
self.bits.resize(size.div_ceil(32), 0);
|
||||
self.entered.clear();
|
||||
self.entered.resize(size, 0);
|
||||
self.sky_reached = false;
|
||||
@@ -260,7 +243,7 @@ impl Graph {
|
||||
queue.clear();
|
||||
if (0..columns.sections).contains(&from.1) {
|
||||
let start = self.index(from).expect("camera inside the searched box");
|
||||
self.set_reached(start, REACHED);
|
||||
self.directions[start] = REACHED;
|
||||
queue.push_back(start as u32);
|
||||
} else {
|
||||
self.seed_outside(camera, queue);
|
||||
@@ -311,7 +294,7 @@ impl Graph {
|
||||
let i = self
|
||||
.index((self.from.0 + dx, y, self.from.2 + dz))
|
||||
.expect("seed inside the searched box");
|
||||
self.set_reached(i, directions);
|
||||
self.directions[i] = directions;
|
||||
// Entered through the face opposite the travel (`sourceDirection`).
|
||||
self.entered[i] = 1 << (travel ^ 1);
|
||||
queue.push_back(i as u32);
|
||||
@@ -321,15 +304,14 @@ impl Graph {
|
||||
/// every face their entry faces connect to, away from the camera. A step
|
||||
/// into a section already reached adds the face it enters through.
|
||||
/// `full`: the search from `camera` (`rays` holds its lines), not a
|
||||
/// partial update. Whether it reached a cell it had not.
|
||||
/// partial update.
|
||||
fn propagate(
|
||||
&mut self,
|
||||
queue: &mut VecDeque<u32>,
|
||||
camera: DVec3,
|
||||
full: bool,
|
||||
vis: impl Fn(usize, SectionKey) -> u64,
|
||||
) -> bool {
|
||||
let mut grew = false;
|
||||
) {
|
||||
let eye = (
|
||||
(camera.x.floor() as i32) >> 4,
|
||||
(camera.y.floor() as i32 - self.min_y) >> 4,
|
||||
@@ -383,16 +365,14 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
if self.directions[n] & REACHED == 0 {
|
||||
self.set_reached(n, directions | 1 << d);
|
||||
self.directions[n] = directions | 1 << d;
|
||||
self.entered[n] = 1 << (d ^ 1);
|
||||
queue.push_back(n as u32);
|
||||
grew = true;
|
||||
} else {
|
||||
self.entered[n] |= 1 << (d ^ 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
grew
|
||||
}
|
||||
/// Vanilla's distant step test: from the corner of `key` that the step `d`
|
||||
/// picks, walk towards the camera in section diagonals while farther than
|
||||
@@ -492,42 +472,6 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
|
||||
/// The graph `Occlusion::contains` reads, for GPU culling's copy
|
||||
/// (gfx/gpu_culling.rs): its searched box (`Graph::index`), the level's height
|
||||
/// in sections, whether the search reached the box's top layer (the sky band
|
||||
/// above a clamped box), and its cells' `REACHED` as bits.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Reach<'a> {
|
||||
/// `Occlusion::generation` when it was taken.
|
||||
pub generation: u64,
|
||||
pub from_x: i32,
|
||||
pub from_z: i32,
|
||||
pub radius: i32,
|
||||
pub y0: i32,
|
||||
pub height: i32,
|
||||
pub sections: i32,
|
||||
pub sky_reached: bool,
|
||||
pub bits: &'a [u32],
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Reach<'_> {
|
||||
/// gpu_culling.wgsl's `reached`, statement for statement: what
|
||||
/// `Graph::contains` answers.
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
let r = self.radius;
|
||||
let (dx, dz, dy) = (key.0 - self.from_x, key.2 - self.from_z, key.1 - self.y0);
|
||||
if dx < -r || dx > r || dz < -r || dz > r || dy < 0 {
|
||||
return false;
|
||||
}
|
||||
if dy >= self.height {
|
||||
return self.sky_reached && (0..=self.sections).contains(&key.1);
|
||||
}
|
||||
let i = (((dz + r) * (2 * r + 1) + dx + r) * self.height + dy) as usize;
|
||||
(self.bits[i / 32] >> (i % 32)) & 1 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.floor` for coordinates inside the world, without a libm call (the
|
||||
/// x86-64 baseline has no rounding instruction).
|
||||
fn floor(x: f64) -> i32 {
|
||||
@@ -574,14 +518,6 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new value for `Occlusion::generation`, unique in the process: a copy made
|
||||
/// from an older `Occlusion` (another level's) never matches. As gfx.rs
|
||||
/// `next_block_entity_revision`.
|
||||
fn next_generation() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub struct Occlusion {
|
||||
/// The render thread's copy, for partial updates (and searches when the
|
||||
/// worker is gone).
|
||||
@@ -609,8 +545,6 @@ pub struct Occlusion {
|
||||
changed_late: Vec<SectionKey>,
|
||||
vis: Vec<u64>,
|
||||
queue: VecDeque<u32>,
|
||||
/// Changes whenever `contains` may answer differently (`generation`).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Occlusion {
|
||||
@@ -630,7 +564,6 @@ impl Occlusion {
|
||||
changed_late: Vec::new(),
|
||||
vis: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
generation: next_generation(),
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, key: SectionKey, vis: u64) {
|
||||
@@ -675,11 +608,9 @@ impl Occlusion {
|
||||
self.edits.clear();
|
||||
self.edits.push(Edit::Clear);
|
||||
self.dirty = true;
|
||||
// Another world: the old graph says nothing about it. Without one, every
|
||||
// section was visible already, and stays so.
|
||||
// Another world: the old graph says nothing about it.
|
||||
if let Some(graph) = self.graph.take() {
|
||||
self.spare = graph;
|
||||
self.generation = next_generation();
|
||||
}
|
||||
self.searched = None;
|
||||
self.discard = self.running.is_some();
|
||||
@@ -692,38 +623,6 @@ impl Occlusion {
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
self.graph.as_ref().is_none_or(|g| g.contains(key))
|
||||
}
|
||||
/// Changes whenever `contains` may answer differently: a search landed, a
|
||||
/// partial update reached more sections, or the graph went.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
/// The graph `contains` reads; `None` while every section counts as
|
||||
/// visible.
|
||||
pub fn reach(&self) -> Option<Reach<'_>> {
|
||||
self.graph.as_ref().map(|g| Reach {
|
||||
generation: self.generation,
|
||||
from_x: g.from.0,
|
||||
from_z: g.from.2,
|
||||
radius: g.radius,
|
||||
y0: g.y0,
|
||||
height: g.height,
|
||||
sections: g.sections,
|
||||
sky_reached: g.sky_reached,
|
||||
bits: &g.bits,
|
||||
})
|
||||
}
|
||||
/// The bits `reach` exports are the cells' `REACHED`, cell for cell.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn reach_is_exact(&self) -> bool {
|
||||
self.graph.as_ref().is_none_or(|g| {
|
||||
g.bits.len() == g.directions.len().div_ceil(32)
|
||||
&& g.directions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &d)| (d & REACHED != 0) == ((g.bits[i / 32] >> (i % 32)) & 1 != 0))
|
||||
})
|
||||
}
|
||||
/// Take a finished search, propagate changed sections into the graph,
|
||||
/// and start a search when sections changed or the camera moved to
|
||||
/// another section. Never waits for the worker.
|
||||
@@ -743,7 +642,6 @@ impl Occlusion {
|
||||
&& let Some(graph) = self.graph.take()
|
||||
{
|
||||
self.spare = graph;
|
||||
self.generation = next_generation();
|
||||
}
|
||||
}
|
||||
fn start(&mut self, camera: DVec3, radius: i32) {
|
||||
@@ -815,7 +713,6 @@ impl Occlusion {
|
||||
if let Some(old) = self.graph.replace(graph) {
|
||||
self.spare = old;
|
||||
}
|
||||
self.generation = next_generation();
|
||||
}
|
||||
/// Vanilla's `runPartialUpdate`: what changed sections now connect to.
|
||||
fn propagate_changes(&mut self, camera: DVec3) {
|
||||
@@ -832,9 +729,7 @@ impl Occlusion {
|
||||
}
|
||||
}
|
||||
let columns = &self.columns;
|
||||
if graph.propagate(&mut self.queue, camera, false, |_, key| columns.get(key)) {
|
||||
self.generation = next_generation();
|
||||
}
|
||||
graph.propagate(&mut self.queue, camera, false, |_, key| columns.get(key));
|
||||
}
|
||||
/// Wait for the running search: tests, and A/B timing of the old
|
||||
/// blocking search.
|
||||
@@ -1510,197 +1405,4 @@ mod tests {
|
||||
"enclosed cave does not see the sky"
|
||||
);
|
||||
}
|
||||
|
||||
/// `contains` over the columns `radius` + 3 around `from`, from two
|
||||
/// sections below the level to two above it, checked against the graph
|
||||
/// `reach` exports: `None` exactly without a graph, the same answer for
|
||||
/// every key, nothing past its box, its bits its cells' `REACHED`, and its
|
||||
/// sky band reached exactly when a bit of its top layer is.
|
||||
fn answers(o: &Occlusion, from: SectionKey, radius: i32) -> Vec<bool> {
|
||||
let reach = o.reach();
|
||||
assert_eq!(reach.is_some(), o.graph.is_some(), "a graph exported");
|
||||
assert!(o.reach_is_exact(), "the bits are the cells'");
|
||||
if let Some(g) = &o.graph {
|
||||
let height = g.height as usize;
|
||||
let mut top = (height - 1..g.directions.len()).step_by(height);
|
||||
let sky = top.any(|i| (g.bits[i / 32] >> (i % 32)) & 1 != 0);
|
||||
assert_eq!(g.sky_reached, sky, "the sky band");
|
||||
}
|
||||
let past = |key: SectionKey| {
|
||||
o.graph.as_ref().is_some_and(|g| {
|
||||
(key.0 - g.from.0).abs() > g.radius
|
||||
|| (key.2 - g.from.2).abs() > g.radius
|
||||
|| !(g.y0..=g.sections).contains(&key.1)
|
||||
})
|
||||
};
|
||||
let mut answers = Vec::new();
|
||||
for x in from.0 - radius - 3..=from.0 + radius + 3 {
|
||||
for z in from.2 - radius - 3..=from.2 + radius + 3 {
|
||||
for y in -2..=o.columns.sections + 1 {
|
||||
let key = (x, y, z);
|
||||
let contains = o.contains(key);
|
||||
assert_eq!(reach.is_none_or(|r| r.contains(key)), contains, "{key:?}");
|
||||
assert!(!(contains && past(key)), "{key:?} past the box");
|
||||
answers.push(contains);
|
||||
}
|
||||
}
|
||||
}
|
||||
answers
|
||||
}
|
||||
|
||||
/// The graph `reach` exports answers as `contains` (`answers`): walled in,
|
||||
/// opened by a partial update, cleared; over clamped boxes, the sky band
|
||||
/// above them reached or not (a closed cave); from above the world. The
|
||||
/// search, the partial update and `clear` each change the generation, and
|
||||
/// another level's graph never repeats one.
|
||||
#[test]
|
||||
fn the_exported_graph_answers_as_contains() {
|
||||
let mut o = Occlusion::new(0, 4);
|
||||
let first = o.generation();
|
||||
answers(&o, (0, 1, 0), 3);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
o.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
o.settle(at((0, 1, 0)), 3);
|
||||
assert_ne!(o.generation(), first, "a search landed");
|
||||
let walled = answers(&o, (0, 1, 0), 3);
|
||||
let searched = o.generation();
|
||||
o.set((0, 1, 1), ALL_VISIBLE);
|
||||
o.update(at((0, 1, 0)), 3);
|
||||
assert_ne!(o.generation(), searched, "a partial update reached more");
|
||||
assert_ne!(answers(&o, (0, 1, 0), 3), walled);
|
||||
let opened = o.generation();
|
||||
o.clear();
|
||||
assert_ne!(o.generation(), opened, "the graph went");
|
||||
answers(&o, (0, 1, 0), 3);
|
||||
// Another level's graph never takes one of this one's generations, or
|
||||
// the pass would keep its copy of this one.
|
||||
let mut other = Occlusion::new(0, 4);
|
||||
other.settle(at((0, 1, 0)), 3);
|
||||
assert_ne!(other.generation(), searched, "another level's graph");
|
||||
|
||||
let (mut clamped, mut band) = (0, [0; 2]);
|
||||
let mut check = |mut o: Occlusion, camera: DVec3, radius: i32| {
|
||||
o.settle(camera, radius);
|
||||
answers(&o, o.columns.section_of(camera), radius);
|
||||
let g = o.graph.as_ref().expect("searched");
|
||||
if g.height < o.columns.sections + 2 {
|
||||
clamped += 1;
|
||||
// The first section above the box, over the camera's column.
|
||||
band[usize::from(o.contains((g.from.0, g.y0 + g.height, g.from.2)))] += 1;
|
||||
}
|
||||
};
|
||||
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
|
||||
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
random_world(&mut o, seed, count, spread);
|
||||
check(o, at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9), radius);
|
||||
}
|
||||
}
|
||||
let mut cave = Occlusion::new(0, 24);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
cave.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
check(cave, at((0, 1, 0)), 4);
|
||||
let mut above = Occlusion::new(0, 24);
|
||||
random_world(&mut above, 7, 900, 6);
|
||||
check(above, DVec3::new(3.3, 400.0, -5.6), 6);
|
||||
assert!(
|
||||
clamped > 0 && band[0] > 0 && band[1] > 0,
|
||||
"{clamped} clamped, the band above hidden and seen {band:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whenever an answer of `contains` changes, so does the generation: over
|
||||
/// 300 random steps, each a section set (open, closed or anything between),
|
||||
/// a column removed, the camera moved to another section (now and then
|
||||
/// above or below the world), or now and then another world, after a
|
||||
/// `clear` or a teleport that drops every column (`remove_columns`), each
|
||||
/// followed by a frame's `update` or a settled search; then an update from
|
||||
/// a section the graph never reached. The exported graph answers as
|
||||
/// `contains` after every step.
|
||||
#[test]
|
||||
fn every_change_of_the_answers_changes_the_generation() {
|
||||
let mut seed = 0x0bad_5eed_u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let (spread, radius, around) = (5, 3, (0, 0, 0));
|
||||
let offset = DVec3::new(3.3, -5.7, 1.9);
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
random_world(&mut o, 0x2545_f491, 400, spread);
|
||||
let mut camera = at((0, 2, 0)) + offset;
|
||||
o.settle(camera, radius);
|
||||
let mut last = (o.generation(), answers(&o, around, spread + radius));
|
||||
let mut changed = 0;
|
||||
for step in 0..300 {
|
||||
let r = next();
|
||||
let key = (
|
||||
(r % 11) as i32 - spread,
|
||||
((r >> 8) % 7) as i32,
|
||||
((r >> 16) % 11) as i32 - spread,
|
||||
);
|
||||
match r >> 24 & 15 {
|
||||
0..=7 => {
|
||||
let vis = match r >> 28 & 3 {
|
||||
0 => ALL_VISIBLE,
|
||||
1 => 0,
|
||||
_ => next() & ALL_VISIBLE,
|
||||
};
|
||||
o.set(key, vis);
|
||||
}
|
||||
8 | 9 => o.remove_column(key.0, key.2),
|
||||
10..=13 => {
|
||||
let y = match r >> 32 & 7 {
|
||||
0 => 30,
|
||||
1 => -3,
|
||||
_ => key.1,
|
||||
};
|
||||
camera = at((key.0, y, key.2)) + offset;
|
||||
}
|
||||
_ if r >> 36 & 7 == 0 => {
|
||||
o.clear();
|
||||
random_world(&mut o, next(), 400, spread);
|
||||
}
|
||||
_ if r >> 36 & 7 == 1 => {
|
||||
// A teleport: every column goes at once.
|
||||
let all: FxHashSet<_> = o.columns.map.keys().copied().collect();
|
||||
o.remove_columns(&all);
|
||||
random_world(&mut o, next(), 400, spread);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if r >> 40 & 1 == 0 {
|
||||
o.update(camera, radius);
|
||||
} else {
|
||||
o.settle(camera, radius);
|
||||
}
|
||||
let now = (o.generation(), answers(&o, around, spread + radius));
|
||||
if now.1 != last.1 {
|
||||
assert_ne!(
|
||||
now.0, last.0,
|
||||
"step {step}: answers changed, the generation not"
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
last = now;
|
||||
}
|
||||
assert!(changed >= 50, "{changed} steps changed answers");
|
||||
// Walled in, then past the wall: the graph never reached the camera's
|
||||
// section, and goes until the next search lands. (The first settle may
|
||||
// land a search that started before the wall.)
|
||||
for (dx, dy, dz) in STEPS {
|
||||
o.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
o.settle(at((0, 1, 0)), radius);
|
||||
o.settle(at((0, 1, 0)), radius);
|
||||
let walled = (o.generation(), answers(&o, around, spread + radius));
|
||||
assert!(!o.contains((3, 1, 0)), "past the wall");
|
||||
o.update(at((3, 1, 0)), radius);
|
||||
assert_ne!(answers(&o, around, spread + radius), walled.1);
|
||||
assert_ne!(o.generation(), walled.0, "the graph went");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
|
||||
//!
|
||||
//! Flat draws, which read a table per sprite layer, get the game's own layout
|
||||
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
|
||||
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
|
||||
//! a grid of array-sized cells ([`PackAtlas::new`]).
|
||||
|
||||
#[path = "pack_atlas/stitch.rs"]
|
||||
mod stitch;
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
@@ -21,82 +14,6 @@ pub(super) struct PackAtlas {
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// The stitched layout, when the atlas has one.
|
||||
stitched: Option<Stitched>,
|
||||
}
|
||||
|
||||
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
|
||||
struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// One per sprite array.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct BlitSprite {
|
||||
/// The sprite's top left corner, side and padding at that level.
|
||||
rect: [i32; 4],
|
||||
/// Its layer in its array, the array's level it comes from, the atlas level's size.
|
||||
source: [u32; 4],
|
||||
}
|
||||
|
||||
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
|
||||
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
|
||||
const BLIT: &str = r#"
|
||||
struct Out {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) @interpolate(flat) rect: vec4<i32>,
|
||||
@location(1) @interpolate(flat) source: vec2<u32>,
|
||||
}
|
||||
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
|
||||
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
|
||||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
|
||||
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
|
||||
let size = vec2<f32>(source.zw);
|
||||
var out: Out;
|
||||
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
|
||||
out.rect = rect;
|
||||
out.source = source.xy;
|
||||
return out;
|
||||
}
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
|
||||
/// hold it scaled up by a power of two (else the array's).
|
||||
fn atlas_side(native: u32, sprite: u32) -> u32 {
|
||||
if native == 0
|
||||
|| native > sprite
|
||||
|| !sprite.is_multiple_of(native)
|
||||
|| !(sprite / native).is_power_of_two()
|
||||
{
|
||||
sprite
|
||||
} else {
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
|
||||
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
|
||||
let mut lowest = 1u32 << option.min(16);
|
||||
let mut smallest = u32::MAX;
|
||||
for &side in sides {
|
||||
smallest = smallest.min(side);
|
||||
lowest = lowest.min(side & side.wrapping_neg());
|
||||
}
|
||||
let level = smallest.min(lowest).max(1).ilog2();
|
||||
level.min(option)
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -109,37 +26,6 @@ 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);
|
||||
@@ -149,7 +35,7 @@ impl PackAtlas {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = levels.min(sprite.ilog2() + 1);
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -174,173 +60,11 @@ impl PackAtlas {
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
|
||||
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
|
||||
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stitched(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
native: &[u32],
|
||||
mip_option: u32,
|
||||
anisotropy_bit: u32,
|
||||
) -> Option<Self> {
|
||||
let sides: Vec<u32> = if native.is_empty() {
|
||||
vec![sprite]
|
||||
} else {
|
||||
native.iter().map(|&n| atlas_side(n, sprite)).collect()
|
||||
};
|
||||
// A level of the atlas comes from the arrays' level as many halvings further down.
|
||||
let array_levels = arrays[0].mip_level_count();
|
||||
let deepest = sides
|
||||
.iter()
|
||||
.map(|&s| (sprite / s).ilog2())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mip =
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
|
||||
});
|
||||
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
bind_group_layouts: &[Some(&group_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<BlitSprite>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let groups = arrays
|
||||
.iter()
|
||||
.map(|array| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: &group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&array.create_view(
|
||||
&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched {
|
||||
rects,
|
||||
padding: layout.padding,
|
||||
pipeline,
|
||||
groups,
|
||||
}),
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
pub fn is_stitched(&self) -> bool {
|
||||
self.stitched.is_some()
|
||||
}
|
||||
|
||||
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
|
||||
/// (x | y << 16) and side, in atlas pixels.
|
||||
pub fn rects_table(&self) -> Vec<u32> {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return stitched
|
||||
.rects
|
||||
.iter()
|
||||
.flat_map(|&[x, y, side]| [x | (y << 16), side])
|
||||
.collect();
|
||||
}
|
||||
(0..self.columns * self.texture.height() / self.sprite)
|
||||
.flat_map(|layer| {
|
||||
let (x, y) = (layer % self.columns, layer / self.columns);
|
||||
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
@@ -349,14 +73,10 @@ impl PackAtlas {
|
||||
/// Copies `layers` from the sprite arrays again, every level.
|
||||
pub fn copy(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return self.draw_sprites(stitched, device, encoder, layers);
|
||||
}
|
||||
for layer in layers {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
@@ -394,91 +114,6 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
|
||||
/// sprite has its own size.
|
||||
fn draw_sprites(
|
||||
&self,
|
||||
stitched: &Stitched,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
let mut layers: Vec<u32> = layers
|
||||
.into_iter()
|
||||
.filter(|&l| (l as usize) < stitched.rects.len())
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
layers.sort_unstable_by_key(|&l| (l / self.split, l));
|
||||
let levels = self.texture.mip_level_count();
|
||||
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
|
||||
for level in 0..levels {
|
||||
let size = [
|
||||
self.texture.width() >> level,
|
||||
self.texture.height() >> level,
|
||||
];
|
||||
for &layer in &layers {
|
||||
let [x, y, side] = stitched.rects[layer as usize];
|
||||
sprites.push(BlitSprite {
|
||||
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
|
||||
source: [
|
||||
layer % self.split,
|
||||
(self.sprite / side).ilog2() + level,
|
||||
size[0],
|
||||
size[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
contents: bytemuck::cast_slice(&sprites),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
},
|
||||
);
|
||||
let per_level = layers.len() as u32;
|
||||
for level in 0..levels {
|
||||
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&stitched.pipeline);
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
let first = level * per_level;
|
||||
let mut start = 0;
|
||||
while start < layers.len() {
|
||||
let array = layers[start] / self.split;
|
||||
let end = start
|
||||
+ layers[start..]
|
||||
.iter()
|
||||
.take_while(|&&l| l / self.split == array)
|
||||
.count();
|
||||
if let Some(group) = stitched.groups.get(array as usize) {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..6, first + start as u32..first + end as u32);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
@@ -538,8 +173,7 @@ impl PackAtlas {
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -547,29 +181,11 @@ impl PackAtlas {
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
|
||||
// in flat draws (`rects_table`), from the grid in others.
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
[1.0 / width as f32, 1.0 / height as f32],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
|
||||
),
|
||||
"1.0",
|
||||
[
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
],
|
||||
)
|
||||
};
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
@@ -580,8 +196,8 @@ impl PackAtlas {
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
@@ -636,7 +252,6 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,128 +337,6 @@ mod tests {
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
|
||||
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
|
||||
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
|
||||
pub(super) fn upscaled_array(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
native: &[u32],
|
||||
) -> wgpu::Texture {
|
||||
let sprite = native.iter().copied().max().unwrap();
|
||||
let levels = sprite.ilog2() + 1;
|
||||
let mut data = Vec::new();
|
||||
for (layer, &side) in native.iter().enumerate() {
|
||||
for level in 0..levels {
|
||||
let edge = sprite >> level;
|
||||
// Above its own size a level repeats each pixel; below, it shrinks to the first.
|
||||
let scale = (edge / side).max(1);
|
||||
for y in 0..edge {
|
||||
for x in 0..edge {
|
||||
let (x, y) = if edge >= side {
|
||||
(x / scale, y / scale)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: native.len() as u32,
|
||||
},
|
||||
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,
|
||||
&data,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
|
||||
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
|
||||
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
|
||||
#[test]
|
||||
fn stitched_sprites_keep_their_own_size_and_padding() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
assert_eq!(atlas.size(), [16, 8]);
|
||||
assert_eq!(
|
||||
atlas.rects_table(),
|
||||
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 8,
|
||||
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();
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = y * 256 + x * 4;
|
||||
[data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
};
|
||||
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
|
||||
assert_eq!(
|
||||
[at(9, 1), at(10, 1), at(10, 2)],
|
||||
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
|
||||
);
|
||||
assert_eq!(
|
||||
[at(8, 0), at(11, 3), at(11, 1)],
|
||||
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
|
||||
);
|
||||
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
|
||||
assert_eq!(
|
||||
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
|
||||
[
|
||||
[50, 0, 0, 255],
|
||||
[50, 48, 48, 255],
|
||||
[50, 0, 48, 255],
|
||||
[50, 48, 0, 255]
|
||||
]
|
||||
);
|
||||
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -873,7 +366,6 @@ mod tests {
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
|
||||
@@ -123,54 +123,6 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
|
||||
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
|
||||
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
|
||||
#[test]
|
||||
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
|
||||
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
|
||||
let vertices: Vec<_> = (0..3)
|
||||
.flat_map(|layer| {
|
||||
(0..4).map(move |k| {
|
||||
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Corner and side of each layer's sprite, in atlas pixels.
|
||||
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
|
||||
let mut failures = Vec::new();
|
||||
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
|
||||
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
|
||||
for (corner, [u, v]) in whole.iter().enumerate() {
|
||||
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
|
||||
let actual = pixels[4 * layer + corner];
|
||||
if actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6)
|
||||
{
|
||||
failures.push(format!(
|
||||
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -231,7 +183,7 @@ fn sample(
|
||||
}],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![],
|
||||
vec![storage(0), storage(1), storage(2)],
|
||||
vec![storage(0), storage(1)],
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
@@ -316,15 +268,11 @@ fn sample(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
vec![(0, &slots), (1, &origin)],
|
||||
vec![],
|
||||
vec![(0, &ids), (1, &data), (2, &rects)],
|
||||
vec![(0, &ids), (1, &data)],
|
||||
];
|
||||
let groups: Vec<_> = bindings
|
||||
.iter()
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
|
||||
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
|
||||
//! by height, then width, then (where vanilla compares names) sprite layer.
|
||||
|
||||
/// The atlas a set of sprites stitches into.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Stitched {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
|
||||
pub holders: Vec<[u32; 2]>,
|
||||
pub padding: u32,
|
||||
}
|
||||
|
||||
/// `Stitcher.smallestFittingMinTexel`.
|
||||
fn fit(input: u32, mip_level: u32) -> u32 {
|
||||
let step = 1 << mip_level;
|
||||
input.div_ceil(step) * step
|
||||
}
|
||||
|
||||
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
|
||||
fn power_of_two(input: u32) -> u32 {
|
||||
if input == 0 {
|
||||
0
|
||||
} else {
|
||||
input.next_power_of_two()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher.Region`.
|
||||
struct Region {
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
holder: bool,
|
||||
sub: Vec<Region>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
holder: false,
|
||||
sub: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Region.add`: the holder's top left corner, when it fits.
|
||||
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
|
||||
if self.holder || w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
if w == self.width && h == self.height {
|
||||
self.holder = true;
|
||||
return Some([self.x, self.y]);
|
||||
}
|
||||
if self.sub.is_empty() {
|
||||
let (x, y) = (self.x, self.y);
|
||||
self.sub.push(Region::new(x, y, w, h));
|
||||
let (spare_w, spare_h) = (self.width - w, self.height - h);
|
||||
if spare_h > 0 && spare_w > 0 {
|
||||
let right = self.height.max(spare_w);
|
||||
let bottom = self.width.max(spare_h);
|
||||
if right >= bottom {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
self.sub.push(Region::new(x + w, y, spare_w, self.height));
|
||||
} else {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
self.sub.push(Region::new(x, y + h, self.width, spare_h));
|
||||
}
|
||||
} else if spare_w == 0 {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
} else if spare_h == 0 {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
}
|
||||
}
|
||||
self.sub.iter_mut().find_map(|r| r.add([w, h]))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
|
||||
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
|
||||
pub(super) fn stitch(
|
||||
sides: &[u32],
|
||||
mip_level: u32,
|
||||
anisotropy_bit: u32,
|
||||
max: u32,
|
||||
) -> Option<Stitched> {
|
||||
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
|
||||
let mut order: Vec<usize> = (0..sides.len()).collect();
|
||||
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
|
||||
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
|
||||
let (mut storage_x, mut storage_y) = (0u32, 0u32);
|
||||
let mut storage: Vec<Region> = Vec::new();
|
||||
let mut holders = vec![[0, 0]; sides.len()];
|
||||
for i in order {
|
||||
let size = [holder(i); 2];
|
||||
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
|
||||
holders[i] = at;
|
||||
continue;
|
||||
}
|
||||
// `Stitcher.expand`.
|
||||
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
|
||||
let (x_new, y_new) = (
|
||||
power_of_two(storage_x + size[0]),
|
||||
power_of_two(storage_y + size[1]),
|
||||
);
|
||||
let (x_can, y_can) = (x_new <= max, y_new <= max);
|
||||
if !x_can && !y_can {
|
||||
return None;
|
||||
}
|
||||
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
|
||||
let grow_x = if x_will ^ y_will {
|
||||
x_will
|
||||
} else {
|
||||
x_can && x_now <= y_now
|
||||
};
|
||||
let mut slot = if grow_x {
|
||||
if storage_y == 0 {
|
||||
storage_y = y_new;
|
||||
}
|
||||
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
|
||||
storage_x = x_new;
|
||||
slot
|
||||
} else {
|
||||
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
|
||||
storage_y = y_new;
|
||||
slot
|
||||
};
|
||||
holders[i] = slot.add(size)?;
|
||||
storage.push(slot);
|
||||
}
|
||||
Some(Stitched {
|
||||
width: storage_x,
|
||||
height: storage_y,
|
||||
holders,
|
||||
padding,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
|
||||
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
|
||||
#[test]
|
||||
fn equal_sprites_follow_vanillas_regions() {
|
||||
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
|
||||
assert_eq!(
|
||||
(stitched.width, stitched.height, stitched.padding),
|
||||
(64, 64, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
stitched.holders,
|
||||
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
|
||||
);
|
||||
}
|
||||
|
||||
/// The largest sprite goes first; padding and holders round up to the mip step.
|
||||
#[test]
|
||||
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
|
||||
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
|
||||
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
|
||||
assert_eq!(stitched.padding, 4);
|
||||
assert_eq!(stitched.holders[1], [0, 0]);
|
||||
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
|
||||
assert_eq!((stitched.width, stitched.height), (64, 64));
|
||||
assert_eq!(stitched.holders[0], [0, 40]);
|
||||
// Anisotropy 16x (bit 4) multiplies the padding by 8.
|
||||
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
|
||||
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,7 @@ impl EntitySpan {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
|
||||
/// `entityColor`.
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
pub(super) struct DrawUniforms {
|
||||
@@ -74,10 +73,8 @@ 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; `currentRenderedItemId`
|
||||
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
|
||||
// `blockEntityId` outside block entities, as Iris has it.
|
||||
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,7 +101,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +162,6 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +194,6 @@ 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).
|
||||
@@ -379,89 +375,38 @@ impl PackEntities {
|
||||
pack,
|
||||
casts,
|
||||
overlay,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
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)]
|
||||
};
|
||||
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,
|
||||
);
|
||||
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 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);
|
||||
}
|
||||
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;
|
||||
@@ -508,7 +453,6 @@ impl PackEntities {
|
||||
pack: bool,
|
||||
casts: bool,
|
||||
overlay: u32,
|
||||
portal: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
@@ -532,7 +476,6 @@ impl PackEntities {
|
||||
pack,
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
portal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -561,104 +504,13 @@ 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;
|
||||
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
|
||||
pub const BEAM: usize = 5;
|
||||
pub const PROGRAMS: usize = 6;
|
||||
pub const PROGRAMS: usize = 5;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -735,11 +587,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, true]),
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
@@ -802,103 +654,6 @@ 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::*;
|
||||
@@ -1036,57 +791,6 @@ 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,14 +13,9 @@ 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",
|
||||
);
|
||||
@@ -50,17 +45,10 @@ 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 {
|
||||
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
|
||||
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -86,28 +74,6 @@ 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,
|
||||
@@ -116,10 +82,6 @@ impl PackHand {
|
||||
group,
|
||||
capacity,
|
||||
quads: 0,
|
||||
slots,
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,31 +116,18 @@ impl PackHand {
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Uploads this frame's hand and its projection.
|
||||
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) =
|
||||
@@ -195,54 +144,10 @@ 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,9 +35,7 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
|
||||
defs.into_iter()
|
||||
.map(|def| PackImage {
|
||||
format: format(def.format),
|
||||
dimension: if def.is_1d() {
|
||||
wgpu::TextureViewDimension::D1
|
||||
} else if def.is_3d() {
|
||||
dimension: if def.is_3d() {
|
||||
wgpu::TextureViewDimension::D3
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
@@ -53,15 +51,6 @@ 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;
|
||||
@@ -96,9 +85,7 @@ pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), S
|
||||
));
|
||||
}
|
||||
let limits = device.limits();
|
||||
let largest = if def.is_1d() {
|
||||
limits.max_texture_dimension_1d
|
||||
} else if def.is_3d() {
|
||||
let largest = if def.is_3d() {
|
||||
limits.max_texture_dimension_3d
|
||||
} else {
|
||||
limits.max_texture_dimension_2d
|
||||
@@ -141,7 +128,11 @@ pub(super) fn resize_images(
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: texture_dimension(image.dimension),
|
||||
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
|
||||
wgpu::TextureDimension::D3
|
||||
} else {
|
||||
wgpu::TextureDimension::D2
|
||||
},
|
||||
format: image.format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
|
||||
view_formats: &[],
|
||||
@@ -330,7 +321,11 @@ pub(super) fn stand_ins(
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: texture_dimension(dimension),
|
||||
dimension: if dimension == wgpu::TextureViewDimension::D3 {
|
||||
wgpu::TextureDimension::D3
|
||||
} else {
|
||||
wgpu::TextureDimension::D2
|
||||
},
|
||||
format,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
|
||||
@@ -50,7 +50,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
//! 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 {
|
||||
sprite_rects: &[],
|
||||
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,
|
||||
)
|
||||
.expect("the pack runs");
|
||||
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();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1040 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user