Compare commits
No files matched your search
@@ -6,7 +6,7 @@ name: launcher
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [launcher]
|
||||
branches: [launcher, 'launcher-*']
|
||||
tags: ['launcher-v*']
|
||||
|
||||
permissions:
|
||||
@@ -27,7 +27,12 @@ jobs:
|
||||
- os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.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
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: launcher
|
||||
@@ -48,6 +53,42 @@ jobs:
|
||||
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
|
||||
- run: cargo test --release --locked --lib
|
||||
- run: cargo build --release --locked
|
||||
- name: Only system libraries
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
|
||||
- name: Smoke test
|
||||
env:
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
"$launcher" --version
|
||||
retry "$launcher" versions
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# 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
|
||||
python ../tools/check_imports.py "$client"
|
||||
python ../tools/check_imports.py "$launcher"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
fi
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 client and uploads the playable folder as an artifact.
|
||||
# Linux and macOS builds are made on the development machines.
|
||||
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
|
||||
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
|
||||
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
|
||||
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
@@ -18,6 +20,10 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Only system DLLs
|
||||
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
|
||||
# "DLL not found" dialog on some players' machines.
|
||||
run: python tools/check_imports.py target/release/horizon-client.exe
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
@@ -25,3 +31,21 @@ jobs:
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: ALSA headers
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
- name: Build the client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Standard loader and libraries only
|
||||
run: python3 tools/check_imports.py target/release/horizon-client
|
||||
- name: Package
|
||||
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux-x86_64
|
||||
path: dist/horizon-linux-x86_64
|
||||
@@ -681,12 +681,14 @@ dependencies = [
|
||||
name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"cpal",
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-theme",
|
||||
"horizon-world",
|
||||
"miniz_oxide 0.8.9",
|
||||
"png",
|
||||
@@ -713,6 +715,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-theme"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-world"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 133,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 941908352
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2409.179688612205,
|
||||
"cpu": {
|
||||
"avg_ms": 0.41004928978864236,
|
||||
"max_ms": 18.15971565246582,
|
||||
"p50_ms": 0.2718150019645691,
|
||||
"p95_ms": 0.7112299799919128,
|
||||
"p99_ms": 1.316559076309204
|
||||
},
|
||||
"frames": 28910,
|
||||
"gpu": {
|
||||
"avg_ms": 0.09866036293994015,
|
||||
"max_ms": 0.25088000297546387,
|
||||
"p50_ms": 0.08806400001049042,
|
||||
"p95_ms": 0.1740799993276596,
|
||||
"p99_ms": 0.19968000054359436
|
||||
},
|
||||
"low_1pct_fps": 160.80638022914025,
|
||||
"max_ms": 18.175312042236328,
|
||||
"p50_ms": 0.2760099768638611,
|
||||
"p95_ms": 0.7161250114440918,
|
||||
"p99_ms": 1.4078609943389893,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.20953580908804606,
|
||||
"render": 0.12439006986512069,
|
||||
"tick": 0.05299293669455945,
|
||||
"update": 0.023244019639293335
|
||||
},
|
||||
"seconds": 11.999935138359666,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4052206484151298,
|
||||
"render": 0.41893499056840766,
|
||||
"tick": 5.252727109792939,
|
||||
"update": 0.0936474929949076
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 1099.4635681039538,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9003613499442573,
|
||||
"max_ms": 23.31729507446289,
|
||||
"p50_ms": 0.6657320261001587,
|
||||
"p95_ms": 0.9898859858512878,
|
||||
"p99_ms": 9.592857360839844
|
||||
},
|
||||
"frames": 13194,
|
||||
"gpu": {
|
||||
"avg_ms": 0.2639814413552015,
|
||||
"max_ms": 3.0013439655303955,
|
||||
"p50_ms": 0.2549760043621063,
|
||||
"p95_ms": 0.3553279936313629,
|
||||
"p99_ms": 0.37171199917793274
|
||||
},
|
||||
"low_1pct_fps": 82.87026130908886,
|
||||
"max_ms": 27.990703582763672,
|
||||
"p50_ms": 0.6705269813537598,
|
||||
"p95_ms": 1.0082690715789795,
|
||||
"p99_ms": 9.64776611328125,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.571024256178027,
|
||||
"render": 0.12442494783992657,
|
||||
"tick": 0.1706307831885292,
|
||||
"update": 0.03442592136820218
|
||||
},
|
||||
"seconds": 12.000397632777691,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.7136096191225629,
|
||||
"render": 0.43175565880356415,
|
||||
"tick": 10.713303016893791,
|
||||
"update": 0.05390534066651581
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 687.0806476294383,
|
||||
"cpu": {
|
||||
"avg_ms": 1.4490217101725757,
|
||||
"max_ms": 29.75178337097168,
|
||||
"p50_ms": 0.9425880312919617,
|
||||
"p95_ms": 1.6511069536209106,
|
||||
"p99_ms": 15.603099822998047
|
||||
},
|
||||
"frames": 8245,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3711021953564546,
|
||||
"max_ms": 1.5022079944610596,
|
||||
"p50_ms": 0.3665919899940491,
|
||||
"p95_ms": 0.43827199935913086,
|
||||
"p99_ms": 0.4679679870605469
|
||||
},
|
||||
"low_1pct_fps": 47.486143092805435,
|
||||
"max_ms": 29.770263671875,
|
||||
"p50_ms": 0.947784960269928,
|
||||
"p95_ms": 1.6628600358963013,
|
||||
"p99_ms": 15.621638298034668,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8449565766073126,
|
||||
"render": 0.1554101111230668,
|
||||
"tick": 0.3840128544967872,
|
||||
"update": 0.06481705411812297
|
||||
},
|
||||
"seconds": 12.000046906352043,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.2885668083845851,
|
||||
"render": 0.45316039797771407,
|
||||
"tick": 19.216593845781073,
|
||||
"update": 0.08380924088947744
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 343.77852072094186,
|
||||
"cpu": {
|
||||
"avg_ms": 1.5961282111390964,
|
||||
"max_ms": 22.24184799194336,
|
||||
"p50_ms": 0.8254669904708862,
|
||||
"p95_ms": 11.660508155822754,
|
||||
"p99_ms": 14.398658752441406
|
||||
},
|
||||
"frames": 4113,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3449904050323872,
|
||||
"max_ms": 2.9040639400482178,
|
||||
"p50_ms": 0.3338240087032318,
|
||||
"p95_ms": 0.43724799156188965,
|
||||
"p99_ms": 0.5171200037002563
|
||||
},
|
||||
"low_1pct_fps": 29.95597087331344,
|
||||
"max_ms": 33.422760009765625,
|
||||
"p50_ms": 0.8299460411071777,
|
||||
"p95_ms": 33.33942413330078,
|
||||
"p99_ms": 33.37727355957031,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.7147062004496516,
|
||||
"render": 0.14707977256612884,
|
||||
"tick": 0.6855548198381525,
|
||||
"update": 0.04897363143349227
|
||||
},
|
||||
"seconds": 11.96409825539589,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8949875491006034,
|
||||
"render": 0.6183000469491595,
|
||||
"tick": 14.63794419879005,
|
||||
"update": 0.8699657806594457
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 16462208
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 1041.3622747953375,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9549695556642304,
|
||||
"max_ms": 26.009796142578125,
|
||||
"p50_ms": 0.4810290038585663,
|
||||
"p95_ms": 2.5506999492645264,
|
||||
"p99_ms": 9.879941940307617
|
||||
},
|
||||
"frames": 12496,
|
||||
"gpu": {
|
||||
"avg_ms": 0.22781361336253403,
|
||||
"max_ms": 1.7469439506530762,
|
||||
"p50_ms": 0.18534399569034576,
|
||||
"p95_ms": 0.6113280057907104,
|
||||
"p99_ms": 0.809984028339386
|
||||
},
|
||||
"low_1pct_fps": 64.04348583959742,
|
||||
"max_ms": 26.032779693603516,
|
||||
"p50_ms": 0.48582199215888977,
|
||||
"p95_ms": 2.5584940910339355,
|
||||
"p99_ms": 9.892412185668945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.37678885421859964,
|
||||
"render": 0.3450905326050295,
|
||||
"tick": 0.1742891420732452,
|
||||
"update": 0.0590072420405441
|
||||
},
|
||||
"seconds": 11.999666496902703,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.4649118003845214,
|
||||
"render": 1.1414484503269196,
|
||||
"tick": 12.881806396484375,
|
||||
"update": 0.11011978423595428
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 316.2068527731055,
|
||||
"cpu": {
|
||||
"avg_ms": 3.154168446387541,
|
||||
"max_ms": 26.147607803344727,
|
||||
"p50_ms": 2.2156569957733154,
|
||||
"p95_ms": 12.254712104797363,
|
||||
"p99_ms": 17.7707462310791
|
||||
},
|
||||
"frames": 3795,
|
||||
"gpu": {
|
||||
"avg_ms": 0.7502970771905776,
|
||||
"max_ms": 2.599936008453369,
|
||||
"p50_ms": 0.7239680290222168,
|
||||
"p95_ms": 1.3158400058746338,
|
||||
"p99_ms": 1.3772799968719482
|
||||
},
|
||||
"low_1pct_fps": 49.59272936233826,
|
||||
"max_ms": 26.164094924926758,
|
||||
"p50_ms": 2.222438097000122,
|
||||
"p95_ms": 12.270298957824707,
|
||||
"p99_ms": 17.786956787109375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.5666706890613822,
|
||||
"render": 0.7056146899387927,
|
||||
"tick": 0.7469899302897249,
|
||||
"update": 0.13521884845002838
|
||||
},
|
||||
"seconds": 12.001637430429458,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.9408628783727948,
|
||||
"render": 1.194917630208166,
|
||||
"tick": 16.762235842253034,
|
||||
"update": 0.24904731876756014
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 385.09978163469907,
|
||||
"cpu": {
|
||||
"avg_ms": 2.589322079316407,
|
||||
"max_ms": 18.984909057617188,
|
||||
"p50_ms": 2.0551369190216064,
|
||||
"p95_ms": 8.740593910217285,
|
||||
"p99_ms": 11.559625625610352
|
||||
},
|
||||
"frames": 4622,
|
||||
"gpu": {
|
||||
"avg_ms": 0.6778284030938654,
|
||||
"max_ms": 2.398207902908325,
|
||||
"p50_ms": 0.7833600044250488,
|
||||
"p95_ms": 0.9431040287017822,
|
||||
"p99_ms": 1.511423945426941
|
||||
},
|
||||
"low_1pct_fps": 71.97424221133898,
|
||||
"max_ms": 19.0070858001709,
|
||||
"p50_ms": 2.061105966567993,
|
||||
"p95_ms": 8.753417015075684,
|
||||
"p99_ms": 11.577004432678223,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3568510900633204,
|
||||
"render": 0.7359144338432817,
|
||||
"tick": 0.41578289517198963,
|
||||
"update": 0.0810434000275639
|
||||
},
|
||||
"seconds": 12.002084188103677,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.6523635336693296,
|
||||
"render": 1.2426407299143203,
|
||||
"tick": 10.818658037388579,
|
||||
"update": 0.16372418847489864
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 219.70895893367268,
|
||||
"cpu": {
|
||||
"avg_ms": 3.098228604710274,
|
||||
"max_ms": 23.518693923950195,
|
||||
"p50_ms": 2.0422279834747314,
|
||||
"p95_ms": 10.511551856994629,
|
||||
"p99_ms": 16.436803817749023
|
||||
},
|
||||
"frames": 2634,
|
||||
"gpu": {
|
||||
"avg_ms": 0.9377313036310374,
|
||||
"max_ms": 9.588735580444336,
|
||||
"p50_ms": 0.8488960266113281,
|
||||
"p95_ms": 0.9728000164031982,
|
||||
"p99_ms": 3.4201600551605225
|
||||
},
|
||||
"low_1pct_fps": 29.957383952355947,
|
||||
"max_ms": 33.38852310180664,
|
||||
"p50_ms": 2.0481808185577393,
|
||||
"p95_ms": 33.341556549072266,
|
||||
"p99_ms": 33.377445220947266,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3405477057013806,
|
||||
"render": 0.8202416233113071,
|
||||
"tick": 0.8190081106798682,
|
||||
"update": 0.11872799601335143
|
||||
},
|
||||
"seconds": 11.988587141752243,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.648652986243919,
|
||||
"render": 1.7585702317732352,
|
||||
"tick": 14.372832368921351,
|
||||
"update": 1.0560209235107456
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 212,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 255897088
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 48.3055081533635,
|
||||
"cpu": {
|
||||
"avg_ms": 9.376506657416572,
|
||||
"max_ms": 861.2249145507812,
|
||||
"p50_ms": 3.0502548217773438,
|
||||
"p95_ms": 32.94725036621094,
|
||||
"p99_ms": 74.04153442382812
|
||||
},
|
||||
"frames": 1271,
|
||||
"gpu": {
|
||||
"avg_ms": 1.7700931664342303,
|
||||
"max_ms": 20.69094467163086,
|
||||
"p50_ms": 1.2410880327224731,
|
||||
"p95_ms": 7.598080158233643,
|
||||
"p99_ms": 15.925248146057129
|
||||
},
|
||||
"low_1pct_fps": 0.761712600806078,
|
||||
"max_ms": 14330.3583984375,
|
||||
"p50_ms": 3.071429967880249,
|
||||
"p95_ms": 33.099002838134766,
|
||||
"p99_ms": 75.7547378540039,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.518610580008767,
|
||||
"render": 1.6828305973582713,
|
||||
"tick": 5.15484752775804,
|
||||
"update": 1.020680267595335
|
||||
},
|
||||
"seconds": 26.31169919514656,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.9654910931220422,
|
||||
"render": 3.5708756630237284,
|
||||
"tick": 196.6683132465069,
|
||||
"update": 14.971596607795128
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.53318156189366,
|
||||
"cpu": {
|
||||
"avg_ms": 40.13187835841161,
|
||||
"max_ms": 1166.3994140625,
|
||||
"p50_ms": 6.5535569190979,
|
||||
"p95_ms": 63.877315521240234,
|
||||
"p99_ms": 1108.11865234375
|
||||
},
|
||||
"frames": 271,
|
||||
"gpu": {
|
||||
"avg_ms": 2.297342118961785,
|
||||
"max_ms": 22.554624557495117,
|
||||
"p50_ms": 0.7065600156784058,
|
||||
"p95_ms": 7.245823860168457,
|
||||
"p99_ms": 9.055232048034668
|
||||
},
|
||||
"low_1pct_fps": 0.8848759980013957,
|
||||
"max_ms": 1166.4168701171875,
|
||||
"p50_ms": 6.675865173339844,
|
||||
"p95_ms": 59.150550842285156,
|
||||
"p99_ms": 1108.134521484375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.83869483858017,
|
||||
"render": 2.5961379855321343,
|
||||
"tick": 33.04425997872419,
|
||||
"update": 1.6535338119387186
|
||||
},
|
||||
"seconds": 9.842669267654419,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.766425689061483,
|
||||
"render": 4.919464270273845,
|
||||
"tick": 1112.3431803385417,
|
||||
"update": 10.057182629903158
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 67.01857611699128,
|
||||
"cpu": {
|
||||
"avg_ms": 14.669945189483148,
|
||||
"max_ms": 113.66764068603516,
|
||||
"p50_ms": 4.473541736602783,
|
||||
"p95_ms": 51.374263763427734,
|
||||
"p99_ms": 94.75230407714844
|
||||
},
|
||||
"frames": 802,
|
||||
"gpu": {
|
||||
"avg_ms": 2.9074973299021734,
|
||||
"max_ms": 22.526975631713867,
|
||||
"p50_ms": 1.8206720352172852,
|
||||
"p95_ms": 8.869888305664062,
|
||||
"p99_ms": 11.596799850463867
|
||||
},
|
||||
"low_1pct_fps": 10.111656570136391,
|
||||
"max_ms": 113.68443298339844,
|
||||
"p50_ms": 4.488469123840332,
|
||||
"p95_ms": 51.39033508300781,
|
||||
"p99_ms": 94.76870727539062,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.4291724538862556,
|
||||
"render": 2.242118674918006,
|
||||
"tick": 9.334139847333992,
|
||||
"update": 0.6651962902814967
|
||||
},
|
||||
"seconds": 11.966831384181976,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.7512698968251548,
|
||||
"render": 3.2687110900878906,
|
||||
"tick": 90.47323608398438,
|
||||
"update": 2.3865103986528187
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.70327462003782,
|
||||
"cpu": {
|
||||
"avg_ms": 16.062305691622306,
|
||||
"max_ms": 67.72319793701172,
|
||||
"p50_ms": 10.215509414672852,
|
||||
"p95_ms": 45.747154235839844,
|
||||
"p99_ms": 63.64399337768555
|
||||
},
|
||||
"frames": 345,
|
||||
"gpu": {
|
||||
"avg_ms": 8.470281650363535,
|
||||
"max_ms": 21.519359588623047,
|
||||
"p50_ms": 8.845312118530273,
|
||||
"p95_ms": 15.868927955627441,
|
||||
"p99_ms": 17.557504653930664
|
||||
},
|
||||
"low_1pct_fps": 15.182693348952107,
|
||||
"max_ms": 67.74352264404297,
|
||||
"p50_ms": 33.36533737182617,
|
||||
"p95_ms": 46.64082336425781,
|
||||
"p99_ms": 63.65925979614258,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8729904708654984,
|
||||
"render": 3.4296869785889337,
|
||||
"tick": 8.941314909811826,
|
||||
"update": 2.8190046198558116
|
||||
},
|
||||
"seconds": 12.01953451538086,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8918330073356628,
|
||||
"render": 4.432150959968567,
|
||||
"tick": 50.667978286743164,
|
||||
"update": 9.854933142662048
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
1.86328125,
|
||||
2.111328125,
|
||||
3.9072265625
|
||||
],
|
||||
"load_after": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 156,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1278284288
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 58.3354572261441,
|
||||
"cpu": {
|
||||
"avg_ms": 11.363309368576322,
|
||||
"max_ms": 36.24496841430664,
|
||||
"p50_ms": 13.213258743286133,
|
||||
"p95_ms": 19.535802841186523,
|
||||
"p99_ms": 24.83148956298828
|
||||
},
|
||||
"frames": 700,
|
||||
"gpu": {
|
||||
"avg_ms": 13.518205827985492,
|
||||
"max_ms": 34.97639083862305,
|
||||
"p50_ms": 13.195829391479492,
|
||||
"p95_ms": 18.039182662963867,
|
||||
"p99_ms": 23.378856658935547
|
||||
},
|
||||
"low_1pct_fps": 31.312386939419746,
|
||||
"max_ms": 36.36259078979492,
|
||||
"p50_ms": 16.02706527709961,
|
||||
"p95_ms": 21.249738693237305,
|
||||
"p99_ms": 29.130090713500977,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.2306461141364915,
|
||||
"render": 9.720663217795746,
|
||||
"tick": 1.3112367039202113,
|
||||
"update": 0.10169148096282567
|
||||
},
|
||||
"seconds": 11.99956310081482,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.35282185673713684,
|
||||
"render": 25.05345916748047,
|
||||
"tick": 3.1022580585309436,
|
||||
"update": 0.38026727948869976
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 41.90001789962103,
|
||||
"cpu": {
|
||||
"avg_ms": 18.045572770611635,
|
||||
"max_ms": 36.546627044677734,
|
||||
"p50_ms": 20.543725967407227,
|
||||
"p95_ms": 30.5231990814209,
|
||||
"p99_ms": 34.017364501953125
|
||||
},
|
||||
"frames": 504,
|
||||
"gpu": {
|
||||
"avg_ms": 21.35815803588383,
|
||||
"max_ms": 36.23066329956055,
|
||||
"p50_ms": 19.825437545776367,
|
||||
"p95_ms": 29.274858474731445,
|
||||
"p99_ms": 33.32203674316406
|
||||
},
|
||||
"low_1pct_fps": 20.022920779459263,
|
||||
"max_ms": 99.08029174804688,
|
||||
"p50_ms": 22.576229095458984,
|
||||
"p95_ms": 32.772239685058594,
|
||||
"p99_ms": 36.67753982543945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22351377572686898,
|
||||
"render": 16.234788014183916,
|
||||
"tick": 1.4387834655649459,
|
||||
"update": 0.14943464463281017
|
||||
},
|
||||
"seconds": 12.028634479522704,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2705736731489499,
|
||||
"render": 32.015567461649574,
|
||||
"tick": 2.741411516830946,
|
||||
"update": 0.3076988315830628
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 60.413862302111134,
|
||||
"cpu": {
|
||||
"avg_ms": 10.874024665643429,
|
||||
"max_ms": 29.430055618286133,
|
||||
"p50_ms": 12.944002151489258,
|
||||
"p95_ms": 17.997587203979492,
|
||||
"p99_ms": 25.568126678466797
|
||||
},
|
||||
"frames": 725,
|
||||
"gpu": {
|
||||
"avg_ms": 12.736994123787715,
|
||||
"max_ms": 28.143625259399414,
|
||||
"p50_ms": 12.234904289245605,
|
||||
"p95_ms": 15.61594295501709,
|
||||
"p99_ms": 24.476089477539062
|
||||
},
|
||||
"low_1pct_fps": 34.82401357005238,
|
||||
"max_ms": 33.36631393432617,
|
||||
"p50_ms": 16.01299476623535,
|
||||
"p95_ms": 18.74709701538086,
|
||||
"p99_ms": 26.6020565032959,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.4042872669162421,
|
||||
"render": 9.401332649341946,
|
||||
"tick": 0.9126873913477977,
|
||||
"update": 0.15663292961387798
|
||||
},
|
||||
"seconds": 12.000557030677795,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3962315060198307,
|
||||
"render": 26.506223678588867,
|
||||
"tick": 0.0020812499860767275,
|
||||
"update": 0.5454612672328949
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 37.77761368324693,
|
||||
"cpu": {
|
||||
"avg_ms": 11.491443304826092,
|
||||
"max_ms": 34.474151611328125,
|
||||
"p50_ms": 13.871685981750488,
|
||||
"p95_ms": 20.351163864135742,
|
||||
"p99_ms": 23.77434539794922
|
||||
},
|
||||
"frames": 453,
|
||||
"gpu": {
|
||||
"avg_ms": 14.109076927302953,
|
||||
"max_ms": 35.115882873535156,
|
||||
"p50_ms": 13.555350303649902,
|
||||
"p95_ms": 18.336809158325195,
|
||||
"p99_ms": 30.865131378173828
|
||||
},
|
||||
"low_1pct_fps": 27.343844611059406,
|
||||
"max_ms": 39.0676155090332,
|
||||
"p50_ms": 33.33863830566406,
|
||||
"p95_ms": 33.36062240600586,
|
||||
"p99_ms": 34.297752380371094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22927819613457778,
|
||||
"render": 9.350584785242217,
|
||||
"tick": 1.7383085893987211,
|
||||
"update": 0.17420387428015405
|
||||
},
|
||||
"seconds": 11.991228556632995,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2908208012580872,
|
||||
"render": 25.648976135253907,
|
||||
"tick": 1.9043863375205548,
|
||||
"update": 0.7624145835638046
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 36807552
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 20.701493975921895,
|
||||
"cpu": {
|
||||
"avg_ms": 31.808356682459515,
|
||||
"max_ms": 74.47195434570312,
|
||||
"p50_ms": 41.86561584472656,
|
||||
"p95_ms": 56.75548553466797,
|
||||
"p99_ms": 73.00084686279297
|
||||
},
|
||||
"frames": 249,
|
||||
"gpu": {
|
||||
"avg_ms": 40.500388481140135,
|
||||
"max_ms": 67.22461700439453,
|
||||
"p50_ms": 40.38219451904297,
|
||||
"p95_ms": 50.01243209838867,
|
||||
"p99_ms": 62.00723648071289
|
||||
},
|
||||
"low_1pct_fps": 8.83914293384798,
|
||||
"max_ms": 133.2972869873047,
|
||||
"p50_ms": 47.37559509277344,
|
||||
"p95_ms": 61.3382682800293,
|
||||
"p99_ms": 74.61792755126953,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.30830518620081215,
|
||||
"render": 25.584145257750667,
|
||||
"tick": 5.116575629957185,
|
||||
"update": 0.8002672145165592
|
||||
},
|
||||
"seconds": 12.028117404937744,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3641819953918457,
|
||||
"render": 44.77838750680288,
|
||||
"tick": 6.285618623097737,
|
||||
"update": 22.415442685286205
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 18.118065350089793,
|
||||
"cpu": {
|
||||
"avg_ms": 36.827518428137545,
|
||||
"max_ms": 85.7597427368164,
|
||||
"p50_ms": 49.536197662353516,
|
||||
"p95_ms": 62.31198501586914,
|
||||
"p99_ms": 66.591064453125
|
||||
},
|
||||
"frames": 218,
|
||||
"gpu": {
|
||||
"avg_ms": 47.82228247616269,
|
||||
"max_ms": 76.9173355102539,
|
||||
"p50_ms": 46.98957824707031,
|
||||
"p95_ms": 57.49673843383789,
|
||||
"p99_ms": 69.92066192626953
|
||||
},
|
||||
"low_1pct_fps": 11.528998473378772,
|
||||
"max_ms": 93.62928009033203,
|
||||
"p50_ms": 53.71598434448242,
|
||||
"p95_ms": 66.69908905029297,
|
||||
"p99_ms": 80.71000671386719,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.29564340877423595,
|
||||
"render": 30.678313442326466,
|
||||
"tick": 4.647872053591402,
|
||||
"update": 1.206680115073099
|
||||
},
|
||||
"seconds": 12.032189739227295,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.37793900569279987,
|
||||
"render": 66.15962092081706,
|
||||
"tick": 5.583541075388591,
|
||||
"update": 1.5741689602533977
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 8.272097120557788,
|
||||
"cpu": {
|
||||
"avg_ms": 96.70907358733974,
|
||||
"max_ms": 208.84027099609375,
|
||||
"p50_ms": 89.04510498046875,
|
||||
"p95_ms": 169.3397979736328,
|
||||
"p99_ms": 208.84027099609375
|
||||
},
|
||||
"frames": 98,
|
||||
"gpu": {
|
||||
"avg_ms": 57.298700176939676,
|
||||
"max_ms": 66.62165832519531,
|
||||
"p50_ms": 57.89077377319336,
|
||||
"p95_ms": 65.73504638671875,
|
||||
"p99_ms": 66.62165832519531
|
||||
},
|
||||
"low_1pct_fps": 4.785729156618201,
|
||||
"max_ms": 208.95457458496094,
|
||||
"p50_ms": 140.0601348876953,
|
||||
"p95_ms": 178.57022094726562,
|
||||
"p99_ms": 208.95457458496094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 3.3946731230434106,
|
||||
"render": 34.994675042982,
|
||||
"tick": 57.14290783599932,
|
||||
"update": 1.177816742536973
|
||||
},
|
||||
"seconds": 11.847056262969971,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 7.7776899337768555,
|
||||
"render": 70.36097717285156,
|
||||
"tick": 128.46734619140625,
|
||||
"update": 2.2351090908050537
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 11.65883600782651,
|
||||
"cpu": {
|
||||
"avg_ms": 56.6912090659987,
|
||||
"max_ms": 107.11215209960938,
|
||||
"p50_ms": 77.04230499267578,
|
||||
"p95_ms": 97.60908508300781,
|
||||
"p99_ms": 104.08527374267578
|
||||
},
|
||||
"frames": 141,
|
||||
"gpu": {
|
||||
"avg_ms": 70.81221792731486,
|
||||
"max_ms": 96.33728790283203,
|
||||
"p50_ms": 69.16046905517578,
|
||||
"p95_ms": 86.61634063720703,
|
||||
"p99_ms": 92.56858825683594
|
||||
},
|
||||
"low_1pct_fps": 4.93309783391495,
|
||||
"max_ms": 216.64273071289062,
|
||||
"p50_ms": 81.59197235107422,
|
||||
"p95_ms": 104.20377349853516,
|
||||
"p99_ms": 188.7820281982422,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.31791428133105554,
|
||||
"render": 45.35119773817401,
|
||||
"tick": 9.391353438086544,
|
||||
"update": 1.6317692605104852
|
||||
},
|
||||
"seconds": 12.093831657409668,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.348672017455101,
|
||||
"render": 86.63534545898438,
|
||||
"tick": 13.065274715423584,
|
||||
"update": 5.5503233671188354
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 119,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 222619264
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 5.310476400246329,
|
||||
"cpu": {
|
||||
"avg_ms": 111.2176975607872,
|
||||
"max_ms": 224.16696166992188,
|
||||
"p50_ms": 76.4469985961914,
|
||||
"p95_ms": 218.82273864746094,
|
||||
"p99_ms": 224.16696166992188
|
||||
},
|
||||
"frames": 64,
|
||||
"gpu": {
|
||||
"avg_ms": 121.35315322875977,
|
||||
"max_ms": 171.11207580566406,
|
||||
"p50_ms": 125.45780944824219,
|
||||
"p95_ms": 166.71322631835938,
|
||||
"p99_ms": 171.11207580566406
|
||||
},
|
||||
"low_1pct_fps": 3.59762228435401,
|
||||
"max_ms": 277.96136474609375,
|
||||
"p50_ms": 192.1078338623047,
|
||||
"p95_ms": 232.86318969726562,
|
||||
"p99_ms": 277.96136474609375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.524940284434706,
|
||||
"render": 56.89460512250662,
|
||||
"tick": 38.0364211499691,
|
||||
"update": 15.762759290635586
|
||||
},
|
||||
"seconds": 12.051649452209473,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5129500031471252,
|
||||
"render": 147.3928985595703,
|
||||
"tick": 62.813438415527344,
|
||||
"update": 13.448556900024414
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 3.899515654388116,
|
||||
"cpu": {
|
||||
"avg_ms": 155.46855975211935,
|
||||
"max_ms": 270.7467041015625,
|
||||
"p50_ms": 83.60679626464844,
|
||||
"p95_ms": 268.6576843261719,
|
||||
"p99_ms": 270.7467041015625
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 192.77972445280656,
|
||||
"max_ms": 225.69723510742188,
|
||||
"p50_ms": 189.4962615966797,
|
||||
"p95_ms": 217.36190795898438,
|
||||
"p99_ms": 225.69723510742188
|
||||
},
|
||||
"low_1pct_fps": 3.3773198460417087,
|
||||
"max_ms": 296.0927734375,
|
||||
"p50_ms": 259.49017333984375,
|
||||
"p95_ms": 283.4007568359375,
|
||||
"p99_ms": 296.0927734375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.5114446409205173,
|
||||
"render": 99.58217374821926,
|
||||
"tick": 43.03971359577585,
|
||||
"update": 12.336258025879555
|
||||
},
|
||||
"seconds": 12.052778900146484,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5626839995384216,
|
||||
"render": 208.71124267578125,
|
||||
"tick": 53.47932052612305,
|
||||
"update": 7.994467258453369
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.9455219461811217,
|
||||
"cpu": {
|
||||
"avg_ms": 287.98993862376494,
|
||||
"max_ms": 464.7646484375,
|
||||
"p50_ms": 286.4447326660156,
|
||||
"p95_ms": 444.6121520996094,
|
||||
"p99_ms": 464.7646484375
|
||||
},
|
||||
"frames": 34,
|
||||
"gpu": {
|
||||
"avg_ms": 116.96576734270369,
|
||||
"max_ms": 158.26394653320312,
|
||||
"p50_ms": 106.45819854736328,
|
||||
"p95_ms": 158.1454620361328,
|
||||
"p99_ms": 158.26394653320312
|
||||
},
|
||||
"low_1pct_fps": 2.151113067092828,
|
||||
"max_ms": 464.8756103515625,
|
||||
"p50_ms": 315.1367492675781,
|
||||
"p95_ms": 456.3304138183594,
|
||||
"p99_ms": 464.8756103515625,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 4.395756518139558,
|
||||
"render": 69.52432030789993,
|
||||
"tick": 206.13154108384077,
|
||||
"update": 7.93927874284632
|
||||
},
|
||||
"seconds": 11.542945739746093,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 4.144052982330322,
|
||||
"render": 166.14959716796875,
|
||||
"tick": 285.5132751464844,
|
||||
"update": 8.958683967590332
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.983176689759444,
|
||||
"cpu": {
|
||||
"avg_ms": 171.41208145466257,
|
||||
"max_ms": 413.8367919921875,
|
||||
"p50_ms": 164.3318328857422,
|
||||
"p95_ms": 360.7731628417969,
|
||||
"p99_ms": 413.8367919921875
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 166.80018520355225,
|
||||
"max_ms": 304.8785400390625,
|
||||
"p50_ms": 143.19728088378906,
|
||||
"p95_ms": 275.3953552246094,
|
||||
"p99_ms": 304.8785400390625
|
||||
},
|
||||
"low_1pct_fps": 2.418344167836982,
|
||||
"max_ms": 413.5060729980469,
|
||||
"p50_ms": 230.7030029296875,
|
||||
"p95_ms": 365.4028625488281,
|
||||
"p99_ms": 413.5060729980469,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.496992789684458,
|
||||
"render": 94.4349257743105,
|
||||
"tick": 61.33542941478973,
|
||||
"update": 15.14569672117842
|
||||
},
|
||||
"seconds": 11.799627197265625,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4748440086841583,
|
||||
"render": 312.3215637207031,
|
||||
"tick": 86.37409973144531,
|
||||
"update": 14.667168617248535
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
],
|
||||
"load_after": [
|
||||
10.98291015625,
|
||||
7.201171875,
|
||||
5.5615234375
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 158,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 1137747200
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 362.94072531330323,
|
||||
"cpu": {
|
||||
"avg_ms": 2.734311161203731,
|
||||
"max_ms": 68.06377410888672,
|
||||
"p50_ms": 1.4820960760116577,
|
||||
"p95_ms": 7.922675609588623,
|
||||
"p99_ms": 35.78989028930664
|
||||
},
|
||||
"frames": 4373,
|
||||
"gpu": {
|
||||
"avg_ms": 0.4188921034758625,
|
||||
"max_ms": 2.5876479148864746,
|
||||
"p50_ms": 0.4106239974498749,
|
||||
"p95_ms": 0.5283839702606201,
|
||||
"p99_ms": 0.5427200198173523
|
||||
},
|
||||
"low_1pct_fps": 21.64332698060583,
|
||||
"max_ms": 68.08219146728516,
|
||||
"p50_ms": 1.4894599914550781,
|
||||
"p95_ms": 7.936410427093506,
|
||||
"p99_ms": 36.54151153564453,
|
||||
"seconds": 12.048799418210983
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 613.1553113846195,
|
||||
"cpu": {
|
||||
"avg_ms": 1.6186122214214547,
|
||||
"max_ms": 216.98960876464844,
|
||||
"p50_ms": 1.10076105594635,
|
||||
"p95_ms": 3.0016868114471436,
|
||||
"p99_ms": 10.144570350646973
|
||||
},
|
||||
"frames": 6746,
|
||||
"gpu": {
|
||||
"avg_ms": 0.395751640401594,
|
||||
"max_ms": 1.99782395362854,
|
||||
"p50_ms": 0.3983359932899475,
|
||||
"p95_ms": 0.4833280146121979,
|
||||
"p99_ms": 0.5375999808311462
|
||||
},
|
||||
"low_1pct_fps": 49.639170314777054,
|
||||
"max_ms": 217.0211181640625,
|
||||
"p50_ms": 1.109616994857788,
|
||||
"p95_ms": 3.0161218643188477,
|
||||
"p99_ms": 10.163111686706543,
|
||||
"seconds": 11.002106439828873
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 323.23791637473414,
|
||||
"cpu": {
|
||||
"avg_ms": 2.7230400277200597,
|
||||
"max_ms": 4748.7744140625,
|
||||
"p50_ms": 0.9233829975128174,
|
||||
"p95_ms": 1.677724003791809,
|
||||
"p99_ms": 7.7397589683532715
|
||||
},
|
||||
"frames": 3870,
|
||||
"gpu": {
|
||||
"avg_ms": 0.24116999250656182,
|
||||
"max_ms": 0.954367995262146,
|
||||
"p50_ms": 0.2754560112953186,
|
||||
"p95_ms": 0.3389439880847931,
|
||||
"p99_ms": 0.3532800078392029
|
||||
},
|
||||
"low_1pct_fps": 5.421154869716093,
|
||||
"max_ms": 4748.80615234375,
|
||||
"p50_ms": 0.9285120368003845,
|
||||
"p95_ms": 1.7079449892044067,
|
||||
"p99_ms": 33.33257293701172,
|
||||
"seconds": 11.97260532861948
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.19080143244723,
|
||||
"cpu": {
|
||||
"avg_ms": 12.358911560308298,
|
||||
"max_ms": 187.3409881591797,
|
||||
"p50_ms": 9.513923645019531,
|
||||
"p95_ms": 25.783824920654297,
|
||||
"p99_ms": 110.71585845947266
|
||||
},
|
||||
"frames": 338,
|
||||
"gpu": {
|
||||
"avg_ms": 0.46002139662497143,
|
||||
"max_ms": 1.5626239776611328,
|
||||
"p50_ms": 0.4546560049057007,
|
||||
"p95_ms": 0.5140479803085327,
|
||||
"p99_ms": 0.5959680080413818
|
||||
},
|
||||
"low_1pct_fps": 7.428141780247131,
|
||||
"max_ms": 187.36033630371094,
|
||||
"p50_ms": 33.334835052490234,
|
||||
"p95_ms": 35.65903854370117,
|
||||
"p99_ms": 110.73509216308594,
|
||||
"seconds": 11.98972653579712
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 54284928
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 7.569165639843409,
|
||||
"cpu": {
|
||||
"avg_ms": 138.25648041658624,
|
||||
"max_ms": 877.6473999023438,
|
||||
"p50_ms": 103.97663879394531,
|
||||
"p95_ms": 318.5262145996094,
|
||||
"p99_ms": 877.6473999023438
|
||||
},
|
||||
"frames": 86,
|
||||
"gpu": {
|
||||
"avg_ms": 3.585262137790059,
|
||||
"max_ms": 8.415231704711914,
|
||||
"p50_ms": 3.299328088760376,
|
||||
"p95_ms": 7.171072006225586,
|
||||
"p99_ms": 8.415231704711914
|
||||
},
|
||||
"low_1pct_fps": 2.44461512120682,
|
||||
"max_ms": 409.0623474121094,
|
||||
"p50_ms": 104.01687622070312,
|
||||
"p95_ms": 318.5569763183594,
|
||||
"p99_ms": 409.0623474121094,
|
||||
"seconds": 11.361886381149292
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.65529078792617,
|
||||
"cpu": {
|
||||
"avg_ms": 37.926895780927815,
|
||||
"max_ms": 3484.08349609375,
|
||||
"p50_ms": 3.398620128631592,
|
||||
"p95_ms": 44.56385040283203,
|
||||
"p99_ms": 684.4393920898438
|
||||
},
|
||||
"frames": 314,
|
||||
"gpu": {
|
||||
"avg_ms": 2.683724634467986,
|
||||
"max_ms": 9.881600379943848,
|
||||
"p50_ms": 1.8923519849777222,
|
||||
"p95_ms": 7.221248149871826,
|
||||
"p99_ms": 9.04089641571045
|
||||
},
|
||||
"low_1pct_fps": 0.4753349245465373,
|
||||
"max_ms": 3484.115234375,
|
||||
"p50_ms": 3.5420219898223877,
|
||||
"p95_ms": 44.591251373291016,
|
||||
"p99_ms": 159.3817901611328,
|
||||
"seconds": 11.354066113710404
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 59.16125632179682,
|
||||
"cpu": {
|
||||
"avg_ms": 16.807428855239674,
|
||||
"max_ms": 106.0348129272461,
|
||||
"p50_ms": 9.335607528686523,
|
||||
"p95_ms": 44.57380294799805,
|
||||
"p99_ms": 84.97154998779297
|
||||
},
|
||||
"frames": 712,
|
||||
"gpu": {
|
||||
"avg_ms": 1.958949391112736,
|
||||
"max_ms": 8.635392189025879,
|
||||
"p50_ms": 1.1182080507278442,
|
||||
"p95_ms": 5.493760108947754,
|
||||
"p99_ms": 7.108607769012451
|
||||
},
|
||||
"low_1pct_fps": 10.542641239411056,
|
||||
"max_ms": 106.06544494628906,
|
||||
"p50_ms": 9.364562034606934,
|
||||
"p95_ms": 45.14366912841797,
|
||||
"p99_ms": 84.9927749633789,
|
||||
"seconds": 12.034903317928315
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.78175419456072,
|
||||
"cpu": {
|
||||
"avg_ms": 20.230309660251326,
|
||||
"max_ms": 128.05508422851562,
|
||||
"p50_ms": 15.369550704956055,
|
||||
"p95_ms": 67.15973663330078,
|
||||
"p99_ms": 104.6961898803711
|
||||
},
|
||||
"frames": 325,
|
||||
"gpu": {
|
||||
"avg_ms": 7.143625645270714,
|
||||
"max_ms": 21.25823974609375,
|
||||
"p50_ms": 7.187456130981445,
|
||||
"p95_ms": 12.228608131408691,
|
||||
"p99_ms": 19.310592651367188
|
||||
},
|
||||
"low_1pct_fps": 7.777799947825389,
|
||||
"max_ms": 155.30685424804688,
|
||||
"p50_ms": 33.335182189941406,
|
||||
"p95_ms": 68.94293975830078,
|
||||
"p99_ms": 109.09304809570312,
|
||||
"seconds": 12.135127431869506
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 4829184
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 4.1477100728464,
|
||||
"cpu": {
|
||||
"avg_ms": 244.17284844846142,
|
||||
"max_ms": 386.7664489746094,
|
||||
"p50_ms": 244.8783416748047,
|
||||
"p95_ms": 363.0765380859375,
|
||||
"p99_ms": 386.7664489746094
|
||||
},
|
||||
"frames": 49,
|
||||
"gpu": {
|
||||
"avg_ms": 10.357822744213806,
|
||||
"max_ms": 23.010303497314453,
|
||||
"p50_ms": 9.227264404296875,
|
||||
"p95_ms": 19.309568405151367,
|
||||
"p99_ms": 23.010303497314453
|
||||
},
|
||||
"low_1pct_fps": 2.5853636290905797,
|
||||
"max_ms": 386.79278564453125,
|
||||
"p50_ms": 230.8652801513672,
|
||||
"p95_ms": 363.0941162109375,
|
||||
"p99_ms": 386.79278564453125,
|
||||
"seconds": 11.813747619628906
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 8.3624969613463,
|
||||
"cpu": {
|
||||
"avg_ms": 118.74922356747165,
|
||||
"max_ms": 278.8187255859375,
|
||||
"p50_ms": 123.03407287597656,
|
||||
"p95_ms": 231.6608123779297,
|
||||
"p99_ms": 260.8450622558594
|
||||
},
|
||||
"frames": 101,
|
||||
"gpu": {
|
||||
"avg_ms": 15.898816661669477,
|
||||
"max_ms": 72.880126953125,
|
||||
"p50_ms": 20.789247512817383,
|
||||
"p95_ms": 28.441600799560547,
|
||||
"p99_ms": 60.816383361816406
|
||||
},
|
||||
"low_1pct_fps": 3.7057290596141406,
|
||||
"max_ms": 278.83917236328125,
|
||||
"p50_ms": 123.19290161132812,
|
||||
"p95_ms": 231.68026733398438,
|
||||
"p99_ms": 260.8656921386719,
|
||||
"seconds": 12.077732340812684
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.3954947141714045,
|
||||
"cpu": {
|
||||
"avg_ms": 424.7621285574777,
|
||||
"max_ms": 625.8532104492188,
|
||||
"p50_ms": 435.64044189453125,
|
||||
"p95_ms": 577.7593994140625,
|
||||
"p99_ms": 625.8532104492188
|
||||
},
|
||||
"frames": 28,
|
||||
"gpu": {
|
||||
"avg_ms": 9.483117733682905,
|
||||
"max_ms": 12.973055839538574,
|
||||
"p50_ms": 9.90719985961914,
|
||||
"p95_ms": 12.610560417175293,
|
||||
"p99_ms": 12.973055839538574
|
||||
},
|
||||
"low_1pct_fps": 1.5977632874291703,
|
||||
"max_ms": 625.8749389648438,
|
||||
"p50_ms": 433.9859619140625,
|
||||
"p95_ms": 577.7803955078125,
|
||||
"p99_ms": 625.8749389648438,
|
||||
"seconds": 11.688608551025391
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.3756775332390268,
|
||||
"cpu": {
|
||||
"avg_ms": 300.512197249975,
|
||||
"max_ms": 407.4363708496094,
|
||||
"p50_ms": 292.483642578125,
|
||||
"p95_ms": 394.8424377441406,
|
||||
"p99_ms": 407.4363708496094
|
||||
},
|
||||
"frames": 39,
|
||||
"gpu": {
|
||||
"avg_ms": 9.728866460995796,
|
||||
"max_ms": 18.930688858032227,
|
||||
"p50_ms": 8.820735931396484,
|
||||
"p95_ms": 17.399808883666992,
|
||||
"p99_ms": 18.930688858032227
|
||||
},
|
||||
"low_1pct_fps": 2.4541792422631716,
|
||||
"max_ms": 407.46820068359375,
|
||||
"p50_ms": 292.4177551269531,
|
||||
"p95_ms": 394.8649597167969,
|
||||
"p99_ms": 407.46820068359375,
|
||||
"seconds": 11.553236236572266
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 146,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1288851072
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 16.38225374384963,
|
||||
"cpu": {
|
||||
"avg_ms": 48.17248754105975,
|
||||
"max_ms": 134.31028747558594,
|
||||
"p50_ms": 44.57072067260742,
|
||||
"p95_ms": 97.15985870361328,
|
||||
"p99_ms": 127.31073760986328
|
||||
},
|
||||
"frames": 199,
|
||||
"gpu": {
|
||||
"avg_ms": 26.732369270324707,
|
||||
"max_ms": 47.3107795715332,
|
||||
"p50_ms": 26.284116744995117,
|
||||
"p95_ms": 35.006874084472656,
|
||||
"p99_ms": 39.38005828857422
|
||||
},
|
||||
"low_1pct_fps": 6.569673692138871,
|
||||
"max_ms": 169.97352600097656,
|
||||
"p50_ms": 56.791988372802734,
|
||||
"p95_ms": 109.15220642089844,
|
||||
"p99_ms": 134.45559692382812,
|
||||
"seconds": 12.147290788650512
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 19.917172468484612,
|
||||
"cpu": {
|
||||
"avg_ms": 31.039839970568817,
|
||||
"max_ms": 106.39506530761719,
|
||||
"p50_ms": 33.26064682006836,
|
||||
"p95_ms": 70.09876251220703,
|
||||
"p99_ms": 86.21255493164062
|
||||
},
|
||||
"frames": 240,
|
||||
"gpu": {
|
||||
"avg_ms": 37.272586759195285,
|
||||
"max_ms": 77.586669921875,
|
||||
"p50_ms": 34.38500213623047,
|
||||
"p95_ms": 59.649635314941406,
|
||||
"p99_ms": 66.52396392822266
|
||||
},
|
||||
"low_1pct_fps": 9.909291086862112,
|
||||
"max_ms": 106.57542419433594,
|
||||
"p50_ms": 46.05862808227539,
|
||||
"p95_ms": 80.38935852050781,
|
||||
"p99_ms": 94.94140625,
|
||||
"seconds": 12.049903186798096
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 27.969763129786234,
|
||||
"cpu": {
|
||||
"avg_ms": 25.69895526429727,
|
||||
"max_ms": 930.7354125976562,
|
||||
"p50_ms": 18.981081008911133,
|
||||
"p95_ms": 39.42117691040039,
|
||||
"p99_ms": 168.07225036621094
|
||||
},
|
||||
"frames": 336,
|
||||
"gpu": {
|
||||
"avg_ms": 18.856842384451912,
|
||||
"max_ms": 57.835872650146484,
|
||||
"p50_ms": 18.172868728637695,
|
||||
"p95_ms": 28.62451171875,
|
||||
"p99_ms": 44.414398193359375
|
||||
},
|
||||
"low_1pct_fps": 1.527565223414601,
|
||||
"max_ms": 930.8734741210938,
|
||||
"p50_ms": 26.45800018310547,
|
||||
"p95_ms": 50.053672790527344,
|
||||
"p99_ms": 180.92478942871094,
|
||||
"seconds": 12.012972667694092
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.5942562164059,
|
||||
"cpu": {
|
||||
"avg_ms": 22.482411369495093,
|
||||
"max_ms": 104.89199829101562,
|
||||
"p50_ms": 22.033512115478516,
|
||||
"p95_ms": 46.385555267333984,
|
||||
"p99_ms": 61.15697479248047
|
||||
},
|
||||
"frames": 320,
|
||||
"gpu": {
|
||||
"avg_ms": 23.762589550018312,
|
||||
"max_ms": 48.10239028930664,
|
||||
"p50_ms": 22.580347061157227,
|
||||
"p95_ms": 35.14606475830078,
|
||||
"p99_ms": 42.47938919067383
|
||||
},
|
||||
"low_1pct_fps": 11.197907022053611,
|
||||
"max_ms": 105.07585906982422,
|
||||
"p50_ms": 33.362815856933594,
|
||||
"p95_ms": 53.36695098876953,
|
||||
"p99_ms": 74.80219268798828,
|
||||
"seconds": 12.03267342376709
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 16106880
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 10.754658718707336,
|
||||
"cpu": {
|
||||
"avg_ms": 69.22208800682655,
|
||||
"max_ms": 193.21267700195312,
|
||||
"p50_ms": 66.95130157470703,
|
||||
"p95_ms": 154.44834899902344,
|
||||
"p99_ms": 191.29208374023438
|
||||
},
|
||||
"frames": 130,
|
||||
"gpu": {
|
||||
"avg_ms": 49.041961078495945,
|
||||
"max_ms": 73.83775329589844,
|
||||
"p50_ms": 48.90928268432617,
|
||||
"p95_ms": 60.60416793823242,
|
||||
"p99_ms": 66.1044921875
|
||||
},
|
||||
"low_1pct_fps": 4.171866301284614,
|
||||
"max_ms": 252.21458435058594,
|
||||
"p50_ms": 79.8940200805664,
|
||||
"p95_ms": 173.05947875976562,
|
||||
"p99_ms": 227.18716430664062,
|
||||
"seconds": 12.087784782409669
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 12.167278778001634,
|
||||
"cpu": {
|
||||
"avg_ms": 58.366991678873696,
|
||||
"max_ms": 156.683349609375,
|
||||
"p50_ms": 69.24014282226562,
|
||||
"p95_ms": 110.83757019042969,
|
||||
"p99_ms": 134.16554260253906
|
||||
},
|
||||
"frames": 147,
|
||||
"gpu": {
|
||||
"avg_ms": 56.45229888606716,
|
||||
"max_ms": 78.26596069335938,
|
||||
"p50_ms": 55.92947006225586,
|
||||
"p95_ms": 63.54412841796875,
|
||||
"p99_ms": 66.5202407836914
|
||||
},
|
||||
"low_1pct_fps": 5.722095670390665,
|
||||
"max_ms": 212.99729919433594,
|
||||
"p50_ms": 77.36986541748047,
|
||||
"p95_ms": 122.84784698486328,
|
||||
"p99_ms": 136.52499389648438,
|
||||
"seconds": 12.081583950042724
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 7.681804845753147,
|
||||
"cpu": {
|
||||
"avg_ms": 98.35419226740743,
|
||||
"max_ms": 385.2493591308594,
|
||||
"p50_ms": 78.85044860839844,
|
||||
"p95_ms": 225.36231994628906,
|
||||
"p99_ms": 385.2493591308594
|
||||
},
|
||||
"frames": 91,
|
||||
"gpu": {
|
||||
"avg_ms": 71.51263585297957,
|
||||
"max_ms": 128.29212951660156,
|
||||
"p50_ms": 67.92412567138672,
|
||||
"p95_ms": 101.93219757080078,
|
||||
"p99_ms": 128.29212951660156
|
||||
},
|
||||
"low_1pct_fps": 2.5948566279945817,
|
||||
"max_ms": 385.37774658203125,
|
||||
"p50_ms": 123.91693878173828,
|
||||
"p95_ms": 225.49420166015625,
|
||||
"p99_ms": 385.37774658203125,
|
||||
"seconds": 11.846174411773681
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 5.886844433101332,
|
||||
"cpu": {
|
||||
"avg_ms": 124.87309415575484,
|
||||
"max_ms": 321.2157897949219,
|
||||
"p50_ms": 117.2811050415039,
|
||||
"p95_ms": 237.34304809570312,
|
||||
"p99_ms": 321.2157897949219
|
||||
},
|
||||
"frames": 71,
|
||||
"gpu": {
|
||||
"avg_ms": 95.46965434816148,
|
||||
"max_ms": 166.9488067626953,
|
||||
"p50_ms": 83.54202270507812,
|
||||
"p95_ms": 160.2403564453125,
|
||||
"p99_ms": 166.9488067626953
|
||||
},
|
||||
"low_1pct_fps": 3.111272818975726,
|
||||
"max_ms": 321.411865234375,
|
||||
"p50_ms": 153.0963134765625,
|
||||
"p95_ms": 275.37579345703125,
|
||||
"p99_ms": 321.411865234375,
|
||||
"seconds": 12.060790939331055
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 19154560
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2.5956714101148957,
|
||||
"cpu": {
|
||||
"avg_ms": 345.63314671670236,
|
||||
"max_ms": 474.2013854980469,
|
||||
"p50_ms": 347.07415771484375,
|
||||
"p95_ms": 468.0090026855469,
|
||||
"p99_ms": 474.2013854980469
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 85.50063645839691,
|
||||
"max_ms": 134.11460876464844,
|
||||
"p50_ms": 82.40946960449219,
|
||||
"p95_ms": 133.71929931640625,
|
||||
"p99_ms": 134.11460876464844
|
||||
},
|
||||
"low_1pct_fps": 2.01406006678458,
|
||||
"max_ms": 496.509521484375,
|
||||
"p50_ms": 390.230224609375,
|
||||
"p95_ms": 493.8070373535156,
|
||||
"p99_ms": 496.509521484375,
|
||||
"seconds": 11.942960067749024
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 2.2283930626963917,
|
||||
"cpu": {
|
||||
"avg_ms": 354.7813931978666,
|
||||
"max_ms": 611.8099975585938,
|
||||
"p50_ms": 293.61376953125,
|
||||
"p95_ms": 601.3502197265625,
|
||||
"p99_ms": 611.8099975585938
|
||||
},
|
||||
"frames": 26,
|
||||
"gpu": {
|
||||
"avg_ms": 201.54420654296874,
|
||||
"max_ms": 357.0310363769531,
|
||||
"p50_ms": 172.65980529785156,
|
||||
"p95_ms": 341.8655700683594,
|
||||
"p99_ms": 357.0310363769531
|
||||
},
|
||||
"low_1pct_fps": 1.52339962842959,
|
||||
"max_ms": 656.4265747070312,
|
||||
"p50_ms": 407.1051330566406,
|
||||
"p95_ms": 611.97119140625,
|
||||
"p99_ms": 656.4265747070312,
|
||||
"seconds": 11.667600494384766
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.6137193379326398,
|
||||
"cpu": {
|
||||
"avg_ms": 330.783615620931,
|
||||
"max_ms": 730.7304077148438,
|
||||
"p50_ms": 296.65692138671875,
|
||||
"p95_ms": 680.8717041015625,
|
||||
"p99_ms": 730.7304077148438
|
||||
},
|
||||
"frames": 30,
|
||||
"gpu": {
|
||||
"avg_ms": 113.80178985595703,
|
||||
"max_ms": 216.57342529296875,
|
||||
"p50_ms": 104.23703002929688,
|
||||
"p95_ms": 193.67672729492188,
|
||||
"p99_ms": 216.57342529296875
|
||||
},
|
||||
"low_1pct_fps": 1.1784160273210693,
|
||||
"max_ms": 848.5967407226562,
|
||||
"p50_ms": 346.120849609375,
|
||||
"p95_ms": 681.0716552734375,
|
||||
"p99_ms": 848.5967407226562,
|
||||
"seconds": 11.477896484375
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 2.72441042065798,
|
||||
"cpu": {
|
||||
"avg_ms": 325.24547207740045,
|
||||
"max_ms": 578.7177124023438,
|
||||
"p50_ms": 295.25457763671875,
|
||||
"p95_ms": 569.8099975585938,
|
||||
"p99_ms": 578.7177124023438
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 104.99177145957947,
|
||||
"max_ms": 163.95635986328125,
|
||||
"p50_ms": 99.96784210205078,
|
||||
"p95_ms": 160.04824829101562,
|
||||
"p99_ms": 163.95635986328125
|
||||
},
|
||||
"low_1pct_fps": 1.7544071931551564,
|
||||
"max_ms": 569.9931030273438,
|
||||
"p50_ms": 378.246337890625,
|
||||
"p95_ms": 555.5369873046875,
|
||||
"p99_ms": 569.9931030273438,
|
||||
"seconds": 11.378608657836914
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
|
||||
@@ -61,10 +61,78 @@ pub struct Heard {
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
cache: Arc<Mutex<SoundCache>>,
|
||||
/// The decoder thread for static sounds, started with the first one.
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
#[derive(Default)]
|
||||
struct SoundCache {
|
||||
entries: HashMap<String, (Samples, u64)>,
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
}
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
impl SoundCache {
|
||||
fn get(&mut self, key: &str) -> Option<Samples> {
|
||||
self.clock += 1;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.1 = self.clock;
|
||||
Some(entry.0.clone())
|
||||
}
|
||||
fn insert(&mut self, key: String, samples: Samples) {
|
||||
self.clock += 1;
|
||||
self.bytes += Self::size(&samples);
|
||||
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
|
||||
self.bytes -= Self::size(&old);
|
||||
}
|
||||
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
|
||||
let Some(oldest) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, used))| *used)
|
||||
.map(|(k, _)| k.clone())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some((gone, _)) = self.entries.remove(&oldest) {
|
||||
self.bytes -= Self::size(&gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
struct Decode {
|
||||
key: String,
|
||||
file: PathBuf,
|
||||
channel: ChannelId,
|
||||
}
|
||||
/// Give a channel waiting on its sound the decoded samples and start it, as
|
||||
/// the buffer future attaching and playing the source. A source stopped or
|
||||
/// released meanwhile stays so.
|
||||
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
&& c.state == State::Initial
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
c.looping |= looping;
|
||||
}
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub engine: SoundEngine,
|
||||
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
/// as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
|
||||
if let Some(samples) = cached {
|
||||
attach(&self.mixer, channel, samples, false);
|
||||
return;
|
||||
}
|
||||
let decoder = self.decoder.get_or_insert_with(|| {
|
||||
let (send, receive) = std::sync::mpsc::channel::<Decode>();
|
||||
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("sound decoder".into())
|
||||
.spawn(move || {
|
||||
for job in receive {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
samples
|
||||
});
|
||||
attach(&mixer, job.channel, samples, false);
|
||||
}
|
||||
})
|
||||
.expect("sound decoder thread");
|
||||
send
|
||||
});
|
||||
let _ = decoder.send(Decode { key, file, channel });
|
||||
}
|
||||
/// `alSourceStop`: any state becomes stopped, an initial source too (a
|
||||
/// sound still decoding then never starts).
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| c.state = State::Stopped);
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// Why the game should not open an output, if it should not:
|
||||
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
|
||||
/// harnesses run the game and must stay off the user's speakers.
|
||||
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
|
||||
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
|
||||
if var("HORIZON_NO_AUDIO").is_some() {
|
||||
return Some("audio off: HORIZON_NO_AUDIO is set".into());
|
||||
}
|
||||
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
|
||||
return None;
|
||||
}
|
||||
let display = var("WAYLAND_DISPLAY")?;
|
||||
Path::new(&display)
|
||||
.components()
|
||||
.any(|c| c.as_os_str() == "vscreen")
|
||||
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
|
||||
}
|
||||
/// An output stream on `device` in its default configuration, whatever its
|
||||
/// sample format: the mixer works in f32 and converts.
|
||||
fn open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
scratch.resize(data.len(), 0.0f32);
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, &mut scratch, channels, rate);
|
||||
} else {
|
||||
scratch.fill(0.0);
|
||||
}
|
||||
for (out, s) in data.iter_mut().zip(&scratch) {
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
@@ -347,7 +486,7 @@ impl Audio {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
eprintln!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -360,7 +499,8 @@ impl Audio {
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
@@ -374,32 +514,40 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or("no audio output device")?;
|
||||
let config = device.default_output_config()?;
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
eprintln!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
return Err(format!("no audio output device on the {} host", host.id().name()).into());
|
||||
}
|
||||
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
|
||||
}
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
@@ -525,6 +673,21 @@ impl Audio {
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
/// The engine's table sizes and the mixer's channels (`status`).
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
let mut census = self.engine.census();
|
||||
census["channels"] = self
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.map_or(0, |m| m.channels.len())
|
||||
.into();
|
||||
if let Ok(cache) = self.backend.cache.lock() {
|
||||
census["decoded"] = cache.entries.len().into();
|
||||
census["decoded_mib"] = (cache.bytes >> 20).into();
|
||||
}
|
||||
census
|
||||
}
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
@@ -590,6 +753,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
assert!(cache.get("a").is_some());
|
||||
cache.insert("e".into(), sound());
|
||||
assert!(cache.bytes <= CACHE_BYTES);
|
||||
assert!(
|
||||
cache.get("b").is_none(),
|
||||
"the least recently used goes first"
|
||||
);
|
||||
assert!(cache.get("a").is_some() && cache.get("e").is_some());
|
||||
// A failed load is remembered without counting.
|
||||
cache.insert("missing".into(), None);
|
||||
assert_eq!(cache.get("missing"), Some(None));
|
||||
}
|
||||
#[test]
|
||||
fn output_stays_off_on_a_virtual_screen_unless_asked() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
|
||||
assert!(output_disabled(env(&[])).is_none());
|
||||
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
|
||||
assert!(
|
||||
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
|
||||
);
|
||||
assert!(
|
||||
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
|
||||
@@ -140,10 +140,37 @@ fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder.
|
||||
/// Frame durations since the last reset, summarized like the Java recorder,
|
||||
/// with the CPU part of each frame and GPU times from timestamp queries.
|
||||
#[derive(Default)]
|
||||
pub struct FrameStats {
|
||||
millis: Vec<f32>,
|
||||
cpu: Vec<f32>,
|
||||
gpu: Vec<f32>,
|
||||
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
|
||||
phases: Vec<[f32; PHASES.len()]>,
|
||||
}
|
||||
|
||||
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
|
||||
/// building the frame's geometry and HUD, and the renderer's own work.
|
||||
pub const PHASES: [&str; 4] = ["update", "tick", "prepare", "render"];
|
||||
|
||||
fn percentiles(values: &[f32]) -> serde_json::Value {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
let n = sorted.len();
|
||||
if n == 0 {
|
||||
return serde_json::Value::Null;
|
||||
}
|
||||
let total: f64 = sorted.iter().map(|&m| f64::from(m)).sum();
|
||||
let percentile = |p: f64| sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
|
||||
serde_json::json!({
|
||||
"avg_ms": total / n as f64,
|
||||
"p50_ms": percentile(0.5),
|
||||
"p95_ms": percentile(0.95),
|
||||
"p99_ms": percentile(0.99),
|
||||
"max_ms": sorted[n - 1],
|
||||
})
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
@@ -153,8 +180,48 @@ impl FrameStats {
|
||||
self.millis.push(millis);
|
||||
}
|
||||
}
|
||||
/// The frame's own work: its duration minus waits for the GPU, the
|
||||
/// display and the frame limiter.
|
||||
pub fn record_cpu(&mut self, millis: f32) {
|
||||
if self.cpu.len() < Self::LIMIT {
|
||||
self.cpu.push(millis);
|
||||
}
|
||||
}
|
||||
pub fn record_phases(&mut self, millis: [f32; PHASES.len()]) {
|
||||
if self.phases.len() < Self::LIMIT {
|
||||
self.phases.push(millis);
|
||||
}
|
||||
}
|
||||
/// Average milliseconds per phase over the frames whose CPU time is at
|
||||
/// least `min_cpu` (all frames for 0): where the slow frames go.
|
||||
fn phase_average(&self, min_cpu: f32) -> serde_json::Value {
|
||||
let mut sums = [0.0f64; PHASES.len()];
|
||||
let mut n = 0;
|
||||
for (i, phases) in self.phases.iter().enumerate() {
|
||||
if self.cpu.get(i).is_some_and(|&c| c >= min_cpu) {
|
||||
for (sum, m) in sums.iter_mut().zip(phases) {
|
||||
*sum += f64::from(*m);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
PHASES
|
||||
.iter()
|
||||
.zip(sums)
|
||||
.map(|(name, sum)| (name.to_string(), (sum / f64::from(n.max(1))).into()))
|
||||
.collect::<serde_json::Map<_, _>>()
|
||||
.into()
|
||||
}
|
||||
pub fn record_gpu(&mut self, millis: &[f32]) {
|
||||
if self.gpu.len() < Self::LIMIT {
|
||||
self.gpu.extend_from_slice(millis);
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.millis.clear();
|
||||
self.cpu.clear();
|
||||
self.gpu.clear();
|
||||
self.phases.clear();
|
||||
}
|
||||
pub fn summary(&self) -> serde_json::Value {
|
||||
let mut sorted = self.millis.clone();
|
||||
@@ -172,6 +239,15 @@ impl FrameStats {
|
||||
.map(|&m| f64::from(m))
|
||||
.sum::<f64>()
|
||||
/ slowest as f64;
|
||||
// Where the slowest 1% of frames by CPU time go.
|
||||
let slow_phases = {
|
||||
let mut cpu = self.cpu.clone();
|
||||
cpu.sort_by(f32::total_cmp);
|
||||
let p99 = cpu
|
||||
.get(cpu.len().saturating_sub(cpu.len().div_ceil(100)))
|
||||
.copied();
|
||||
p99.map_or(serde_json::Value::Null, |p| self.phase_average(p))
|
||||
};
|
||||
serde_json::json!({
|
||||
"frames": n,
|
||||
"seconds": total / 1000.0,
|
||||
@@ -181,6 +257,10 @@ impl FrameStats {
|
||||
"p95_ms": percentile(0.95),
|
||||
"p99_ms": percentile(0.99),
|
||||
"max_ms": sorted[n - 1],
|
||||
"cpu": percentiles(&self.cpu),
|
||||
"gpu": percentiles(&self.gpu),
|
||||
"phases_avg_ms": self.phase_average(0.0),
|
||||
"slow_phases_avg_ms": slow_phases,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
//! The front-end screens in Horizon's menu style (`ui_theme`), laid out like
|
||||
//! the launcher: a 200-wide sidebar with the sliding selection (Home,
|
||||
//! Singleplayer, Multiplayer, Options), content pages with a page header,
|
||||
//! settings rows and 1px dividers, and the launcher's opening (the wordmark,
|
||||
//! then the interface). They drive the same state as the classic layouts in
|
||||
//! `title` and `multiplayer`; only drawing and hit testing differ. The pause
|
||||
//! menu's Horizon layout is `pause`, below.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
multiplayer::{Outcome, Ping, Screen},
|
||||
title::{Action, Mode, Title, list_worlds},
|
||||
ui_theme::{
|
||||
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
|
||||
},
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
const NAV: u64 = id("title.nav", 0);
|
||||
const QUIT: u64 = id("title.quit", 0);
|
||||
const PLAY: u64 = id("home.play", 0);
|
||||
const MULTIPLAYER: u64 = id("home.multiplayer", 0);
|
||||
const RESUME: u64 = id("home.resume", 0);
|
||||
const BACK: u64 = id("front.back", 0);
|
||||
const PLAY_WORLD: u64 = id("worlds.play", 0);
|
||||
const CREATE: u64 = id("worlds.create", 0);
|
||||
const WORLD: u64 = id("worlds.row", 0);
|
||||
const NAME: u64 = id("create.name", 0);
|
||||
const SEED: u64 = id("create.seed", 0);
|
||||
const MODE: u64 = id("create.mode", 0);
|
||||
const MAKE: u64 = id("create.make", 0);
|
||||
const SERVER: u64 = id("mp.row", 0);
|
||||
const JOIN: u64 = id("mp.join", 0);
|
||||
const DIRECT: u64 = id("mp.direct", 0);
|
||||
const ADD: u64 = id("mp.add", 0);
|
||||
const EDIT: u64 = id("mp.edit", 0);
|
||||
const DELETE: u64 = id("mp.delete", 0);
|
||||
const REFRESH: u64 = id("mp.refresh", 0);
|
||||
const FIELD_A: u64 = id("mp.field", 0);
|
||||
const FIELD_B: u64 = id("mp.field", 1);
|
||||
const DONE: u64 = id("mp.done", 0);
|
||||
|
||||
/// The launcher's opening (`launcher/src/main.rs`): the wordmark holds over
|
||||
/// the backdrop, then the interface comes in. A click or a key skips ahead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Opening {
|
||||
started: Instant,
|
||||
reveal: Option<Instant>,
|
||||
}
|
||||
impl Opening {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
started: Instant::now(),
|
||||
reveal: None,
|
||||
}
|
||||
}
|
||||
/// The reveal's progress, 0 to 1; `ready` says the backdrop is showing.
|
||||
fn phase(&mut self, ready: bool) -> f32 {
|
||||
let held = self.started.elapsed();
|
||||
if self.reveal.is_none()
|
||||
&& ((held >= horizon_theme::OPENING_HOLD && ready)
|
||||
|| held >= horizon_theme::OPENING_MAX)
|
||||
{
|
||||
self.reveal = Some(Instant::now());
|
||||
}
|
||||
self.reveal.map_or(0.0, |at| {
|
||||
at.elapsed().as_secs_f32() / horizon_theme::OPENING_REVEAL.as_secs_f32()
|
||||
})
|
||||
}
|
||||
pub fn skip(&mut self) {
|
||||
self.reveal.get_or_insert_with(Instant::now);
|
||||
}
|
||||
}
|
||||
impl Default for Opening {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sidebar item a mode belongs to.
|
||||
fn page(mode: &Mode) -> usize {
|
||||
match mode {
|
||||
Mode::Main => 0,
|
||||
Mode::Select { .. } | Mode::Create { .. } => 1,
|
||||
Mode::Multiplayer(_) => 2,
|
||||
}
|
||||
}
|
||||
const PAGES: [&str; 4] = ["Home", "Singleplayer", "Multiplayer", "Options"];
|
||||
|
||||
impl Title {
|
||||
/// Horizon's layout for every title mode. `backdrop` is how much of the
|
||||
/// live backdrop is visible (0 shows the plain cold background).
|
||||
pub fn draw_horizon(&self, ui: &mut Ui, backdrop: f32) {
|
||||
let reveal = self.opening_phase(backdrop >= 1.0);
|
||||
if backdrop < 1.0 {
|
||||
ui.cold_background(1.0 - backdrop);
|
||||
}
|
||||
let shade = t::ease_out_cubic(reveal);
|
||||
let veil = if matches!(self.mode, Mode::Main) {
|
||||
0.42
|
||||
} else {
|
||||
0.66
|
||||
};
|
||||
ui.atmosphere(veil * shade, shade);
|
||||
let side = t::ease_out_cubic((reveal - 0.2) / 0.7);
|
||||
let main = t::ease_out_cubic((reveal - 0.35) / 0.65);
|
||||
|
||||
// The sidebar slides in from the left.
|
||||
ui.opacity = side;
|
||||
ui.offset = (-16.0 * (1.0 - side), 0.0);
|
||||
if side > 0.0 {
|
||||
self.sidebar(ui);
|
||||
}
|
||||
// The content rises; each new page fades in and rises 8 over MOVE.
|
||||
let entered = self.entered();
|
||||
let p = t::ease_out_quint(entered.elapsed().as_secs_f32() / t::move_());
|
||||
ui.opacity = main * p;
|
||||
ui.offset = (0.0, 16.0 * (1.0 - main) + 8.0 * (1.0 - p));
|
||||
if main > 0.0 {
|
||||
let x = SIDEBAR + 48.0;
|
||||
let w = (ui.w - x - 48.0).min(760.0);
|
||||
match &self.mode {
|
||||
Mode::Main => self.home(ui, x, w),
|
||||
Mode::Select { worlds, selected } => worlds_page(ui, x, w, worlds, *selected),
|
||||
Mode::Create {
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
focus,
|
||||
} => {
|
||||
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
|
||||
create_page(ui, x, w, name, seed, *creative, *focus, caret);
|
||||
}
|
||||
Mode::Multiplayer(screen) => {
|
||||
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
|
||||
multiplayer_page(ui, x, w, screen, caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.opacity = 1.0;
|
||||
ui.offset = (0.0, 0.0);
|
||||
if reveal < 1.0 {
|
||||
// The wordmark rises and fades as the interface comes in.
|
||||
let word = 1.0 - t::ease_out_cubic(reveal / 0.6);
|
||||
ui.opacity = word;
|
||||
let tw = ui.measure(Face::Display, t::WORDMARK, "Horizon");
|
||||
let line = (t::WORDMARK * 1.2).round();
|
||||
ui.text_line(
|
||||
Face::Display,
|
||||
t::WORDMARK,
|
||||
line,
|
||||
((ui.w - tw) / 2.0).round(),
|
||||
(ui.h - line) / 2.0 - 28.0 * (1.0 - word),
|
||||
color::WHITE,
|
||||
"Horizon",
|
||||
);
|
||||
ui.opacity = 1.0;
|
||||
}
|
||||
}
|
||||
fn opening_phase(&self, ready: bool) -> f32 {
|
||||
let mut opening = self.opening.get();
|
||||
let phase = opening.as_mut().map_or(1.0, |o| o.phase(ready));
|
||||
self.opening.set(opening.filter(|_| phase < 1.0));
|
||||
phase.min(1.0)
|
||||
}
|
||||
/// A click or key during the opening jumps to the reveal; true if it did.
|
||||
pub fn skip_opening(&self) -> bool {
|
||||
let mut opening = self.opening.get();
|
||||
let skipped = opening.as_mut().is_some_and(|o| {
|
||||
let holding = o.reveal.is_none();
|
||||
o.skip();
|
||||
holding
|
||||
});
|
||||
self.opening.set(opening);
|
||||
skipped
|
||||
}
|
||||
fn sidebar(&self, ui: &mut Ui) {
|
||||
let h = ui.h;
|
||||
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
|
||||
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
|
||||
ui.text_line(
|
||||
Face::Display,
|
||||
22.0,
|
||||
30.0,
|
||||
22.0,
|
||||
16.0,
|
||||
color::TEXT,
|
||||
"Horizon",
|
||||
);
|
||||
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &PAGES, page(&self.mode));
|
||||
let qy = h - 12.0 - 18.0 - 12.0 - CONTROL;
|
||||
let hover = ui.interact(QUIT, 12.0, qy, SIDEBAR - 24.0, CONTROL, true);
|
||||
ui.rounded(
|
||||
12.0,
|
||||
qy,
|
||||
SIDEBAR - 24.0,
|
||||
CONTROL,
|
||||
t::RADIUS,
|
||||
color::with_alpha(color::HOVER, 0.7 * hover),
|
||||
);
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
BODY,
|
||||
CONTROL,
|
||||
22.0,
|
||||
qy,
|
||||
color::mix(color::MUTED, color::TEXT, hover),
|
||||
"Quit game",
|
||||
);
|
||||
ui.text(
|
||||
Face::Regular,
|
||||
SMALL,
|
||||
22.0,
|
||||
h - 12.0 - 18.0,
|
||||
color::FAINT,
|
||||
"Horizon 26.3",
|
||||
);
|
||||
}
|
||||
/// Home: the launcher's Play page over the backdrop.
|
||||
fn home(&self, ui: &mut Ui, x: f32, w: f32) {
|
||||
let mut y = 36.0 + 28.0;
|
||||
ui.text(
|
||||
Face::Medium,
|
||||
SMALL,
|
||||
x,
|
||||
y,
|
||||
color::FAINT,
|
||||
"Minecraft 26.3 · Java Edition, rewritten in Rust",
|
||||
);
|
||||
y += 22.0;
|
||||
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, "Horizon");
|
||||
y += 58.0 + 12.0;
|
||||
ui.text(
|
||||
Face::Regular,
|
||||
t::LARGE,
|
||||
x,
|
||||
y,
|
||||
color::MUTED,
|
||||
"The world you know, rebuilt from the ground up.",
|
||||
);
|
||||
y += 22.0 + 24.0;
|
||||
let pw = ui.button_width("Singleplayer", true).max(120.0);
|
||||
ui.button(
|
||||
PLAY,
|
||||
[x, y, pw, CONTROL_LARGE],
|
||||
"Singleplayer",
|
||||
Kind::Primary,
|
||||
true,
|
||||
);
|
||||
let mw = ui.button_width("Multiplayer", true);
|
||||
ui.button(
|
||||
MULTIPLAYER,
|
||||
[x + pw + 8.0, y, mw, CONTROL_LARGE],
|
||||
"Multiplayer",
|
||||
Kind::Secondary,
|
||||
true,
|
||||
);
|
||||
y += CONTROL_LARGE + 24.0;
|
||||
ui.divider(x, y, w);
|
||||
let worlds = self.recent_worlds();
|
||||
let last = worlds.first();
|
||||
let value = last.map_or("None yet".to_owned(), |l| {
|
||||
let secs = l
|
||||
.modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
format!("{} · {}", l.name, crate::title::date_label(secs))
|
||||
});
|
||||
let rh = ui.row(x, y, "Last played", &value, w - t::ROW_LABEL - 120.0);
|
||||
if last.is_some() {
|
||||
let bw = ui.button_width("Resume", false);
|
||||
ui.button(
|
||||
RESUME,
|
||||
[x + w - bw, y + (rh - CONTROL) / 2.0, bw, CONTROL],
|
||||
"Resume",
|
||||
Kind::Secondary,
|
||||
true,
|
||||
);
|
||||
}
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
let count = match worlds.len() {
|
||||
1 => "1 world".to_owned(),
|
||||
n => format!("{n} worlds"),
|
||||
};
|
||||
y += ui.row(x, y, "Saves", &count, w - t::ROW_LABEL - 16.0);
|
||||
ui.divider(x, y, w);
|
||||
let note = "Not affiliated with Mojang or Microsoft";
|
||||
ui.text(
|
||||
Face::Regular,
|
||||
SMALL,
|
||||
x,
|
||||
ui.h - 12.0 - 18.0,
|
||||
color::FAINT,
|
||||
note,
|
||||
);
|
||||
}
|
||||
/// A click in Horizon's layout (`x`, `y` in GUI pixels).
|
||||
pub fn click_horizon(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Action {
|
||||
if self.skip_opening() {
|
||||
return Action::None;
|
||||
}
|
||||
let hit = {
|
||||
let mut ui = Ui::dry(atlas, batch, (x, y));
|
||||
self.draw_horizon(&mut ui, 1.0);
|
||||
ui.hit((x, y))
|
||||
};
|
||||
let Some(hit) = hit else {
|
||||
if let Mode::Create { focus, .. } = &mut self.mode {
|
||||
*focus = 2;
|
||||
}
|
||||
return Action::None;
|
||||
};
|
||||
let saves = self.saves.clone();
|
||||
let select = |saves: &std::path::Path| Mode::Select {
|
||||
worlds: list_worlds(saves),
|
||||
selected: None,
|
||||
};
|
||||
match hit {
|
||||
QUIT => return Action::Quit,
|
||||
h if h == NAV => {
|
||||
self.mode = Mode::Main;
|
||||
return Action::None;
|
||||
}
|
||||
h if h == NAV + 1 => {
|
||||
if !matches!(self.mode, Mode::Select { .. }) {
|
||||
self.mode = select(&saves);
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
h if h == NAV + 2 => {
|
||||
if !matches!(self.mode, Mode::Multiplayer(_)) {
|
||||
self.mode = Mode::Multiplayer(Screen::list(&saves));
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
h if h == NAV + 3 => return Action::Options,
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.mode {
|
||||
Mode::Main => match hit {
|
||||
PLAY => self.mode = select(&saves),
|
||||
MULTIPLAYER => self.mode = Mode::Multiplayer(Screen::list(&saves)),
|
||||
RESUME => {
|
||||
if let Some(w) = list_worlds(&saves).into_iter().next() {
|
||||
return Action::Play { folder: w.folder };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Mode::Select { worlds, selected } => match hit {
|
||||
PLAY_WORLD => {
|
||||
if let Some(w) = selected.and_then(|i| worlds.get(i)) {
|
||||
return Action::Play {
|
||||
folder: w.folder.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
CREATE => {
|
||||
self.mode = Mode::Create {
|
||||
name: "New World".into(),
|
||||
seed: String::new(),
|
||||
creative: false,
|
||||
focus: 0,
|
||||
}
|
||||
}
|
||||
h if (WORLD..WORLD + 10_000).contains(&h) => {
|
||||
let i = (h - WORLD) as usize;
|
||||
if *selected == Some(i) {
|
||||
return Action::Play {
|
||||
folder: worlds[i].folder.clone(),
|
||||
};
|
||||
}
|
||||
*selected = Some(i);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Mode::Create {
|
||||
creative, focus, ..
|
||||
} => match hit {
|
||||
BACK => self.mode = select(&saves),
|
||||
NAME => *focus = 0,
|
||||
SEED => *focus = 1,
|
||||
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
|
||||
MAKE => return self.create_action(),
|
||||
_ => {}
|
||||
},
|
||||
Mode::Multiplayer(screen) => {
|
||||
let id = match hit {
|
||||
JOIN => Some(40),
|
||||
DIRECT => Some(41),
|
||||
ADD => Some(42),
|
||||
EDIT => Some(43),
|
||||
DELETE => Some(44),
|
||||
REFRESH => Some(45),
|
||||
BACK => Some(match screen {
|
||||
Screen::List { .. } => 46,
|
||||
Screen::Edit { .. } => 51,
|
||||
Screen::Direct { .. } => 53,
|
||||
Screen::Disconnected { .. } => 54,
|
||||
}),
|
||||
DONE => Some(match screen {
|
||||
Screen::Direct { .. } => 52,
|
||||
_ => 50,
|
||||
}),
|
||||
FIELD_A | FIELD_B => {
|
||||
if let Screen::Edit { focus, .. } = screen {
|
||||
*focus = u8::from(hit == FIELD_B);
|
||||
}
|
||||
None
|
||||
}
|
||||
h if (SERVER..SERVER + 10_000).contains(&h) => {
|
||||
let i = (h - SERVER) as usize;
|
||||
let again =
|
||||
matches!(screen, Screen::List { selected, .. } if *selected == Some(i));
|
||||
if again {
|
||||
if let Some(address) = screen.address_of(i) {
|
||||
return Action::Connect { address };
|
||||
}
|
||||
} else if let Screen::List { selected, .. } = screen {
|
||||
*selected = Some(i);
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
|
||||
Outcome::Back => self.mode = Mode::Main,
|
||||
Outcome::Connect(address) => return Action::Connect { address },
|
||||
Outcome::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
|
||||
/// The page header plus a row of actions under it; returns the y below.
|
||||
fn header(ui: &mut Ui, x: f32, title: &str, description: &str) -> f32 {
|
||||
let y = 36.0;
|
||||
y + ui.page_header(x, y, title, description) + 24.0
|
||||
}
|
||||
/// Actions laid out right to left from `right`; returns the left edge.
|
||||
fn actions_right(ui: &mut Ui, right: f32, y: f32, items: &[(u64, &str, Kind, bool)]) -> f32 {
|
||||
let mut rx = right;
|
||||
for &(bid, label, kind, on) in items {
|
||||
let large = kind == Kind::Primary;
|
||||
let (bw, bh) = if large {
|
||||
(ui.button_width(label, true).max(120.0), CONTROL_LARGE)
|
||||
} else {
|
||||
(ui.button_width(label, false), CONTROL)
|
||||
};
|
||||
rx -= bw;
|
||||
ui.button(
|
||||
bid,
|
||||
[rx, y + (CONTROL_LARGE - bh) / 2.0, bw, bh],
|
||||
label,
|
||||
kind,
|
||||
on,
|
||||
);
|
||||
rx -= 8.0;
|
||||
}
|
||||
rx
|
||||
}
|
||||
/// A selectable list row: the launcher's row look with a hover layer and
|
||||
/// the raised selection with its 1px border. Returns the row height.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn list_row(
|
||||
ui: &mut Ui,
|
||||
rid: u64,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
selected: bool,
|
||||
title: &str,
|
||||
meta: &str,
|
||||
right: &str,
|
||||
dot: Option<u32>,
|
||||
) -> f32 {
|
||||
let h = 60.0;
|
||||
let hover = ui.interact(rid, x - 12.0, y + 2.0, w + 24.0, h - 4.0, true);
|
||||
let sel = ui.tween(rid ^ 0x5e1ec7, if selected { 1.0 } else { 0.0 }, t::quick());
|
||||
ui.rounded(
|
||||
x - 12.0,
|
||||
y + 4.0,
|
||||
w + 24.0,
|
||||
h - 8.0,
|
||||
t::RADIUS,
|
||||
color::with_alpha(color::HOVER, 0.7 * hover),
|
||||
);
|
||||
ui.rounded(
|
||||
x - 12.0,
|
||||
y + 4.0,
|
||||
w + 24.0,
|
||||
h - 8.0,
|
||||
t::RADIUS,
|
||||
color::with_alpha(color::RAISED, sel),
|
||||
);
|
||||
ui.border(
|
||||
x - 12.0,
|
||||
y + 4.0,
|
||||
w + 24.0,
|
||||
h - 8.0,
|
||||
t::RADIUS,
|
||||
color::with_alpha(color::BORDER, sel),
|
||||
);
|
||||
let mut tx = x;
|
||||
if let Some(dot) = dot {
|
||||
ui.rounded(x, y + 17.0, 6.0, 6.0, 3.0, dot);
|
||||
tx += 16.0;
|
||||
}
|
||||
let title = ui.fit(Face::Medium, BODY, title, w - 200.0);
|
||||
ui.text(Face::Medium, BODY, tx, y + 10.0, color::TEXT, &title);
|
||||
let meta = ui.fit(Face::Regular, SMALL, meta, w - 200.0);
|
||||
ui.text(Face::Regular, SMALL, tx, y + 31.0, color::MUTED, &meta);
|
||||
ui.text_right(Face::Regular, SMALL, x + w, y + 21.0, color::FAINT, right);
|
||||
ui.divider(x, y + h, w);
|
||||
h
|
||||
}
|
||||
/// An empty list: a serif line and a hint.
|
||||
fn empty(ui: &mut Ui, x: f32, y: f32, title: &str, hint: &str) {
|
||||
ui.text_line(Face::Display, 22.0, 30.0, x, y + 32.0, color::TEXT, title);
|
||||
ui.text(Face::Regular, BODY, x, y + 66.0, color::MUTED, hint);
|
||||
}
|
||||
|
||||
fn worlds_page(
|
||||
ui: &mut Ui,
|
||||
x: f32,
|
||||
w: f32,
|
||||
worlds: &[crate::title::WorldEntry],
|
||||
selected: Option<usize>,
|
||||
) {
|
||||
let count = match worlds.len() {
|
||||
0 => "No worlds yet. They live in the saves folder.".to_owned(),
|
||||
1 => "1 world in the saves folder.".to_owned(),
|
||||
n => format!("{n} worlds in the saves folder."),
|
||||
};
|
||||
let mut y = header(ui, x, "Singleplayer", &count);
|
||||
actions_right(
|
||||
ui,
|
||||
x + w,
|
||||
y - 24.0 - 58.0,
|
||||
&[
|
||||
(PLAY_WORLD, "Play", Kind::Primary, selected.is_some()),
|
||||
(CREATE, "Create world", Kind::Secondary, true),
|
||||
],
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
if worlds.is_empty() {
|
||||
empty(ui, x, y, "A blank map.", "Create a world to begin.");
|
||||
return;
|
||||
}
|
||||
let bottom = ui.h - 36.0;
|
||||
for (i, world) in worlds.iter().enumerate() {
|
||||
if y + 60.0 > bottom {
|
||||
break;
|
||||
}
|
||||
let secs = world
|
||||
.modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let meta = format!("{} · {}", crate::title::date_label(secs), world.folder);
|
||||
let mode = if world.creative {
|
||||
"Creative"
|
||||
} else {
|
||||
"Survival"
|
||||
};
|
||||
y += list_row(
|
||||
ui,
|
||||
WORLD + i as u64,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
selected == Some(i),
|
||||
&world.name,
|
||||
&meta,
|
||||
mode,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_page(
|
||||
ui: &mut Ui,
|
||||
x: f32,
|
||||
w: f32,
|
||||
name: &str,
|
||||
seed: &str,
|
||||
creative: bool,
|
||||
focus: u8,
|
||||
caret: bool,
|
||||
) {
|
||||
let mut y = header(
|
||||
ui,
|
||||
x,
|
||||
"Create world",
|
||||
"Generated by Horizon from the seed, as vanilla would.",
|
||||
);
|
||||
actions_right(
|
||||
ui,
|
||||
x + w,
|
||||
y - 24.0 - 58.0,
|
||||
&[
|
||||
(MAKE, "Create", Kind::Primary, true),
|
||||
(BACK, "Cancel", Kind::Ghost, true),
|
||||
],
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
let fx = x + t::ROW_LABEL + 16.0;
|
||||
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
|
||||
for (i, label) in ["World name", "Seed", "Game mode"].iter().enumerate() {
|
||||
let rh = 12.0 + CONTROL + 12.0;
|
||||
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
|
||||
let r = [fx, y + 12.0, fw, CONTROL];
|
||||
match i {
|
||||
0 => ui.field(NAME, r, name, "New World", focus == 0, caret),
|
||||
1 => ui.field(SEED, r, seed, "Random", focus == 1, caret),
|
||||
_ => {
|
||||
ui.segmented(
|
||||
MODE,
|
||||
fx,
|
||||
y + 12.0,
|
||||
&["Survival", "Creative"],
|
||||
usize::from(creative),
|
||||
);
|
||||
}
|
||||
}
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
}
|
||||
}
|
||||
|
||||
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
|
||||
match screen {
|
||||
Screen::List {
|
||||
servers,
|
||||
selected,
|
||||
pings,
|
||||
lan,
|
||||
} => {
|
||||
let games = lan.games();
|
||||
let sub = match servers.len() {
|
||||
0 => "No saved servers. Worlds opened to LAN show up here too.".to_owned(),
|
||||
1 => "1 saved server, and worlds opened to LAN.".to_owned(),
|
||||
n => format!("{n} saved servers, and worlds opened to LAN."),
|
||||
};
|
||||
let mut y = header(ui, x, "Multiplayer", &sub);
|
||||
let has = selected.is_some();
|
||||
let saved = selected.is_some_and(|i| i < servers.len());
|
||||
let mut items = vec![
|
||||
(JOIN, "Join", Kind::Primary, has),
|
||||
(ADD, "Add server", Kind::Secondary, true),
|
||||
(DIRECT, "Direct connect", Kind::Secondary, true),
|
||||
(REFRESH, "Refresh", Kind::Ghost, true),
|
||||
];
|
||||
if saved {
|
||||
items.push((EDIT, "Edit", Kind::Ghost, true));
|
||||
items.push((DELETE, "Delete", Kind::Danger, true));
|
||||
}
|
||||
actions_right(ui, x + w, y - 24.0 - 58.0, &items);
|
||||
ui.divider(x, y, w);
|
||||
let pings = pings.lock().map(|p| p.clone()).unwrap_or_default();
|
||||
let mut rows: Vec<(String, String, String, u32)> = servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (line, right, dot) = match pings.get(&s.ip) {
|
||||
Some(Ping::Ok {
|
||||
motd, players, ms, ..
|
||||
}) => (
|
||||
motd.lines().next().unwrap_or("").trim().to_owned(),
|
||||
format!("{players} · {ms} ms"),
|
||||
color::ACCENT,
|
||||
),
|
||||
Some(Ping::Failed(_)) => (
|
||||
"Can't reach the server".to_owned(),
|
||||
String::new(),
|
||||
color::DANGER,
|
||||
),
|
||||
_ => ("Pinging…".to_owned(), String::new(), color::FAINT),
|
||||
};
|
||||
(s.name.clone(), format!("{line} · {}", s.ip), right, dot)
|
||||
})
|
||||
.collect();
|
||||
rows.extend(games.iter().map(|g| {
|
||||
(
|
||||
"LAN world".to_owned(),
|
||||
format!("{} · {}", g.motd, g.address),
|
||||
"On your network".to_owned(),
|
||||
color::ACCENT,
|
||||
)
|
||||
}));
|
||||
if rows.is_empty() {
|
||||
empty(
|
||||
ui,
|
||||
x,
|
||||
y,
|
||||
"Nobody here yet.",
|
||||
"Add a server, connect directly, or open a world to LAN.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let bottom = ui.h - 36.0;
|
||||
for (i, (name, meta, right, dot)) in rows.iter().enumerate() {
|
||||
if y + 60.0 > bottom {
|
||||
break;
|
||||
}
|
||||
y += list_row(
|
||||
ui,
|
||||
SERVER + i as u64,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
*selected == Some(i),
|
||||
name,
|
||||
meta,
|
||||
right,
|
||||
Some(*dot),
|
||||
);
|
||||
}
|
||||
}
|
||||
Screen::Edit {
|
||||
index,
|
||||
name,
|
||||
address,
|
||||
focus,
|
||||
} => {
|
||||
let title = if index.is_some() {
|
||||
"Edit server"
|
||||
} else {
|
||||
"Add server"
|
||||
};
|
||||
let mut y = header(
|
||||
ui,
|
||||
x,
|
||||
title,
|
||||
"Saved to servers.dat, like vanilla's server list.",
|
||||
);
|
||||
let ok = horizon_protocol::servers::parse_address(address).is_some();
|
||||
actions_right(
|
||||
ui,
|
||||
x + w,
|
||||
y - 24.0 - 58.0,
|
||||
&[
|
||||
(DONE, "Save", Kind::Primary, ok),
|
||||
(BACK, "Cancel", Kind::Ghost, true),
|
||||
],
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
let fx = x + t::ROW_LABEL + 16.0;
|
||||
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
|
||||
for (i, (label, value, hint)) in [
|
||||
("Name", name, "Minecraft Server"),
|
||||
("Address", address, "play.example.net"),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rh = 12.0 + CONTROL + 12.0;
|
||||
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
|
||||
let fid = if i == 0 { FIELD_A } else { FIELD_B };
|
||||
ui.field(
|
||||
fid,
|
||||
[fx, y + 12.0, fw, CONTROL],
|
||||
value,
|
||||
hint,
|
||||
usize::from(*focus) == i,
|
||||
caret,
|
||||
);
|
||||
y += rh;
|
||||
ui.divider(x, y, w);
|
||||
}
|
||||
}
|
||||
Screen::Direct { address } => {
|
||||
let y = header(
|
||||
ui,
|
||||
x,
|
||||
"Direct connect",
|
||||
"Join a server once without saving it.",
|
||||
);
|
||||
let ok = horizon_protocol::servers::parse_address(address).is_some();
|
||||
actions_right(
|
||||
ui,
|
||||
x + w,
|
||||
y - 24.0 - 58.0,
|
||||
&[
|
||||
(DONE, "Join", Kind::Primary, ok),
|
||||
(BACK, "Cancel", Kind::Ghost, true),
|
||||
],
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
let rh = 12.0 + CONTROL + 12.0;
|
||||
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Address");
|
||||
let fx = x + t::ROW_LABEL + 16.0;
|
||||
ui.field(
|
||||
FIELD_A,
|
||||
[fx, y + 12.0, (w - t::ROW_LABEL - 16.0).min(360.0), CONTROL],
|
||||
address,
|
||||
"host:port",
|
||||
true,
|
||||
caret,
|
||||
);
|
||||
ui.divider(x, y + rh, w);
|
||||
}
|
||||
Screen::Disconnected { reason } => {
|
||||
let mut y = header(
|
||||
ui,
|
||||
x,
|
||||
"Connection lost",
|
||||
"The server closed the connection.",
|
||||
);
|
||||
ui.divider(x, y, w);
|
||||
y += 16.0;
|
||||
// The launcher's error note: a danger dot beside the message.
|
||||
let lines = wrap(ui, reason, w - 40.0);
|
||||
let box_h = 24.0 + lines.len() as f32 * 19.0;
|
||||
ui.rounded(x, y, w, box_h, t::RADIUS, color::RAISED);
|
||||
ui.border(x, y, w, box_h, t::RADIUS, color::BORDER);
|
||||
ui.rounded(x + 12.0, y + 18.0, 6.0, 6.0, 3.0, color::DANGER);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
BODY,
|
||||
19.0,
|
||||
x + 28.0,
|
||||
y + 12.0 + i as f32 * 19.0,
|
||||
color::TEXT,
|
||||
line,
|
||||
);
|
||||
}
|
||||
y += box_h + 16.0;
|
||||
let bw = ui.button_width("Back to servers", false);
|
||||
ui.button(
|
||||
BACK,
|
||||
[x, y, bw, CONTROL],
|
||||
"Back to servers",
|
||||
Kind::Secondary,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Greedy word wrap in dp.
|
||||
pub fn wrap(ui: &Ui, text: &str, width: f32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for para in text.lines() {
|
||||
let mut line = String::new();
|
||||
for word in para.split_whitespace() {
|
||||
let next = if line.is_empty() {
|
||||
word.to_owned()
|
||||
} else {
|
||||
format!("{line} {word}")
|
||||
};
|
||||
if !line.is_empty() && ui.measure(Face::Regular, BODY, &next) > width {
|
||||
lines.push(std::mem::replace(&mut line, word.to_owned()));
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// The pause menu in Horizon's style: the launcher's sidebar over the
|
||||
/// blurred world, with the game's actions as a page. Ids match
|
||||
/// `game/screens.rs`'s PauseScreen actions: 0 back to game, 1 options,
|
||||
/// 2 save and quit, 3 statistics, 4 advancements, 9 world options.
|
||||
pub mod pause {
|
||||
use super::*;
|
||||
fn act(a: u8) -> u64 {
|
||||
id("pause", u64::from(a))
|
||||
}
|
||||
pub fn draw(ui: &mut Ui, world: &str, quit_label: &str, opened: Instant) {
|
||||
let (w, h) = (ui.w, ui.h);
|
||||
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
|
||||
ui.opacity = p;
|
||||
ui.atmosphere(0.35, 1.0);
|
||||
ui.offset = (-16.0 * (1.0 - p), 0.0);
|
||||
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
|
||||
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
|
||||
ui.text_line(
|
||||
Face::Display,
|
||||
22.0,
|
||||
30.0,
|
||||
22.0,
|
||||
16.0,
|
||||
color::TEXT,
|
||||
"Horizon",
|
||||
);
|
||||
ui.sidebar_list(
|
||||
id("pause.nav", 0),
|
||||
12.0,
|
||||
66.0,
|
||||
SIDEBAR - 24.0,
|
||||
&["Game", "Advancements", "Statistics", "Options"],
|
||||
0,
|
||||
);
|
||||
ui.text(
|
||||
Face::Regular,
|
||||
SMALL,
|
||||
22.0,
|
||||
h - 12.0 - 18.0,
|
||||
color::FAINT,
|
||||
"Horizon 26.3",
|
||||
);
|
||||
ui.offset = (0.0, 8.0 * (1.0 - p));
|
||||
let x = SIDEBAR + 48.0;
|
||||
let cw = (w - x - 48.0).min(760.0);
|
||||
let mut y = 36.0;
|
||||
ui.text(Face::Medium, SMALL, x, y, color::FAINT, "Game paused");
|
||||
y += 22.0;
|
||||
let title = ui.fit(Face::Display, t::HERO, world, cw);
|
||||
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, &title);
|
||||
y += 58.0 + 24.0;
|
||||
let bw = ui.button_width("Back to game", true).max(120.0);
|
||||
ui.button(
|
||||
act(0),
|
||||
[x, y, bw, CONTROL_LARGE],
|
||||
"Back to game",
|
||||
Kind::Primary,
|
||||
true,
|
||||
);
|
||||
let lw = ui.button_width("Open to LAN", true);
|
||||
ui.button(
|
||||
act(9),
|
||||
[x + bw + 8.0, y, lw, CONTROL_LARGE],
|
||||
"Open to LAN",
|
||||
Kind::Secondary,
|
||||
true,
|
||||
);
|
||||
y += CONTROL_LARGE + 24.0;
|
||||
ui.divider(x, y, cw);
|
||||
let rh = ui.row(
|
||||
x,
|
||||
y,
|
||||
"Leave",
|
||||
"Saves the world, then returns to the title.",
|
||||
cw - t::ROW_LABEL - 220.0,
|
||||
);
|
||||
let qw = ui.button_width(quit_label, false);
|
||||
ui.button(
|
||||
act(2),
|
||||
[x + cw - qw, y + (rh - CONTROL) / 2.0, qw, CONTROL],
|
||||
quit_label,
|
||||
Kind::Danger,
|
||||
true,
|
||||
);
|
||||
y += rh;
|
||||
ui.divider(x, y, cw);
|
||||
ui.opacity = 1.0;
|
||||
ui.offset = (0.0, 0.0);
|
||||
}
|
||||
/// The PauseScreen action under a click, if any.
|
||||
pub fn click(
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
x: f32,
|
||||
y: f32,
|
||||
world: &str,
|
||||
quit_label: &str,
|
||||
) -> Option<u8> {
|
||||
let mut ui = Ui::dry(atlas, batch, (x, y));
|
||||
draw(
|
||||
&mut ui,
|
||||
world,
|
||||
quit_label,
|
||||
Instant::now() - std::time::Duration::from_secs(5),
|
||||
);
|
||||
let hit = ui.hit((x, y))?;
|
||||
let nav = id("pause.nav", 0);
|
||||
match hit {
|
||||
h if h == nav => Some(0),
|
||||
h if h == nav + 1 => Some(4),
|
||||
h if h == nav + 2 => Some(3),
|
||||
h if h == nav + 3 => Some(1),
|
||||
h => [0u8, 1, 2, 3, 4, 9].into_iter().find(|&a| act(a) == h),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod advancements;
|
||||
mod advancements_screen;
|
||||
mod allays;
|
||||
mod armor_stands;
|
||||
mod backdrop;
|
||||
mod banners;
|
||||
mod beacon;
|
||||
mod block_entity_anims;
|
||||
@@ -184,6 +185,11 @@ struct Options {
|
||||
no_pause: bool,
|
||||
/// Control socket path (`--control`), for `horizon-ctl`.
|
||||
control: Option<PathBuf>,
|
||||
/// `HORIZON_SPIKES=MS`: print the timed spans of frames slower than MS.
|
||||
spikes: Option<f32>,
|
||||
/// Render into a texture instead of the window (`--offscreen`), so a
|
||||
/// GPU the display cannot present from can still be benchmarked.
|
||||
offscreen: bool,
|
||||
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
|
||||
server: Option<String>,
|
||||
player_name: String,
|
||||
@@ -219,6 +225,10 @@ impl Options {
|
||||
dimension: None,
|
||||
no_pause: false,
|
||||
control: None,
|
||||
offscreen: false,
|
||||
spikes: std::env::var("HORIZON_SPIKES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok()),
|
||||
server: None,
|
||||
player_name: std::env::var("HORIZON_PLAYER_NAME").unwrap_or_else(|_| "Player".into()),
|
||||
dedicated: None,
|
||||
@@ -239,6 +249,7 @@ impl Options {
|
||||
"--creative" => o.creative = true,
|
||||
"--test-terrain" => o.test_terrain = true,
|
||||
"--no-pause" => o.no_pause = true,
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
_ => {
|
||||
let value = args
|
||||
@@ -701,6 +712,10 @@ struct Game<'a> {
|
||||
settings: Settings,
|
||||
/// Where the options screen returns: true = title, false = pause menu.
|
||||
options_from_title: bool,
|
||||
/// The live title backdrop (game/backdrop.rs).
|
||||
backdrop: backdrop::Backdrop,
|
||||
/// When the pause menu opened (Horizon style's entrance).
|
||||
pause_since: Option<Instant>,
|
||||
dragging: bool,
|
||||
/// `ClientAvatarState`: walked distance and view bob.
|
||||
avatar: horizon_client::camera::AvatarBob,
|
||||
@@ -783,6 +798,16 @@ struct Game<'a> {
|
||||
/// Control replies waiting for a rendered frame or a benchmark deadline.
|
||||
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
|
||||
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
|
||||
/// Recent frame times for the performance overlay.
|
||||
frame_graph: horizon_client::perf_overlay::FrameGraph,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
|
||||
/// This frame's view frustum, for culling entities and block entities.
|
||||
view_frustum: Option<horizon_client::gfx::Frustum>,
|
||||
/// A scripted flight from the control socket (`fly`).
|
||||
flight: Option<control::Flight>,
|
||||
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
|
||||
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
persist: persist::EntityMemory,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
remote: Option<remote::Remote>,
|
||||
@@ -1225,7 +1250,14 @@ impl Game<'_> {
|
||||
}
|
||||
fn tick_items(&mut self) {
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| item.tick(&self.world, &self.res.facts, self.collision));
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
items.retain_mut(|item| {
|
||||
!ticking(
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
) || item.tick(&self.world, &self.res.facts, self.collision)
|
||||
});
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
self.dropped = items;
|
||||
return;
|
||||
@@ -2788,64 +2820,66 @@ impl Game<'_> {
|
||||
/// What the tick runs after the local player's own update: effects, the
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.tick_effects();
|
||||
self.tick_burning();
|
||||
self.tick_using();
|
||||
self.tick_hud();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects());
|
||||
horizon_client::spans::time("tick_burning", || self.tick_burning());
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
// ticks; ServerLevel.tick then runs the scheduled and random block
|
||||
// ticks before any entity, so what they drop or spawn (dispensed
|
||||
// items, popped crops) moves on the tick it appears, as in vanilla.
|
||||
self.tick_local_hand()?;
|
||||
self.postprocess_chunks()?;
|
||||
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
|
||||
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
|
||||
if running {
|
||||
self.tick_blocks()?;
|
||||
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
|
||||
}
|
||||
if running {
|
||||
self.tick_paintings();
|
||||
self.tick_item_frames();
|
||||
self.tick_armor_stands();
|
||||
self.tick_boats();
|
||||
self.tick_carts();
|
||||
self.tick_thunder();
|
||||
self.tick_lecterns();
|
||||
self.tick_maps();
|
||||
self.tick_bolts();
|
||||
self.tick_fishing();
|
||||
self.tick_tridents();
|
||||
self.tick_rockets();
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
|
||||
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
|
||||
horizon_client::spans::time("tick_maps", || self.tick_maps());
|
||||
horizon_client::spans::time("tick_bolts", || self.tick_bolts());
|
||||
horizon_client::spans::time("tick_fishing", || self.tick_fishing());
|
||||
horizon_client::spans::time("tick_tridents", || self.tick_tridents());
|
||||
horizon_client::spans::time("tick_rockets", || self.tick_rockets());
|
||||
}
|
||||
if running {
|
||||
self.tick_items();
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
}
|
||||
self.spawn_loaded_entities();
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
if running {
|
||||
self.tick_mobs()?;
|
||||
self.recheck_golem_openers();
|
||||
self.tick_experience();
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
horizon_client::spans::time("tick_experience", || self.tick_experience());
|
||||
}
|
||||
self.tick_lan();
|
||||
self.tick_hand();
|
||||
self.tick_sleep();
|
||||
self.tick_portal()?;
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan());
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
horizon_client::spans::time("tick_portal", || self.tick_portal())?;
|
||||
if running {
|
||||
self.tick_end()?;
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
}
|
||||
if running {
|
||||
self.tick_falling()?;
|
||||
self.tick_tnt()?;
|
||||
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
|
||||
horizon_client::spans::time("tick_tnt", || self.tick_tnt())?;
|
||||
}
|
||||
if running {
|
||||
self.tick_block_entities()?;
|
||||
self.tick_sculk_entities();
|
||||
self.tick_sculk_blocks()?;
|
||||
self.tick_brewing_stands()?;
|
||||
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
|
||||
horizon_client::spans::time("tick_sculk_entities", || self.tick_sculk_entities());
|
||||
horizon_client::spans::time("tick_sculk_blocks", || self.tick_sculk_blocks())?;
|
||||
horizon_client::spans::time("tick_brewing_stands", || self.tick_brewing_stands())?;
|
||||
}
|
||||
if running {
|
||||
// ClientLevel.tickBlockEntities (not while paused or frozen):
|
||||
// chest lids, books, shulker boxes, bells, conduits, heads.
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_particles();
|
||||
horizon_client::spans::time("tick_block_entity_anims", || {
|
||||
self.tick_block_entity_anims()
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
}
|
||||
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
|
||||
self.use_with_hand()?;
|
||||
@@ -2969,6 +3003,12 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &self.res.atlas, &draws);
|
||||
}
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = self.hud_layers(size);
|
||||
self.frame_graph
|
||||
.draw(self.settings.horizon.overlay, &mut batch, &self.res.atlas);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.screen == Screen::Options {
|
||||
@@ -2976,7 +3016,15 @@ impl Game<'_> {
|
||||
return batch;
|
||||
}
|
||||
if let Some(title) = &self.title {
|
||||
title.draw(&self.res.atlas, &mut batch, self.cursor);
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
let fade = self.backdrop_fade();
|
||||
let mut ui =
|
||||
horizon_client::ui_theme::Ui::new(&self.res.atlas, &mut batch, self.cursor);
|
||||
title.draw_horizon(&mut ui, fade);
|
||||
ui.finish();
|
||||
} else {
|
||||
title.draw(&self.res.atlas, &mut batch, self.cursor);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
let atlas_ptr: *const HudAtlas = &self.res.atlas;
|
||||
@@ -3001,7 +3049,8 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
if !self.ui.hide_gui {
|
||||
// Horizon's pause menu sits over the blurred world alone.
|
||||
if !self.ui.hide_gui && !self.horizon_pause() {
|
||||
self.draw_spyglass(&mut batch);
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
// extractVehicleHealth and the experience bar, laid out by hud_layout.
|
||||
@@ -4372,9 +4421,34 @@ impl Game<'_> {
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
glyph: &|c| self.res.atlas.glyph_cell(c),
|
||||
};
|
||||
// Block entities outside the view frustum are skipped (with a
|
||||
// block of margin for models larger than their block).
|
||||
let view = self.view_frustum;
|
||||
// Beams and structure outlines reach far outside their block
|
||||
// (BlockEntityRenderer.shouldRenderOffScreen): never culled.
|
||||
let off_screen = |state: u32| {
|
||||
let name = &*self.res.facts.get(BlockStateId(state)).name;
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:beacon"
|
||||
| "minecraft:end_gateway"
|
||||
| "minecraft:structure_block"
|
||||
| "minecraft:test_instance_block"
|
||||
)
|
||||
};
|
||||
let visible = r.block_entities().filter(|(p, state)| {
|
||||
off_screen(*state)
|
||||
|| view.is_none_or(|v| {
|
||||
let o =
|
||||
(glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
});
|
||||
self.res
|
||||
.block_entity_render
|
||||
.render(&scene, r.block_entities(), &mut out);
|
||||
.render(&scene, visible, &mut out);
|
||||
}
|
||||
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
|
||||
if let Some(r) = self
|
||||
@@ -4608,11 +4682,20 @@ impl Game<'_> {
|
||||
.sample(self.world.day_time, None, None);
|
||||
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
}
|
||||
let mut sky = self.res.environment.sample(self.world.day_time, None, None);
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Sampled once per (time, weather): every mob asks each tick.
|
||||
let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
|
||||
if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
&& k == key
|
||||
{
|
||||
return darken;
|
||||
}
|
||||
let mut sky = self.res.environment.sample(self.world.day_time, None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
self.sky_darken_memo.set(Some((key, darken)));
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
let eye = self.eye_position()?;
|
||||
@@ -7127,7 +7210,13 @@ impl Game<'_> {
|
||||
return Ok(false);
|
||||
};
|
||||
let alpha = self.clock.fraction();
|
||||
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
|
||||
let live = self.backdrop_live();
|
||||
let view = if live {
|
||||
self.backdrop_view()
|
||||
} else {
|
||||
self.camera_view(alpha as f32)
|
||||
};
|
||||
let (camera, underwater, view_yaw, view_pitch) = match view {
|
||||
Some((c, yaw, pitch)) => {
|
||||
let s =
|
||||
self.world
|
||||
@@ -7194,14 +7283,39 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut hud = self.hud(size);
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// Map textures changed since the last drawn frame (game/maps.rs).
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let title = self.title.is_some();
|
||||
let (yaw, pitch) = if live {
|
||||
(view_yaw, view_pitch)
|
||||
} else if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
|
||||
} else {
|
||||
(view_yaw, view_pitch)
|
||||
};
|
||||
let fov_degrees = if live {
|
||||
72.0
|
||||
} else if title {
|
||||
85.0
|
||||
} else {
|
||||
self.fov_degrees(alpha as f32, camera.to_array())
|
||||
};
|
||||
let bob = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.bob_matrix(alpha as f32)
|
||||
};
|
||||
let distance = (self.streaming.radius * 16) as f32;
|
||||
self.view_frustum = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, distance));
|
||||
let mut entities = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.entity_geometry(camera, alpha)
|
||||
horizon_client::spans::time("entity_geometry", || self.entity_geometry(camera, alpha))
|
||||
};
|
||||
let mut additive = Vec::new();
|
||||
if !title {
|
||||
@@ -7213,7 +7327,7 @@ impl Game<'_> {
|
||||
entities.extend(dragon);
|
||||
}
|
||||
}
|
||||
let celestial = if title {
|
||||
let celestial = if title && !live {
|
||||
self.panorama_geometry()
|
||||
} else if self.dim == Dim::Overworld
|
||||
&& self.potions.effects.get("darkness").is_none()
|
||||
@@ -7239,16 +7353,16 @@ impl Game<'_> {
|
||||
if !title {
|
||||
self.end_geometry(camera, &mut entities);
|
||||
}
|
||||
if !title && self.dim == Dim::Overworld {
|
||||
self.cloud_geometry(&sky, camera, alpha, &mut entities);
|
||||
self.weather_geometry(camera, alpha, &mut entities);
|
||||
if (!title || live) && self.dim == Dim::Overworld {
|
||||
horizon_client::spans::time("clouds", || {
|
||||
self.cloud_geometry(&sky, camera, alpha, &mut entities)
|
||||
});
|
||||
if self.settings.horizon.weather {
|
||||
horizon_client::spans::time("weather", || {
|
||||
self.weather_geometry(camera, alpha, &mut entities)
|
||||
});
|
||||
}
|
||||
}
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
let (yaw, pitch) = if title {
|
||||
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
|
||||
} else {
|
||||
(view_yaw, view_pitch)
|
||||
};
|
||||
let water_fog = {
|
||||
let c = info.water_fog;
|
||||
[
|
||||
@@ -7264,37 +7378,42 @@ impl Game<'_> {
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
fov_degrees: if title {
|
||||
85.0
|
||||
} else {
|
||||
self.fov_degrees(alpha as f32, camera.to_array())
|
||||
},
|
||||
bob: if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.bob_matrix(alpha as f32)
|
||||
},
|
||||
fov_degrees,
|
||||
bob,
|
||||
sky,
|
||||
distance: (self.streaming.radius * 16) as f32,
|
||||
distance,
|
||||
underwater,
|
||||
title,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
end_sky: self.dim == Dim::End,
|
||||
title: title && !live,
|
||||
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
|
||||
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
|
||||
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
|
||||
// The camera inside an opaque block sees nothing through it: disable.
|
||||
cave_culling: !self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(block[0], block[1], block[2]))
|
||||
.solid_render,
|
||||
cave_culling: self.settings.horizon.cave_culling
|
||||
&& !self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(block[0], block[1], block[2]))
|
||||
.solid_render,
|
||||
water_fog,
|
||||
fog: Some(self.frame_fog(camera, &sky, biome)),
|
||||
fog: Some({
|
||||
let mut fog = self.frame_fog(camera, &sky, biome);
|
||||
// Horizon's fog setting: no distance fog (fluids keep theirs).
|
||||
if !self.settings.horizon.fog && !underwater && fog.environmental_end >= distance {
|
||||
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
fog
|
||||
}),
|
||||
dark_disc: self.dark_disc(camera),
|
||||
outline,
|
||||
hud: &hud,
|
||||
entities: &entities,
|
||||
additive: &additive,
|
||||
celestial: &celestial,
|
||||
celestial: if self.settings.horizon.sky || title {
|
||||
&celestial
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
hand: &hand,
|
||||
hand_fov_degrees: self.hud_fov_degrees(alpha as f32, camera.to_array()),
|
||||
screen: &screen,
|
||||
@@ -7312,8 +7431,16 @@ impl Game<'_> {
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(),
|
||||
block_flicker: self.res.fog.state.block_light_flicker,
|
||||
post: if live {
|
||||
Some(horizon_client::post::PostGrade::FROST)
|
||||
} else if self.horizon_pause() {
|
||||
Some(horizon_client::post::PostGrade::PAUSE)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
self.renderer.as_mut().ok_or("renderer")?.render(&frame)
|
||||
let renderer = self.renderer.as_mut().ok_or("renderer")?;
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
|
||||
fn border_draw(
|
||||
@@ -7514,32 +7641,79 @@ impl Game<'_> {
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
}
|
||||
let (uploads, unloaded) = self.streaming.update(&mut self.world);
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
let dirty: Vec<_> = unloaded
|
||||
.iter()
|
||||
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
|
||||
.cloned()
|
||||
.collect();
|
||||
let modified: Vec<_> = dirty.iter().map(|c| self.with_entities(c)).collect();
|
||||
self.unload_entities(&unloaded);
|
||||
for c in &unloaded {
|
||||
let modified: Vec<_> = horizon_client::spans::time("with_entities", || {
|
||||
dirty.iter().map(|c| self.with_entities(c)).collect()
|
||||
});
|
||||
horizon_client::spans::time("unload_entities", || self.unload_entities(&unloaded));
|
||||
if !unloaded.is_empty() {
|
||||
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> =
|
||||
unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|p, _| !(p.0 >> 4 == c.x && p.2 >> 4 == c.z));
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
self.world
|
||||
.block_names
|
||||
.retain(|p, _| !(p.0 >> 4 == c.x && p.2 >> 4 == c.z));
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
}
|
||||
if !modified.is_empty()
|
||||
&& let Err(e) = self
|
||||
.save
|
||||
.save(&modified, self.res.assets.registry, &self.res.biomes)
|
||||
&& let Err(e) = horizon_client::spans::time("save_chunks", || {
|
||||
self.save
|
||||
.save_later(&modified, self.res.assets.registry, &self.res.biomes)
|
||||
})
|
||||
{
|
||||
eprintln!("Horizon: failed to save unloaded chunks: {e}");
|
||||
}
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
for (key, mesh) in uploads {
|
||||
renderer.upload(key, &mesh);
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
if !budgeted && self.pending_uploads.is_empty() {
|
||||
horizon_client::spans::time("upload", || {
|
||||
for (key, mesh) in uploads {
|
||||
renderer.upload(key, &mesh);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Horizon's chunk-update and upload budgets: the nearest
|
||||
// sections go first, the rest wait for later frames; a newer
|
||||
// mesh of a waiting section replaces it.
|
||||
let pending = &mut self.pending_uploads;
|
||||
let fresh: horizon_client::fast_hash::FxHashSet<(i32, i32, i32)> =
|
||||
uploads.iter().map(|(k, _)| *k).collect();
|
||||
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> =
|
||||
unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
pending.retain(|(k, _)| !fresh.contains(k) && !gone.contains(&(k.0, k.2)));
|
||||
pending.extend(uploads);
|
||||
let centre = self.streaming.center;
|
||||
let far = |k: &(i32, i32, i32)| (k.0 - centre.0).pow(2) + (k.2 - centre.1).pow(2);
|
||||
pending.sort_unstable_by_key(|(k, _)| std::cmp::Reverse(far(k)));
|
||||
let (limit, budget) = if budgeted {
|
||||
(
|
||||
g.chunk_updates as usize,
|
||||
u64::from(g.upload_budget_kib) * 1024,
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let (mut count, mut bytes) = (0, 0u64);
|
||||
while let Some((_, mesh)) = pending.last() {
|
||||
let size = ((mesh.solid.len() + mesh.translucent.len()) * 32) as u64;
|
||||
if (limit > 0 && count >= limit)
|
||||
|| (budget > 0 && bytes > 0 && bytes + size > budget)
|
||||
{
|
||||
break;
|
||||
}
|
||||
let (key, mesh) = pending.pop().expect("checked above");
|
||||
renderer.upload(key, &mesh);
|
||||
count += 1;
|
||||
bytes += size;
|
||||
}
|
||||
}
|
||||
for c in unloaded {
|
||||
for sy in 0..(HEIGHT / 16) {
|
||||
@@ -7569,6 +7743,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
let assets = &self.res.assets;
|
||||
match pollster::block_on(Renderer::new(
|
||||
window.clone(),
|
||||
self.options.offscreen,
|
||||
&assets.pixels,
|
||||
assets.sprite_size,
|
||||
assets.texture_layers,
|
||||
@@ -7603,6 +7778,15 @@ impl ApplicationHandler for Game<'_> {
|
||||
) {
|
||||
self.last_input = Instant::now();
|
||||
}
|
||||
if let WindowEvent::MouseInput {
|
||||
state,
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
// The press state of Horizon-style buttons.
|
||||
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
|
||||
}
|
||||
let result: Result<()> = (|| {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@@ -7728,9 +7912,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let b = HudBatch::new(size);
|
||||
let mut b = HudBatch::new(size);
|
||||
let atlas = &self.res.atlas;
|
||||
let action = self.title.as_mut().map_or(Action::None, |t| {
|
||||
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1)
|
||||
} else {
|
||||
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
|
||||
}
|
||||
});
|
||||
match action {
|
||||
Action::Quit => event_loop.exit(),
|
||||
@@ -7896,6 +8085,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& let Some(title) = self.title.as_mut()
|
||||
&& !title.skip_opening()
|
||||
{
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Backspace) => title.backspace(),
|
||||
@@ -7977,18 +8167,26 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.pitch = (self.pitch
|
||||
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
|
||||
.clamp(-90.0, 90.0);
|
||||
if self.title.is_none() {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
self.update_backdrop()?;
|
||||
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
|
||||
if self.screen != Screen::Paused {
|
||||
self.pause_since = None;
|
||||
}
|
||||
if self.title.is_none() {
|
||||
horizon_client::spans::time("update_world", || self.update_world());
|
||||
horizon_client::spans::time("spawn_player", || self.spawn_player())?;
|
||||
}
|
||||
let updated = Instant::now();
|
||||
self.track_toggle_screens();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
if self.title.is_none() && !self.pause {
|
||||
self.tick()?;
|
||||
horizon_client::spans::time("tick", || self.tick())?;
|
||||
self.tick_ambience();
|
||||
self.tick_fov();
|
||||
} else if self.title.is_some() {
|
||||
self.tick_backdrop();
|
||||
}
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
@@ -7998,7 +8196,48 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
self.apply_mouse_look();
|
||||
self.advance_flight();
|
||||
let ticked = Instant::now();
|
||||
let rendered = self.render()?;
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
let spans = horizon_client::spans::take();
|
||||
if cpu > threshold {
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|(_, ms)| *ms >= 0.5)
|
||||
.map(|(n, ms)| format!("{n} {ms:.1}"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"Horizon: slow frame {cpu:.1} ms ({} ticks): {}",
|
||||
steps,
|
||||
list.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait);
|
||||
if rendered {
|
||||
let ms = |d: Duration| d.as_secs_f32() * 1000.0;
|
||||
self.frame_graph.record(
|
||||
ms(elapsed),
|
||||
ms(cpu),
|
||||
r.gpu_times.last().copied(),
|
||||
);
|
||||
self.frame_stats.record_cpu(ms(cpu));
|
||||
let render = r.last_encode;
|
||||
self.frame_stats.record_phases([
|
||||
ms(updated - now),
|
||||
ms(ticked - updated),
|
||||
ms(ticked.elapsed().saturating_sub(r.last_wait + render)),
|
||||
ms(render),
|
||||
]);
|
||||
}
|
||||
self.frame_stats.record_gpu(&r.gpu_times);
|
||||
r.gpu_times.clear();
|
||||
}
|
||||
self.limit_frame();
|
||||
self.pause = self.pauses_game();
|
||||
self.clock.end_frame(self.pause);
|
||||
@@ -8079,6 +8318,7 @@ pub fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs);
|
||||
@@ -8127,10 +8367,7 @@ pub fn run() -> Result<()> {
|
||||
biomes: biomes.clone(),
|
||||
light,
|
||||
});
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(1)
|
||||
.max(1);
|
||||
let threads = early_settings.horizon.worker_threads();
|
||||
eprintln!(
|
||||
"Horizon: resources loaded in {:.1}s; {} block states, {} items, {} biomes; {} worker threads",
|
||||
started.elapsed().as_secs_f32(),
|
||||
@@ -8222,6 +8459,14 @@ pub fn run() -> Result<()> {
|
||||
let now = Instant::now();
|
||||
let game_root = options.resources.clone();
|
||||
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
|
||||
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
|
||||
// screens unless HORIZON_MENU_STYLE asks otherwise.
|
||||
if (options.frames.is_some() || options.control.is_some())
|
||||
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
|
||||
{
|
||||
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
|
||||
}
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
}
|
||||
@@ -8342,6 +8587,8 @@ pub fn run() -> Result<()> {
|
||||
hand_strength: Default::default(),
|
||||
settings,
|
||||
options_from_title: false,
|
||||
backdrop: Default::default(),
|
||||
pause_since: None,
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
flash: 0,
|
||||
@@ -8407,6 +8654,11 @@ pub fn run() -> Result<()> {
|
||||
frame_stats: Default::default(),
|
||||
pending_screenshot: None,
|
||||
pending_bench: None,
|
||||
flight: None,
|
||||
view_frustum: None,
|
||||
pending_uploads: Vec::new(),
|
||||
frame_graph: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
remote: None,
|
||||
lan: None,
|
||||
@@ -8472,7 +8724,16 @@ pub fn run() -> Result<()> {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
game.open_world(&folder, None, Some(seed), creative)?;
|
||||
}
|
||||
None => game.title = Some(Title::new(std::env::current_dir()?.join("saves"))),
|
||||
None => {
|
||||
let title = Title::new(std::env::current_dir()?.join("saves"));
|
||||
// The launcher's opening, once per session and only in Horizon's style.
|
||||
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
|
||||
title
|
||||
.opening
|
||||
.set(Some(horizon_client::front::Opening::new()));
|
||||
}
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
if let (Some(script), Some(output)) = (
|
||||
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
|
||||
//! Horizon generates and renders itself behind the title screens, seen
|
||||
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
|
||||
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
|
||||
//! from the vanilla generator for a fixed seed and are cached in
|
||||
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
|
||||
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
|
||||
//! in `spot.txt` there.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
save::{Persistent, SaveDir},
|
||||
streaming::Streaming,
|
||||
vanillagen::VanillaTerrain,
|
||||
world::{Weather, World},
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
|
||||
const SEED: i64 = 2_026_031_337;
|
||||
/// The biome the camera looks for.
|
||||
const BIOME: &str = "minecraft:ice_spikes";
|
||||
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
|
||||
const DAY_TIME: i64 = 4_000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Backdrop {
|
||||
pub active: bool,
|
||||
/// The search for the spot, then the spot (block x, z).
|
||||
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
|
||||
centred: bool,
|
||||
/// Camera position once the ground under it has loaded.
|
||||
pub camera: Option<glam::DVec3>,
|
||||
pub ready: Option<Instant>,
|
||||
pub started: Option<Instant>,
|
||||
}
|
||||
fn seed() -> i64 {
|
||||
std::env::var("HORIZON_BACKDROP_SEED")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(SEED)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// How visible the live backdrop is (0 until its first chunks are drawn).
|
||||
pub(super) fn backdrop_fade(&self) -> f32 {
|
||||
let Some(at) = self.backdrop.ready else {
|
||||
return 0.0;
|
||||
};
|
||||
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
/// Whether the frame should draw the backdrop world instead of the panorama.
|
||||
pub(super) fn backdrop_live(&self) -> bool {
|
||||
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
|
||||
}
|
||||
/// The orbiting camera: position, yaw and pitch.
|
||||
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
|
||||
let c = self.backdrop.camera?;
|
||||
let t = self
|
||||
.backdrop
|
||||
.started
|
||||
.map_or(0.0, |s| s.elapsed().as_secs_f32());
|
||||
// A full turn in about six minutes.
|
||||
let yaw = (200.0 + t * 1.0) % 360.0;
|
||||
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
|
||||
Some((c, yaw, pitch))
|
||||
}
|
||||
/// Called every frame while the title is up.
|
||||
pub(super) fn update_backdrop(&mut self) -> Result<()> {
|
||||
let wanted = self.title.is_some()
|
||||
&& horizon_client::ui_theme::horizon()
|
||||
&& self.renderer.is_some()
|
||||
&& self.options.frames.is_none();
|
||||
if !wanted {
|
||||
if self.backdrop.active && self.title.is_some() {
|
||||
self.stop_backdrop();
|
||||
}
|
||||
if self.title.is_none() && self.backdrop.active {
|
||||
// open_world / connect_server replaced the world and streaming.
|
||||
self.backdrop = Backdrop::default();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !self.backdrop.active {
|
||||
self.start_backdrop()?;
|
||||
}
|
||||
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(Some((x, z))) = found else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.backdrop.centred {
|
||||
self.backdrop.centred = true;
|
||||
self.streaming.center = (x >> 4, z >> 4);
|
||||
self.streaming.paused = false;
|
||||
}
|
||||
self.update_world();
|
||||
if self.backdrop.camera.is_none()
|
||||
&& (-2..=2)
|
||||
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
|
||||
{
|
||||
// Above the tallest block nearby, so no spike pokes through the lens.
|
||||
let mut top = 64;
|
||||
for dz in (-12..=12).step_by(3) {
|
||||
for dx in (-12..=12).step_by(3) {
|
||||
if let Some(y) = self.surface_y(x + dx, z + dz) {
|
||||
top = top.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ground = self.surface_y(x, z).unwrap_or(top);
|
||||
let y = (ground + 22).max(top + 4);
|
||||
self.backdrop.camera = Some(glam::DVec3::new(
|
||||
f64::from(x) + 0.5,
|
||||
f64::from(y),
|
||||
f64::from(z) + 0.5,
|
||||
));
|
||||
}
|
||||
if self.backdrop.camera.is_some()
|
||||
&& self.backdrop.ready.is_none()
|
||||
&& self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.section_count() > 40)
|
||||
{
|
||||
self.backdrop.ready = Some(Instant::now());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The world tick of the backdrop: time passes for clouds and snow only.
|
||||
pub(super) fn tick_backdrop(&mut self) {
|
||||
if self.backdrop.active {
|
||||
self.world.game_time += 1;
|
||||
// The snow's fall follows the client tick counter.
|
||||
self.ticks += 1;
|
||||
}
|
||||
}
|
||||
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
|
||||
let air = self.res.facts.air;
|
||||
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
|
||||
}
|
||||
fn start_backdrop(&mut self) -> Result<()> {
|
||||
let seed = seed();
|
||||
let root = std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(".horizon-title")
|
||||
.join("backdrop");
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
|
||||
let res = &*self.res;
|
||||
let inner = Arc::new(
|
||||
VanillaTerrain::new(
|
||||
&self.options.resources.join("worldgen"),
|
||||
res.assets.registry,
|
||||
&res.biomes,
|
||||
seed,
|
||||
)
|
||||
.map_err(|e| format!("title backdrop: {e}"))?,
|
||||
);
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(Persistent {
|
||||
inner: inner.clone(),
|
||||
save: save.clone(),
|
||||
registry: res.assets.clone(),
|
||||
biomes: res.biomes.clone(),
|
||||
}),
|
||||
res.context.clone(),
|
||||
res.threads.min(6),
|
||||
);
|
||||
streaming.radius = self.settings.render_distance.clamp(4, 10);
|
||||
streaming.biome_blend = self.settings.biome_blend;
|
||||
streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.paused = true;
|
||||
self.streaming = streaming;
|
||||
self.save = save;
|
||||
let mut world = World::new(self.res.facts.air, seed);
|
||||
world.day_time = DAY_TIME;
|
||||
world.game_time = 6000;
|
||||
// Light snow: the weather renders as snow in frozen biomes.
|
||||
world.weather = Weather {
|
||||
clear_time: 0,
|
||||
rain_time: i32::MAX / 2,
|
||||
thunder_time: i32::MAX / 2,
|
||||
raining: true,
|
||||
thundering: false,
|
||||
rain_level: (0.35, 0.35),
|
||||
thunder_level: (0.0, 0.0),
|
||||
};
|
||||
self.world = world;
|
||||
self.dim = super::Dim::Overworld;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.backdrop = Backdrop {
|
||||
active: true,
|
||||
started: Some(Instant::now()),
|
||||
..Default::default()
|
||||
};
|
||||
// Find (or recall) the spot off the main thread.
|
||||
let spot = self.backdrop.spot.clone();
|
||||
std::fs::create_dir_all(root.join(format!("{seed}")))?;
|
||||
let cache = root.join(format!("{seed}")).join("spot.txt");
|
||||
std::thread::Builder::new()
|
||||
.name("horizon-backdrop".into())
|
||||
.spawn(move || {
|
||||
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
|
||||
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
|
||||
Some((it.next()??, it.next()??))
|
||||
});
|
||||
let found = known.or_else(|| {
|
||||
let allowed = |name: &str| name == BIOME;
|
||||
let search = horizon_world::worldgen::generator::BiomeSearch {
|
||||
allowed: &allowed,
|
||||
origin: [0, 64, 0],
|
||||
radius: 6400,
|
||||
horizontal: 32,
|
||||
vertical: 64,
|
||||
min_y: -64,
|
||||
max_y: 320,
|
||||
};
|
||||
let found = inner.find_closest_biome(&search);
|
||||
eprintln!("Horizon: title backdrop: {BIOME} at {found:?}");
|
||||
let (_, p) = found?;
|
||||
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
|
||||
Some((p[0], p[2]))
|
||||
});
|
||||
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Some(found.unwrap_or((0, 0))));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn stop_backdrop(&mut self) {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts.air, 0);
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.backdrop = Backdrop::default();
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,10 @@ const HELP: &[&str] = &[
|
||||
"chat TEXT send a chat line or /command as if typed",
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs graphics graphics-advanced)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH] fly the camera along a line at constant speed, then reply with the frame summary",
|
||||
"open WORLD [SEED] [creative] open or create a world from the title screen",
|
||||
"save save the world",
|
||||
"save-all save the world, rewriting every resident chunk",
|
||||
@@ -42,7 +43,42 @@ const HELP: &[&str] = &[
|
||||
"quit save and exit",
|
||||
];
|
||||
|
||||
/// A straight camera path at constant speed, for repeatable benchmarks: the
|
||||
/// player is placed on it every frame, facing along it.
|
||||
pub struct Flight {
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
pitch: f32,
|
||||
started: Instant,
|
||||
seconds: f64,
|
||||
reply: Sender<String>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Move the camera along the running flight; reply when it ends.
|
||||
pub(super) fn advance_flight(&mut self) {
|
||||
let Some(flight) = &self.flight else { return };
|
||||
let t = (flight.started.elapsed().as_secs_f64() / flight.seconds).min(1.0);
|
||||
let (a, b) = (flight.from, flight.to);
|
||||
let d = [b.x - a.x, b.y - a.y, b.z - a.z];
|
||||
let at = Vec3::new(a.x + d[0] * t, a.y + d[1] * t, a.z + d[2] * t);
|
||||
// Minecraft yaw: 0 faces +Z, 90 faces -X.
|
||||
self.yaw = (-d[0]).atan2(d[2]).to_degrees() as f32;
|
||||
self.pitch = flight.pitch;
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.position = at;
|
||||
player.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
player.fall_distance = 0.0;
|
||||
// Stay where the flight ends (creative flight).
|
||||
player.flying = true;
|
||||
self.previous_position = at;
|
||||
}
|
||||
if t >= 1.0
|
||||
&& let Some(flight) = self.flight.take()
|
||||
{
|
||||
let _ = flight.reply.send(self.frame_stats.summary().to_string());
|
||||
}
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
@@ -325,6 +361,8 @@ impl Game<'_> {
|
||||
"language" => Page::Language,
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -357,6 +395,24 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(None)
|
||||
}
|
||||
"fly" => {
|
||||
self.require_world()?;
|
||||
if self.flight.is_some() || self.pending_bench.is_some() {
|
||||
return Err("a benchmark is already running".into());
|
||||
}
|
||||
let n = |i: usize| -> Result<f64> { Ok(arg(i)?.parse()?) };
|
||||
let pitch = words.get(8).map_or(Ok(10.0), |p| p.parse::<f32>())?;
|
||||
self.frame_stats.reset();
|
||||
self.flight = Some(Flight {
|
||||
from: Vec3::new(n(1)?, n(2)?, n(3)?),
|
||||
to: Vec3::new(n(4)?, n(5)?, n(6)?),
|
||||
pitch,
|
||||
started: Instant::now(),
|
||||
seconds: n(7)?.max(0.1),
|
||||
reply: reply.clone(),
|
||||
});
|
||||
Ok(None)
|
||||
}
|
||||
"open" => {
|
||||
if self.title.is_none() {
|
||||
return Err("a world is already open".into());
|
||||
@@ -491,6 +547,7 @@ impl Game<'_> {
|
||||
"present_mode": format!("{:?}", r.config.present_mode),
|
||||
"drawn_sections": r.drawn_sections,
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
@@ -510,6 +567,7 @@ impl Game<'_> {
|
||||
"ticks": self.ticks,
|
||||
"day_time": self.world.day_time,
|
||||
"music": self.music.current(&self.audio.engine),
|
||||
"sounds": self.audio.census(),
|
||||
"fishing": self.hook_status(),
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
@@ -519,7 +577,9 @@ impl Game<'_> {
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
"chunks": self.world.chunks.len(),
|
||||
"pending_chunks": self.streaming.pending(),
|
||||
"mobs": self.mobs.len(),
|
||||
"block_entities": self.world.block_entities.len(),
|
||||
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
|
||||
"generated_chunks": self.streaming.generated_total,
|
||||
"meshed_sections": self.streaming.meshed_total,
|
||||
"renderer": renderer,
|
||||
|
||||
@@ -17,10 +17,7 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -319,7 +316,16 @@ impl Game<'_> {
|
||||
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let name = |id: u16| &*self.res.facts.get(BlockStateId(u32::from(id))).name;
|
||||
if self.creaking.hearts.is_empty() {
|
||||
self.creaking.hearts = self
|
||||
.res
|
||||
.facts
|
||||
.states
|
||||
.iter()
|
||||
.map(|f| &*f.name == "minecraft:creaking_heart")
|
||||
.collect();
|
||||
}
|
||||
let hearts = &self.creaking.hearts;
|
||||
let _ = heart;
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
if section.is_uniform(0) {
|
||||
@@ -327,7 +333,7 @@ impl Game<'_> {
|
||||
}
|
||||
for i in 0..4096 {
|
||||
let id = section.get(i);
|
||||
if name(id) != "minecraft:creaking_heart" {
|
||||
if !hearts.get(usize::from(id)).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
|
||||
@@ -343,8 +349,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let loaded: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
|
||||
self.creaking.scanned.retain(|k| loaded.contains(k));
|
||||
let chunks = &self.world.chunks;
|
||||
self.creaking.scanned.retain(|k| chunks.contains_key(k));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,5 +358,7 @@ impl Game<'_> {
|
||||
#[derive(Default)]
|
||||
pub(super) struct CreakingState {
|
||||
/// Chunks already scanned for heart blocks.
|
||||
scanned: HashSet<(i32, i32)>,
|
||||
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Which block states are creaking hearts, by state id.
|
||||
hearts: Vec<bool>,
|
||||
}
|
||||
@@ -403,6 +403,25 @@ impl Game<'_> {
|
||||
self.tick_raids();
|
||||
self.tick_creature_entities()
|
||||
}
|
||||
/// `DistanceManager.inEntityTickingRange`: chunks within the simulation
|
||||
/// distance (Chebyshev, in chunks) of the player or of a guest.
|
||||
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let d = self.settings.simulation_distance.max(2);
|
||||
let mut centres: Vec<(i32, i32)> = self.streaming.extra.iter().map(|&(c, _)| c).collect();
|
||||
if let Some(p) = &self.player {
|
||||
centres.push((
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
));
|
||||
}
|
||||
let everywhere = centres.is_empty();
|
||||
move |x, z| {
|
||||
everywhere
|
||||
|| centres
|
||||
.iter()
|
||||
.any(|&(cx, cz)| ((x >> 4) - cx).abs().max(((z >> 4) - cz).abs()) <= d)
|
||||
}
|
||||
}
|
||||
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
|
||||
/// projectiles and effects that follow from them.
|
||||
pub(super) fn tick_creature_entities(&mut self) -> Result<()> {
|
||||
@@ -424,6 +443,7 @@ impl Game<'_> {
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mut dead = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let ticking = self.entity_ticking_area();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
@@ -441,6 +461,18 @@ impl Game<'_> {
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
continue;
|
||||
}
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick.
|
||||
if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) {
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
@@ -2248,11 +2280,29 @@ impl Game<'_> {
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.res.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
for mob in &self.mobs {
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
if d.length_squared() > 128.0 * 128.0 {
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the entity distance option.
|
||||
let (w, h, _) = mob.dims();
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the culling box, grown by 0.5
|
||||
// (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
let lo =
|
||||
d.as_vec3() - glam::Vec3::new(w as f32 / 2.0 + 0.5, 0.5, w as f32 / 2.0 + 0.5);
|
||||
let hi = d.as_vec3()
|
||||
+ glam::Vec3::new(w as f32 / 2.0 + 0.5, h as f32 + 0.5, w as f32 / 2.0 + 0.5);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
|
||||
@@ -376,6 +376,7 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(game_mode);
|
||||
self.ui.difficulty = props.difficulty as u8;
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
|
||||
@@ -762,9 +762,14 @@ impl Game<'_> {
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
pub(super) fn tick_carts(&mut self) {
|
||||
self.push_carts();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut i = 0;
|
||||
while i < self.carts.len() {
|
||||
if Some(self.carts[i].id) == self.riding {
|
||||
let p = self.carts[i].position;
|
||||
if Some(self.carts[i].id) == self.riding
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,10 @@ impl Game<'_> {
|
||||
let s = &self.settings;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.gamma = s.gamma;
|
||||
r.set_vsync(s.vsync);
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
|
||||
}
|
||||
if let Some(w) = &self.window {
|
||||
let want = s.fullscreen;
|
||||
@@ -207,6 +210,14 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let limit = self.framerate_limit();
|
||||
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock);
|
||||
match self.settings.horizon.pacing {
|
||||
horizon_client::graphics::Pacing::Precise => {
|
||||
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock)
|
||||
}
|
||||
horizon_client::graphics::Pacing::Efficient => {
|
||||
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
|
||||
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -831,7 +831,7 @@ impl Game<'_> {
|
||||
self.spawn_loaded_entities();
|
||||
let keys: HashSet<(i32, i32)> = unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
let chunks = self.entity_chunks(&keys, true);
|
||||
if let Err(e) = self.save.save_entities(chunks) {
|
||||
if let Err(e) = self.save.save_entities_later(chunks) {
|
||||
eprintln!("Horizon: failed to save entities: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,32 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
]
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
|
||||
pub(super) fn horizon_pause(&self) -> bool {
|
||||
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
|
||||
}
|
||||
fn pause_labels(&self) -> (String, &'static str) {
|
||||
let world = if self.remote.is_some() {
|
||||
"Multiplayer".to_owned()
|
||||
} else {
|
||||
self.level.name.clone()
|
||||
};
|
||||
let quit = if self.remote.is_some() {
|
||||
"Disconnect"
|
||||
} else {
|
||||
"Save and quit"
|
||||
};
|
||||
(world, quit)
|
||||
}
|
||||
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
|
||||
let (world, quit) = self.pause_labels();
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.res.atlas, batch, self.cursor);
|
||||
horizon_client::front::pause::draw(&mut ui, &world, quit, opened);
|
||||
ui.finish();
|
||||
return;
|
||||
}
|
||||
let atlas = &self.res.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -102,10 +127,27 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let hit = pause_buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4);
|
||||
let hit = if horizon_client::ui_theme::horizon() {
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let mut b = HudBatch::new(size);
|
||||
let (world, quit) = self.pause_labels();
|
||||
horizon_client::front::pause::click(
|
||||
&self.res.atlas,
|
||||
&mut b,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
&world,
|
||||
quit,
|
||||
)
|
||||
} else {
|
||||
pause_buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
};
|
||||
match hit {
|
||||
Some(0) => {
|
||||
self.screen = Screen::Playing;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! One vertex buffer for every terrain section: sections own ranges of it, so
|
||||
//! a frame binds it once and draws every section with indirect multi-draw.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation.
|
||||
const GRAIN: u64 = 256;
|
||||
|
||||
/// Free vertex ranges: start -> length, never adjacent.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct FreeList(BTreeMap<u64, u64>);
|
||||
|
||||
impl FreeList {
|
||||
fn new(capacity: u64) -> Self {
|
||||
Self(BTreeMap::from([(0, capacity)]))
|
||||
}
|
||||
/// First fit.
|
||||
fn take(&mut self, len: u64) -> Option<u64> {
|
||||
let (&start, &free) = self.0.iter().find(|(_, l)| **l >= len)?;
|
||||
self.0.remove(&start);
|
||||
if free > len {
|
||||
self.0.insert(start + len, free - len);
|
||||
}
|
||||
Some(start)
|
||||
}
|
||||
fn give(&mut self, start: u64, len: u64) {
|
||||
let (mut start, mut len) = (start, len);
|
||||
if let Some((&prev, &prev_len)) = self.0.range(..start).next_back()
|
||||
&& prev + prev_len == start
|
||||
{
|
||||
self.0.remove(&prev);
|
||||
start = prev;
|
||||
len += prev_len;
|
||||
}
|
||||
if let Some(next_len) = self.0.remove(&(start + len)) {
|
||||
len += next_len;
|
||||
}
|
||||
self.0.insert(start, len);
|
||||
}
|
||||
}
|
||||
|
||||
/// One vertex buffer of the arena; only the newest page grows.
|
||||
struct Page {
|
||||
buffer: wgpu::Buffer,
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
}
|
||||
|
||||
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
|
||||
/// buffer sizes): a section's vertices sit in one page.
|
||||
pub struct Arena {
|
||||
pages: Vec<Page>,
|
||||
stride: u64,
|
||||
page_limit: u64,
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
// 256 MiB pages, or less where the device's buffers are smaller.
|
||||
let bytes = device.limits().max_buffer_size.min(256 << 20);
|
||||
let page_limit = (bytes / stride) / GRAIN * GRAIN;
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
|
||||
Self {
|
||||
pages: vec![Page {
|
||||
buffer: Self::create(device, stride, capacity),
|
||||
capacity,
|
||||
free: FreeList::new(capacity),
|
||||
}],
|
||||
stride,
|
||||
page_limit,
|
||||
}
|
||||
}
|
||||
fn create(device: &wgpu::Device, stride: u64, capacity: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section vertex arena"),
|
||||
size: capacity * stride,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
/// The rounded length a request of `vertices` occupies.
|
||||
fn span(vertices: u64) -> u64 {
|
||||
vertices.max(1).next_multiple_of(GRAIN)
|
||||
}
|
||||
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize].buffer
|
||||
}
|
||||
/// A range of `vertices`: (page, first vertex). Grows the newest page
|
||||
/// (copying its contents on the GPU) or starts a new one when nothing
|
||||
/// free is large enough.
|
||||
pub fn alloc(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
vertices: u64,
|
||||
) -> (u32, u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if let Some(start) = page.free.take(len) {
|
||||
return (i as u32, start);
|
||||
}
|
||||
}
|
||||
let last = self.pages.len() - 1;
|
||||
let current = self.pages[last].capacity;
|
||||
if current < self.page_limit {
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
if grown - current >= len || grown == self.page_limit {
|
||||
self.grow(device, queue, last, grown);
|
||||
if let Some(start) = self.pages[last].free.take(len) {
|
||||
return (last as u32, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
let capacity = (1 << 20u64).max(len).min(self.page_limit);
|
||||
let mut free = FreeList::new(capacity);
|
||||
let start = free.take(len).expect("a new page fits one section");
|
||||
self.pages.push(Page {
|
||||
buffer: Self::create(device, self.stride, capacity),
|
||||
capacity,
|
||||
free,
|
||||
});
|
||||
((self.pages.len() - 1) as u32, start)
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
self.pages[page as usize].free.give(start, len);
|
||||
}
|
||||
fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, page: usize, capacity: u64) {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let buffer = Self::create(device, self.stride, capacity);
|
||||
let old = &mut self.pages[page];
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Grow section arena"),
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
// Writes queued for the old buffer run before this copy.
|
||||
queue.submit([encoder.finish()]);
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.capacity = capacity;
|
||||
old.free.give(previous, capacity - previous);
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
for page in &mut self.pages {
|
||||
page.free = FreeList::new(page.capacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_ranges_coalesce() {
|
||||
let mut free = FreeList::new(4 * GRAIN);
|
||||
let a = free.take(GRAIN).unwrap();
|
||||
let b = free.take(2 * GRAIN).unwrap();
|
||||
let c = free.take(GRAIN).unwrap();
|
||||
assert_eq!((a, b, c), (0, GRAIN, 3 * GRAIN));
|
||||
assert_eq!(free.take(1), None);
|
||||
free.give(a, GRAIN);
|
||||
free.give(c, GRAIN);
|
||||
// Two holes of one grain: a two-grain request does not fit.
|
||||
assert_eq!(free.take(2 * GRAIN), None);
|
||||
free.give(b, 2 * GRAIN);
|
||||
assert_eq!(free, FreeList::new(4 * GRAIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_fit_reuses_the_lowest_hole() {
|
||||
let mut free = FreeList::new(8 * GRAIN);
|
||||
let a = free.take(GRAIN).unwrap();
|
||||
let _b = free.take(GRAIN).unwrap();
|
||||
free.give(a, GRAIN);
|
||||
assert_eq!(free.take(GRAIN), Some(a));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `SectionOcclusionGraph`: which sections the camera can see through open
|
||||
//! faces. Breadth-first from the camera section through the faces each
|
||||
//! section's VisGraph connects, never stepping back towards the camera.
|
||||
//!
|
||||
//! The search runs over dense arrays around the camera rather than hash sets,
|
||||
//! so it stays cheap enough to redo whenever a section is meshed.
|
||||
use super::SectionKey;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
const STEPS: [(i32, i32, i32); 6] = [
|
||||
(0, -1, 0),
|
||||
(0, 1, 0),
|
||||
(0, 0, -1),
|
||||
(0, 0, 1),
|
||||
(-1, 0, 0),
|
||||
(1, 0, 0),
|
||||
];
|
||||
const NONE: u8 = 6;
|
||||
|
||||
pub struct Occlusion {
|
||||
/// VisGraph connectivity per column, one entry per section (bottom first).
|
||||
columns: HashMap<(i32, i32), Box<[u64]>>,
|
||||
sections: i32,
|
||||
dirty: bool,
|
||||
from: Option<SectionKey>,
|
||||
radius: i32,
|
||||
/// The searched box: lowest section y and its height in sections.
|
||||
y0: i32,
|
||||
height: i32,
|
||||
reached: Vec<bool>,
|
||||
vis: Vec<u64>,
|
||||
queue: VecDeque<(u32, u8, u8)>,
|
||||
}
|
||||
|
||||
impl Occlusion {
|
||||
pub fn new(sections: i32) -> Self {
|
||||
Self {
|
||||
columns: HashMap::new(),
|
||||
sections,
|
||||
dirty: true,
|
||||
from: None,
|
||||
radius: 0,
|
||||
y0: 0,
|
||||
height: 0,
|
||||
reached: Vec::new(),
|
||||
vis: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, key: SectionKey, vis: u64) {
|
||||
if !(0..self.sections).contains(&key.1) {
|
||||
return;
|
||||
}
|
||||
let sections = self.sections as usize;
|
||||
let column = self
|
||||
.columns
|
||||
.entry((key.0, key.2))
|
||||
.or_insert_with(|| vec![crate::mesher::ALL_VISIBLE; sections].into());
|
||||
let slot = &mut column[key.1 as usize];
|
||||
if *slot != vis {
|
||||
*slot = vis;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, key: SectionKey) {
|
||||
let Some(column) = self.columns.get_mut(&(key.0, key.2)) else {
|
||||
return;
|
||||
};
|
||||
if let Some(slot) = column.get_mut(key.1 as usize)
|
||||
&& *slot != crate::mesher::ALL_VISIBLE
|
||||
{
|
||||
*slot = crate::mesher::ALL_VISIBLE;
|
||||
self.dirty = true;
|
||||
}
|
||||
if column.iter().all(|&v| v == crate::mesher::ALL_VISIBLE) {
|
||||
self.columns.remove(&(key.0, key.2));
|
||||
}
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.columns.clear();
|
||||
self.dirty = true;
|
||||
}
|
||||
fn index(&self, key: SectionKey) -> Option<usize> {
|
||||
let from = self.from?;
|
||||
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 - self.y0);
|
||||
let side = 2 * self.radius + 1;
|
||||
if dx.abs() > self.radius || dz.abs() > self.radius || !(0..self.height).contains(&dy) {
|
||||
return None;
|
||||
}
|
||||
Some((((dz + self.radius) * side + dx + self.radius) * self.height + dy) as usize)
|
||||
}
|
||||
fn key(&self, index: usize) -> SectionKey {
|
||||
let from = self.from.expect("searched");
|
||||
let side = 2 * self.radius + 1;
|
||||
let i = index as i32;
|
||||
let dy = i % self.height;
|
||||
let column = i / self.height;
|
||||
(
|
||||
from.0 + column % side - self.radius,
|
||||
self.y0 + dy,
|
||||
from.2 + column / side - self.radius,
|
||||
)
|
||||
}
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
self.index(key).is_some_and(|i| self.reached[i])
|
||||
}
|
||||
/// Search again when sections changed or the camera moved to another section.
|
||||
pub fn update(&mut self, from: SectionKey, radius: i32) {
|
||||
if !self.dirty && self.from == Some(from) && self.radius == radius {
|
||||
return;
|
||||
}
|
||||
self.from = Some(from);
|
||||
self.radius = radius;
|
||||
self.y0 = from.1.min(-1);
|
||||
self.height = from.1.max(self.sections) - self.y0 + 1;
|
||||
let side = (2 * radius + 1) as usize;
|
||||
let size = side * side * self.height as usize;
|
||||
self.reached.clear();
|
||||
self.reached.resize(size, false);
|
||||
self.vis.clear();
|
||||
self.vis.resize(size, crate::mesher::ALL_VISIBLE);
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let Some(column) = self.columns.get(&(from.0 + dx, from.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
let base = self
|
||||
.index((from.0 + dx, 0, from.2 + dz))
|
||||
.expect("column inside the searched box");
|
||||
self.vis[base..base + column.len()].copy_from_slice(column);
|
||||
}
|
||||
}
|
||||
let start = self.index(from).expect("camera inside the searched box");
|
||||
self.reached[start] = true;
|
||||
self.queue.clear();
|
||||
self.queue.push_back((start as u32, NONE, 0));
|
||||
while let Some((index, entered, travelled)) = self.queue.pop_front() {
|
||||
let vis = self.vis[index as usize];
|
||||
let key = self.key(index as usize);
|
||||
for (d, step) in STEPS.iter().enumerate() {
|
||||
let opposite = d ^ 1;
|
||||
if travelled & (1 << opposite) != 0 {
|
||||
continue;
|
||||
}
|
||||
if entered != NONE && vis & (1 << (usize::from(entered) * 6 + d)) == 0 {
|
||||
continue;
|
||||
}
|
||||
let next = (key.0 + step.0, key.1 + step.1, key.2 + step.2);
|
||||
if next.1 < -1 || next.1 > self.sections {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = self.index(next) else { continue };
|
||||
if !self.reached[n] {
|
||||
self.reached[n] = true;
|
||||
self.queue
|
||||
.push_back((n as u32, opposite as u8, travelled | (1 << d)));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::ALL_VISIBLE;
|
||||
|
||||
#[test]
|
||||
fn open_space_reaches_the_whole_box() {
|
||||
let mut o = Occlusion::new(4);
|
||||
o.update((0, 1, 0), 2);
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
for y in -1..=4 {
|
||||
assert!(o.contains((x, y, z)), "{x} {y} {z}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!o.contains((3, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_neighbours_hide_what_is_behind_them() {
|
||||
let mut o = Occlusion::new(4);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
o.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
o.update((0, 1, 0), 3);
|
||||
assert!(o.contains((1, 1, 0)), "a neighbour is seen");
|
||||
assert!(!o.contains((2, 1, 0)), "nothing shows through it");
|
||||
o.set((1, 1, 0), ALL_VISIBLE);
|
||||
o.update((0, 1, 0), 3);
|
||||
assert!(o.contains((2, 1, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! GPU frame time from timestamp queries, read back a few frames late so the
|
||||
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
const SLOTS: usize = 4;
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
pub struct GpuTimer {
|
||||
queries: wgpu::QuerySet,
|
||||
resolve: wgpu::Buffer,
|
||||
slots: Vec<Slot>,
|
||||
/// The slot this frame writes into, when one was free at `begin`.
|
||||
current: Option<usize>,
|
||||
next: usize,
|
||||
period_ns: f32,
|
||||
}
|
||||
|
||||
impl GpuTimer {
|
||||
/// The features timing needs; `None` when the adapter lacks them.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let wanted =
|
||||
wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS;
|
||||
if adapter.features().contains(wanted) {
|
||||
wanted
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
|
||||
if !device
|
||||
.features()
|
||||
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("Frame timestamps"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: 2,
|
||||
});
|
||||
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame timestamp resolve"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slots = (0..SLOTS)
|
||||
.map(|_| Slot {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame timestamp readback"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
queries,
|
||||
resolve,
|
||||
slots,
|
||||
current: None,
|
||||
next: 0,
|
||||
period_ns: queue.get_timestamp_period(),
|
||||
})
|
||||
}
|
||||
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let slot = self.next;
|
||||
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
|
||||
if self.current.is_some() {
|
||||
encoder.write_timestamp(&self.queries, 0);
|
||||
}
|
||||
}
|
||||
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(slot) = self.current else { return };
|
||||
encoder.write_timestamp(&self.queries, 1);
|
||||
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
|
||||
}
|
||||
/// After the frame's submission: start reading its timestamps back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(slot) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
self.next = (slot + 1) % SLOTS;
|
||||
}
|
||||
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
|
||||
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
for i in 0..SLOTS {
|
||||
let slot = &self.slots[(self.next + i) % SLOTS];
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
|
||||
let stamps: &[u64] = bytemuck::cast_slice(&data);
|
||||
let ticks = stamps[1].saturating_sub(stamps[0]);
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
}
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither
|
||||
//! setting on, the world renders straight to the screen as before.
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
color: wgpu::TextureView,
|
||||
depth: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../world_post.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon post bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |label, format, usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let color = texture(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let depth = texture(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
});
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
let Some(target) = &self.target else { return };
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: screen,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
});
|
||||
pass.set_bind_group(0, &target.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Horizon's own graphics settings, beyond vanilla's options: kept in
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
Performance,
|
||||
Balanced,
|
||||
Quality,
|
||||
Ultra,
|
||||
#[default]
|
||||
Custom,
|
||||
}
|
||||
impl Preset {
|
||||
pub const ALL: [Preset; 5] = [
|
||||
Preset::Performance,
|
||||
Preset::Balanced,
|
||||
Preset::Quality,
|
||||
Preset::Ultra,
|
||||
Preset::Custom,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Preset::Performance => "performance",
|
||||
Preset::Balanced => "balanced",
|
||||
Preset::Quality => "quality",
|
||||
Preset::Ultra => "ultra",
|
||||
Preset::Custom => "custom",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Preset::Performance => "Performance",
|
||||
Preset::Balanced => "Balanced",
|
||||
Preset::Quality => "Quality",
|
||||
Preset::Ultra => "Ultra",
|
||||
Preset::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|p| p.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// How the swapchain presents frames.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PresentMode {
|
||||
/// Follow vanilla's VSync option (Mailbox or Fifo on, Immediate off).
|
||||
#[default]
|
||||
Auto,
|
||||
Fifo,
|
||||
Mailbox,
|
||||
Immediate,
|
||||
}
|
||||
impl PresentMode {
|
||||
pub const ALL: [PresentMode; 4] = [
|
||||
PresentMode::Auto,
|
||||
PresentMode::Fifo,
|
||||
PresentMode::Mailbox,
|
||||
PresentMode::Immediate,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
PresentMode::Auto => "auto",
|
||||
PresentMode::Fifo => "fifo",
|
||||
PresentMode::Mailbox => "mailbox",
|
||||
PresentMode::Immediate => "immediate",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PresentMode::Auto => "Auto (VSync)",
|
||||
PresentMode::Fifo => "FIFO",
|
||||
PresentMode::Mailbox => "Mailbox",
|
||||
PresentMode::Immediate => "Immediate",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|p| p.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the frame limiter does in the last half millisecond before a frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Pacing {
|
||||
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
|
||||
#[default]
|
||||
Efficient,
|
||||
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
|
||||
Precise,
|
||||
}
|
||||
|
||||
/// The performance overlay drawn over the HUD.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Overlay {
|
||||
#[default]
|
||||
Off,
|
||||
/// Frames per second and frame time.
|
||||
Fps,
|
||||
/// The numbers and a graph of recent frame times.
|
||||
Graph,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Graphics {
|
||||
pub preset: Preset,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
pub upload_budget_kib: u32,
|
||||
pub sky: bool,
|
||||
pub fog: bool,
|
||||
pub weather: bool,
|
||||
pub animated_textures: bool,
|
||||
pub present_mode: PresentMode,
|
||||
pub pacing: Pacing,
|
||||
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
|
||||
pub workers: u32,
|
||||
pub overlay: Overlay,
|
||||
/// World resolution as a percentage of the window's (50..=200).
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// The settings level last shown: Advanced, or Simple.
|
||||
pub advanced: bool,
|
||||
}
|
||||
impl Default for Graphics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preset: Preset::Custom,
|
||||
cave_culling: true,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
sky: true,
|
||||
fog: true,
|
||||
weather: true,
|
||||
animated_textures: true,
|
||||
present_mode: PresentMode::Auto,
|
||||
pacing: Pacing::Efficient,
|
||||
workers: 0,
|
||||
overlay: Overlay::Off,
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
advanced: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Graphics {
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut g = Self::default();
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
let flag = |d: bool| match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => d,
|
||||
};
|
||||
let number = |d: u32| value.parse().unwrap_or(d);
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"sky" => g.sky = flag(g.sky),
|
||||
"fog" => g.fog = flag(g.fog),
|
||||
"weather" => g.weather = flag(g.weather),
|
||||
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
|
||||
"presentMode" => g.present_mode = PresentMode::parse(value),
|
||||
"framePacing" => {
|
||||
g.pacing = if value == "precise" {
|
||||
Pacing::Precise
|
||||
} else {
|
||||
Pacing::Efficient
|
||||
}
|
||||
}
|
||||
"workers" => g.workers = number(g.workers).min(64),
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"level" => g.advanced = value == "advanced",
|
||||
"overlay" => {
|
||||
g.overlay = match value {
|
||||
"fps" => Overlay::Fps,
|
||||
"graph" => Overlay::Graph,
|
||||
_ => Overlay::Off,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\ncaveCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nlevel:{}\n",
|
||||
self.preset.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
bool_text(self.sky),
|
||||
bool_text(self.fog),
|
||||
bool_text(self.weather),
|
||||
bool_text(self.animated_textures),
|
||||
self.present_mode.name(),
|
||||
match self.pacing {
|
||||
Pacing::Efficient => "efficient",
|
||||
Pacing::Precise => "precise",
|
||||
},
|
||||
self.workers,
|
||||
match self.overlay {
|
||||
Overlay::Off => "off",
|
||||
Overlay::Fps => "fps",
|
||||
Overlay::Graph => "graph",
|
||||
},
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
if self.advanced { "advanced" } else { "simple" },
|
||||
)
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
return self.workers as usize;
|
||||
}
|
||||
std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(1)
|
||||
.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset's values for vanilla's options and Horizon's own.
|
||||
pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
let (rd, sim, clouds, particles, shadows, blend, mipmaps, entity, leaves, weather_r) =
|
||||
match preset {
|
||||
Preset::Performance => (8, 6, "false", 2, false, 0, 0, 0.5, false, 3),
|
||||
Preset::Balanced => (12, 8, "fast", 1, true, 1, 2, 0.75, true, 5),
|
||||
Preset::Quality => (16, 12, "true", 0, true, 2, 4, 1.0, true, 10),
|
||||
Preset::Ultra => (24, 12, "true", 0, true, 3, 4, 1.5, true, 10),
|
||||
Preset::Custom => return,
|
||||
};
|
||||
s.render_distance = rd;
|
||||
s.simulation_distance = sim;
|
||||
s.clouds = clouds.into();
|
||||
s.particles = particles;
|
||||
s.entity_shadows = shadows;
|
||||
s.biome_blend = blend;
|
||||
s.mipmap_levels = mipmaps;
|
||||
s.entity_distance = entity;
|
||||
s.cutout_leaves = leaves;
|
||||
s.weather_radius = weather_r;
|
||||
s.graphics_preset = "custom".into();
|
||||
let g = &mut s.horizon;
|
||||
g.preset = preset;
|
||||
g.cave_culling = true;
|
||||
g.animated_textures = preset != Preset::Performance;
|
||||
g.chunk_updates = 0;
|
||||
g.upload_budget_kib = 0;
|
||||
g.sky = true;
|
||||
g.fog = true;
|
||||
g.weather = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let g = Graphics {
|
||||
preset: Preset::Balanced,
|
||||
cave_culling: false,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
sky: false,
|
||||
fog: false,
|
||||
weather: false,
|
||||
animated_textures: false,
|
||||
present_mode: PresentMode::Mailbox,
|
||||
pacing: Pacing::Precise,
|
||||
workers: 6,
|
||||
overlay: Overlay::Graph,
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
advanced: true,
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The look of Horizon's graphics settings screens, in one place: sizes,
|
||||
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
|
||||
//! lay out rows and handle input; a menu theme replaces this module.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// What a setting costs in frame time, shown beside its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Cost {
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
impl Cost {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Cost::None => "No cost",
|
||||
Cost::Low => "Low cost",
|
||||
Cost::Medium => "Medium cost",
|
||||
Cost::High => "High cost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Style {
|
||||
/// Width of a setting's button or slider.
|
||||
pub control_width: f32,
|
||||
/// Space between the control and its description.
|
||||
pub gap: f32,
|
||||
/// Widest description column; narrower on small screens.
|
||||
pub max_text_width: f32,
|
||||
/// Vertical distance between setting rows.
|
||||
pub row_height: f32,
|
||||
pub header_height: f32,
|
||||
/// Top and bottom bands holding the title and the buttons.
|
||||
pub band: f32,
|
||||
pub title_color: u32,
|
||||
pub header_color: u32,
|
||||
pub description_color: u32,
|
||||
/// Colour of each `Cost`, in its order.
|
||||
pub cost_colors: [u32; 4],
|
||||
}
|
||||
|
||||
/// `hud::rgba` with full alpha, usable in constants.
|
||||
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
|
||||
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
|
||||
}
|
||||
|
||||
pub const STYLE: Style = Style {
|
||||
control_width: 150.0,
|
||||
gap: 8.0,
|
||||
max_text_width: 250.0,
|
||||
row_height: 25.0,
|
||||
header_height: 16.0,
|
||||
band: 33.0,
|
||||
title_color: u32::MAX,
|
||||
header_color: opaque(255, 255, 85),
|
||||
description_color: opaque(170, 170, 170),
|
||||
cost_colors: [
|
||||
opaque(120, 200, 120),
|
||||
opaque(170, 220, 120),
|
||||
opaque(240, 200, 90),
|
||||
opaque(240, 110, 90),
|
||||
],
|
||||
};
|
||||
|
||||
impl Style {
|
||||
/// Width of the description column on a screen `screen_width` wide.
|
||||
pub fn text_width(&self, screen_width: f32) -> f32 {
|
||||
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
|
||||
}
|
||||
/// Left edge of a row's control, centring control and description.
|
||||
pub fn row_x(&self, screen_width: f32) -> f32 {
|
||||
let total = self.control_width + self.gap + self.text_width(screen_width);
|
||||
((screen_width - total) / 2.0).floor()
|
||||
}
|
||||
pub fn background(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
ui::list_background(batch, atlas, top, bottom, in_world);
|
||||
}
|
||||
pub fn frame(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
in_world: bool,
|
||||
) {
|
||||
ui::separators(batch, atlas, top, bottom, in_world);
|
||||
}
|
||||
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
|
||||
let w = batch.width;
|
||||
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
|
||||
}
|
||||
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
|
||||
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
|
||||
}
|
||||
pub fn button(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
(x, y, w): (f32, f32, f32),
|
||||
label: &Text,
|
||||
hover: bool,
|
||||
) {
|
||||
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
|
||||
}
|
||||
pub fn slider(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
(x, y, w): (f32, f32, f32),
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cost: Cost,
|
||||
text: &str,
|
||||
) {
|
||||
let tag = Text::literal(cost.label());
|
||||
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
|
||||
let indent = atlas.font.width_text(&tag) + 4.0;
|
||||
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
|
||||
for (i, line) in lines.iter().take(2).enumerate() {
|
||||
let lx = if i == 0 { x + indent } else { x };
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(line.clone()),
|
||||
lx,
|
||||
y + i as f32 * 10.0,
|
||||
self.description_color,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy word wrap; the first line is `indent` narrower.
|
||||
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
|
||||
let mut lines = vec![String::new()];
|
||||
for word in text.split_whitespace() {
|
||||
let room = if lines.len() == 1 {
|
||||
width - indent
|
||||
} else {
|
||||
width
|
||||
};
|
||||
let line = lines.last().expect("one line");
|
||||
let candidate = if line.is_empty() {
|
||||
word.to_owned()
|
||||
} else {
|
||||
format!("{line} {word}")
|
||||
};
|
||||
if atlas.font.width(&candidate) <= room || line.is_empty() {
|
||||
*lines.last_mut().expect("one line") = candidate;
|
||||
} else {
|
||||
lines.push(word.to_owned());
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{Overlay, Pacing, PresentMode, Preset},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
options_ui::Opt,
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum H {
|
||||
Preset,
|
||||
CaveCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
Fog,
|
||||
Weather,
|
||||
AnimatedTextures,
|
||||
PresentMode,
|
||||
Pacing,
|
||||
Workers,
|
||||
Overlay,
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Item {
|
||||
Vanilla(Opt),
|
||||
Horizon(H),
|
||||
}
|
||||
|
||||
pub struct Setting {
|
||||
pub item: Item,
|
||||
pub cost: Cost,
|
||||
pub text: &'static str,
|
||||
}
|
||||
pub enum Row {
|
||||
Header(&'static str),
|
||||
Setting(Setting),
|
||||
}
|
||||
fn v(opt: Opt, cost: Cost, text: &'static str) -> Row {
|
||||
Row::Setting(Setting {
|
||||
item: Item::Vanilla(opt),
|
||||
cost,
|
||||
text,
|
||||
})
|
||||
}
|
||||
fn h(opt: H, cost: Cost, text: &'static str) -> Row {
|
||||
Row::Setting(Setting {
|
||||
item: Item::Horizon(opt),
|
||||
cost,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings of a level, grouped under headers (Simple has no headers).
|
||||
pub fn rows(level: Level) -> Vec<Row> {
|
||||
use Cost::*;
|
||||
let render = v(
|
||||
Opt::RenderDistance,
|
||||
High,
|
||||
"How far terrain is drawn, in chunks. The biggest lever on frame time.",
|
||||
);
|
||||
let fov = v(
|
||||
Opt::Fov,
|
||||
Low,
|
||||
"Field of view. Wider shows more terrain at once.",
|
||||
);
|
||||
let fps = v(
|
||||
Opt::MaxFps,
|
||||
None,
|
||||
"Caps frames per second; a cap saves power and heat.",
|
||||
);
|
||||
let vsync = v(
|
||||
Opt::Vsync,
|
||||
None,
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
match level {
|
||||
Level::Simple => vec![preset, render, fov, fps, vsync, gui],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
"How far mobs, crops and machines keep working. CPU time.",
|
||||
),
|
||||
v(
|
||||
Opt::EntityDistance,
|
||||
Medium,
|
||||
"How far away mobs and items are drawn, as a share of normal.",
|
||||
),
|
||||
h(
|
||||
H::CaveCulling,
|
||||
None,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
Opt::SmoothLighting,
|
||||
Low,
|
||||
"Soft shadows in corners. Costs chunk building time only.",
|
||||
),
|
||||
v(
|
||||
Opt::Mipmaps,
|
||||
Low,
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
),
|
||||
v(
|
||||
Opt::BiomeBlend,
|
||||
Low,
|
||||
"Blends grass and water colours across biomes when building chunks.",
|
||||
),
|
||||
v(
|
||||
Opt::Clouds,
|
||||
Medium,
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).",
|
||||
),
|
||||
v(Opt::CloudRange, Low, "How far clouds reach, in chunks."),
|
||||
h(H::Sky, Low, "Draws the sky, sun, moon and stars."),
|
||||
h(
|
||||
H::Fog,
|
||||
None,
|
||||
"Distance fog. Off shows the edge of the loaded world.",
|
||||
),
|
||||
h(H::Weather, Low, "Draws rain and snow."),
|
||||
v(
|
||||
Opt::WeatherRadius,
|
||||
Low,
|
||||
"How far rain and snow fall around you.",
|
||||
),
|
||||
v(Opt::Particles, Medium, "How many particles effects spawn."),
|
||||
v(
|
||||
Opt::EntityShadows,
|
||||
Low,
|
||||
"Round shadows under mobs and items.",
|
||||
),
|
||||
v(
|
||||
Opt::CutoutLeaves,
|
||||
Medium,
|
||||
"Leaves with see-through gaps. Off draws them solid.",
|
||||
),
|
||||
v(
|
||||
Opt::ImprovedTransparency,
|
||||
Medium,
|
||||
"Sorts water and glass more exactly behind each other.",
|
||||
),
|
||||
v(
|
||||
Opt::ChunkBuilder,
|
||||
Low,
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.",
|
||||
),
|
||||
h(
|
||||
H::AnimatedTextures,
|
||||
Low,
|
||||
"Animates water, lava, fire and other moving textures.",
|
||||
),
|
||||
Row::Header("Display"),
|
||||
fov,
|
||||
fps,
|
||||
vsync,
|
||||
h(
|
||||
H::ResolutionScale,
|
||||
High,
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.",
|
||||
),
|
||||
h(
|
||||
H::Antialiasing,
|
||||
Low,
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
"How frames reach the screen. Auto follows VSync.",
|
||||
),
|
||||
h(
|
||||
H::Pacing,
|
||||
None,
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.",
|
||||
),
|
||||
v(Opt::Fullscreen, None, "Fills the screen (borderless)."),
|
||||
gui,
|
||||
v(Opt::Gamma, None, "Brightens dark places."),
|
||||
v(
|
||||
Opt::InactivityFps,
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
v(Opt::Vignette, None, "Darkens the screen's edges."),
|
||||
v(Opt::Autosave, None, "Shows when the world saves."),
|
||||
v(Opt::AttackIndicator, None, "Shows attack cooldown."),
|
||||
Row::Header("Performance"),
|
||||
h(
|
||||
H::Workers,
|
||||
None,
|
||||
"Threads that generate, light and mesh chunks. Next start.",
|
||||
),
|
||||
h(
|
||||
H::UploadBudget,
|
||||
Low,
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Overlay"),
|
||||
h(
|
||||
H::Overlay,
|
||||
None,
|
||||
"Frame rate and frame times over the game, with a graph.",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "On" } else { "Off" }
|
||||
}
|
||||
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
|
||||
Text::literal(format!("{caption}: {value}"))
|
||||
}
|
||||
|
||||
impl H {
|
||||
fn slider(self, s: &Settings) -> Option<f32> {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
H::ChunkUpdates => Some(g.chunk_updates as f32 / 64.0),
|
||||
H::UploadBudget => {
|
||||
let i = UPLOAD_STEPS
|
||||
.iter()
|
||||
.position(|&b| b >= g.upload_budget_kib)
|
||||
.unwrap_or(UPLOAD_STEPS.len() - 1);
|
||||
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self, s: &Settings) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", format!("{n} per frame")),
|
||||
},
|
||||
H::UploadBudget => match g.upload_budget_kib {
|
||||
0 => labelled("Upload Budget", "Unlimited"),
|
||||
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
|
||||
k => labelled("Upload Budget", format!("{k} KiB/frame")),
|
||||
},
|
||||
H::Sky => labelled("Sky", on_off(g.sky)),
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
H::Weather => labelled("Weather", on_off(g.weather)),
|
||||
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
|
||||
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
|
||||
H::Pacing => labelled(
|
||||
"Frame Pacing",
|
||||
match g.pacing {
|
||||
Pacing::Efficient => "Efficient",
|
||||
Pacing::Precise => "Precise",
|
||||
},
|
||||
),
|
||||
H::Workers => match g.workers {
|
||||
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
Overlay::Off => "Off",
|
||||
Overlay::Fps => "FPS",
|
||||
Overlay::Graph => "FPS and Graph",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
fn set_slider(self, s: &mut Settings, v: f32) {
|
||||
let g = &mut s.horizon;
|
||||
match self {
|
||||
H::ChunkUpdates => g.chunk_updates = (v * 64.0).round() as u32,
|
||||
H::UploadBudget => {
|
||||
g.upload_budget_kib =
|
||||
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn cycle(self, s: &mut Settings, back: bool) {
|
||||
fn step<T: Copy + PartialEq>(all: &[T], now: T, back: bool) -> T {
|
||||
let i = all.iter().position(|x| *x == now).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
if self == H::Preset {
|
||||
let preset = step(&Preset::ALL, s.horizon.preset, back);
|
||||
crate::graphics::apply_preset(preset, s);
|
||||
s.horizon.preset = preset;
|
||||
return;
|
||||
}
|
||||
let g = &mut s.horizon;
|
||||
match self {
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
H::Antialiasing => g.fxaa = !g.fxaa,
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
Pacing::Efficient => Pacing::Precise,
|
||||
Pacing::Precise => Pacing::Efficient,
|
||||
}
|
||||
}
|
||||
H::Overlay => {
|
||||
g.overlay = step(
|
||||
&[Overlay::Off, Overlay::Fps, Overlay::Graph],
|
||||
g.overlay,
|
||||
back,
|
||||
)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn max_workers() -> u32 {
|
||||
std::thread::available_parallelism().map_or(8, |n| n.get() as u32)
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// On/off settings, drawn as switches by the Horizon menus.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::Antialiasing) => g.fxaa,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
|
||||
Item::Vanilla(Opt::SmoothLighting) => s.smooth_lighting,
|
||||
Item::Vanilla(Opt::EntityShadows) => s.entity_shadows,
|
||||
Item::Vanilla(Opt::Vignette) => s.vignette,
|
||||
Item::Vanilla(Opt::Autosave) => s.autosave_indicator,
|
||||
Item::Vanilla(Opt::CutoutLeaves) => s.cutout_leaves,
|
||||
Item::Vanilla(Opt::ImprovedTransparency) => s.improved_transparency,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Caption and value text, split from the label ("Caption: Value").
|
||||
pub fn caption_value(self, s: &Settings) -> (String, String) {
|
||||
let label = self.label(s).plain();
|
||||
match label.split_once(": ") {
|
||||
Some((c, v)) => (c.to_owned(), v.to_owned()),
|
||||
None => (label, String::new()),
|
||||
}
|
||||
}
|
||||
pub fn slider(self, s: &Settings) -> Option<f32> {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.slider_position(s),
|
||||
Item::Horizon(h) => h.slider(s),
|
||||
}
|
||||
}
|
||||
pub fn label(self, s: &Settings) -> Text {
|
||||
match self {
|
||||
// The vanilla button shows the scale alone, under its own caption.
|
||||
Item::Vanilla(Opt::GuiScale) => Text::translate(
|
||||
"options.generic_value",
|
||||
&[
|
||||
Text::translate("options.guiScale", &[]),
|
||||
Opt::GuiScale.label(s),
|
||||
],
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s),
|
||||
}
|
||||
}
|
||||
pub fn set_slider(self, s: &mut Settings, v: f32) {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.set_slider(s, v),
|
||||
Item::Horizon(h) => h.set_slider(s, v),
|
||||
}
|
||||
self.touched(s);
|
||||
}
|
||||
pub fn cycle(self, s: &mut Settings, back: bool) {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.cycle(s, back),
|
||||
Item::Horizon(h) => h.cycle(s, back),
|
||||
}
|
||||
self.touched(s);
|
||||
}
|
||||
/// A preset no longer describes the settings once one of them changes.
|
||||
fn touched(self, s: &mut Settings) {
|
||||
let presetless = matches!(
|
||||
self,
|
||||
Item::Horizon(H::Preset | H::Overlay | H::Pacing | H::PresentMode | H::Workers)
|
||||
| Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
| Opt::Fullscreen
|
||||
| Opt::Vsync
|
||||
| Opt::MaxFps
|
||||
| Opt::Fov
|
||||
| Opt::Gamma
|
||||
| Opt::InactivityFps
|
||||
| Opt::ViewBobbing
|
||||
| Opt::ChunkFade
|
||||
| Opt::Vignette
|
||||
| Opt::Autosave
|
||||
| Opt::AttackIndicator
|
||||
)
|
||||
);
|
||||
if !presetless {
|
||||
s.horizon.preset = Preset::Custom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the options screens must do after an interaction here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Changed,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
Item(Item),
|
||||
Button(Action),
|
||||
}
|
||||
struct Widget {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
target: Target,
|
||||
/// In the scrolling list (clipped to it).
|
||||
list: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.dragging = None;
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
/// Widgets and the list's content height.
|
||||
fn layout(&self, level: Level, w: f32, h: f32) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let start = y;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in rows(level) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
headers.push((y, name));
|
||||
y += STYLE.header_height;
|
||||
}
|
||||
Row::Setting(s) => {
|
||||
widgets.push(Widget {
|
||||
x,
|
||||
y,
|
||||
w: STYLE.control_width,
|
||||
target: Target::Item(s.item),
|
||||
list: true,
|
||||
});
|
||||
y += STYLE.row_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
let content = y - start + 4.0;
|
||||
let cx = (w / 2.0).floor();
|
||||
let by = h - 27.0;
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
y: by,
|
||||
w: 150.0,
|
||||
target: Target::Button(other),
|
||||
list: false,
|
||||
});
|
||||
widgets.push(Widget {
|
||||
x: cx + 5.0,
|
||||
y: by,
|
||||
w: 150.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
(widgets, headers, content)
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &Settings,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (_, _, content) = self.layout(level, w, h);
|
||||
self.clamp_scroll(h, content);
|
||||
let (widgets, headers, _) = self.layout(level, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
STYLE.background(batch, atlas, top, bottom, in_world);
|
||||
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
|
||||
let x = STYLE.row_x(w);
|
||||
for (y, name) in headers {
|
||||
if shown(y, STYLE.header_height) {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = rows(level)
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Setting(s) => Some((s.cost, s.text)),
|
||||
Row::Header(_) => None,
|
||||
})
|
||||
.collect();
|
||||
let mut index = 0;
|
||||
for wd in &widgets {
|
||||
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
|
||||
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
|
||||
match wd.target {
|
||||
Target::Item(item) => {
|
||||
let (cost, text) = descriptions[index];
|
||||
index += 1;
|
||||
if !shown(wd.y, 20.0) {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
|
||||
}
|
||||
STYLE.description(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x + wd.w + STYLE.gap,
|
||||
wd.y + 1.0,
|
||||
cost,
|
||||
text,
|
||||
);
|
||||
}
|
||||
Target::Button(action) => {
|
||||
let label = match action {
|
||||
Action::OpenAdvanced => "Advanced...",
|
||||
Action::OpenSimple => "Simple...",
|
||||
_ => "Done",
|
||||
};
|
||||
STYLE.button(
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&Text::literal(label),
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
STYLE.frame(batch, atlas, top, bottom, in_world);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
}
|
||||
/// A press (or a drag with `drag`) at `(x, y)`.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &mut Settings,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
|
||||
{
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
return vec![Action::Changed];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
}
|
||||
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
|
||||
continue;
|
||||
}
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![action],
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
} else {
|
||||
item.cycle(s, right);
|
||||
}
|
||||
vec![Action::Changed]
|
||||
}
|
||||
};
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.dragging = None;
|
||||
}
|
||||
pub fn scroll(&mut self, amount: f32) {
|
||||
self.scroll -= amount * 18.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_advanced_setting_has_a_description() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
for row in rows(level) {
|
||||
if let Row::Setting(s) = row {
|
||||
assert!(!s.text.is_empty(), "{:?}", s.item);
|
||||
assert!(s.text.len() < 80, "one line: {:?}", s.item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
Item::Horizon(H::Preset).cycle(&mut s, false);
|
||||
assert_eq!(s.horizon.preset, Preset::Performance);
|
||||
assert_eq!(s.render_distance, 8);
|
||||
assert_eq!(s.clouds, "false");
|
||||
// Any quality change leaves the preset.
|
||||
Item::Vanilla(Opt::Clouds).cycle(&mut s, false);
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
// Display preferences keep it.
|
||||
Item::Horizon(H::Preset).cycle(&mut s, true);
|
||||
Item::Vanilla(Opt::Vsync).cycle(&mut s, false);
|
||||
assert_eq!(s.horizon.preset, Preset::Ultra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_budget_slider_steps() {
|
||||
let mut s = Settings::default();
|
||||
H::UploadBudget.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.horizon.upload_budget_kib, 16384);
|
||||
assert_eq!(H::UploadBudget.slider(&s), Some(1.0));
|
||||
H::UploadBudget.set_slider(&mut s, 0.0);
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,13 @@ impl HudAtlas {
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
2048,
|
||||
1024,
|
||||
vec![0; 2048 * 1024 * 4],
|
||||
));
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
|
||||
@@ -40,14 +40,19 @@ pub mod enchant;
|
||||
pub mod entities;
|
||||
pub mod entity_nbt;
|
||||
pub mod environment;
|
||||
pub mod fast_hash;
|
||||
pub mod fastutil_order;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod front;
|
||||
pub mod gamerules;
|
||||
pub mod gfx;
|
||||
pub mod gossip;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod health;
|
||||
pub mod hotbar;
|
||||
pub mod hud;
|
||||
@@ -80,12 +85,15 @@ pub mod nbt_path;
|
||||
pub mod net;
|
||||
pub mod options_file;
|
||||
pub mod options_fixes;
|
||||
pub mod options_horizon;
|
||||
pub mod options_ui;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod particles;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pose;
|
||||
pub mod post;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod random_sequences;
|
||||
@@ -106,6 +114,7 @@ pub mod skins;
|
||||
pub mod snbt;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod sprint;
|
||||
pub mod sprites;
|
||||
pub mod stations;
|
||||
@@ -117,6 +126,7 @@ pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod title;
|
||||
pub mod ui;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod window;
|
||||
pub mod world;
|
||||
|
||||
@@ -359,7 +359,7 @@ pub trait LightWorld {
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
|
||||
}
|
||||
impl LightWorld for HashMap<(i32, i32), Arc<Chunk>> {
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
|
||||
@@ -5,6 +5,13 @@ mod game;
|
||||
fn main() {
|
||||
if let Err(error) = game::run() {
|
||||
eprintln!("Horizon: {error}");
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -899,6 +899,18 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Entity.checkDespawn` alone, for a mob outside the entity-ticking
|
||||
/// chunks. Returns false when the mob is gone.
|
||||
pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
if m.removed {
|
||||
return false;
|
||||
}
|
||||
if !check_despawn(m, w) {
|
||||
m.removed = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Mob.checkDespawn, before the tick (players are the only despawn
|
||||
/// reference). Returns false when the mob is discarded.
|
||||
fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
|
||||
@@ -17,6 +17,7 @@ use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `MemorySlot.NEVER_EXPIRE`.
|
||||
pub(super) const NEVER: i64 = i64::MAX;
|
||||
@@ -843,12 +844,20 @@ impl Poi {
|
||||
}
|
||||
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
|
||||
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
|
||||
let name = w.block_name(x, y, z);
|
||||
poi_of_state(w, w.world.block(x, y, z))
|
||||
}
|
||||
/// The POI a block state is, if any (`PoiTypes.forState`).
|
||||
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
|
||||
let name = &*w.facts.get(state).name;
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Poi::Meeting);
|
||||
}
|
||||
if name.ends_with("_bed") {
|
||||
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
|
||||
let part = w
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("part"));
|
||||
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
|
||||
}
|
||||
super::villager::job_site_profession(name).map(Poi::Job)
|
||||
}
|
||||
@@ -920,12 +929,30 @@ fn pois_in_range(
|
||||
let chunks = radius.div_euclid(16) + 1;
|
||||
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
|
||||
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
|
||||
// The POI each block state is, worked out once per search, and the
|
||||
// sections whose palette holds none skipped whole, as PoiManager only
|
||||
// visits sections with records: the same positions in the same order,
|
||||
// without reading and naming every block of the sphere.
|
||||
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
|
||||
let mut kind = |state: u32| {
|
||||
*kinds
|
||||
.entry(state)
|
||||
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
|
||||
};
|
||||
for qz in cz - chunks..=cz + chunks {
|
||||
for qx in cx - chunks..=cx + chunks {
|
||||
if !w.world.chunks.contains_key(&(qx, qz)) {
|
||||
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let holds: Vec<bool> = chunk
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
|
||||
.collect();
|
||||
for y in y0..=y1 {
|
||||
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
|
||||
continue;
|
||||
}
|
||||
for z in qz * 16..qz * 16 + 16 {
|
||||
for x in qx * 16..qx * 16 + 16 {
|
||||
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
|
||||
@@ -937,11 +964,11 @@ fn pois_in_range(
|
||||
if d > r2 {
|
||||
continue;
|
||||
}
|
||||
let state = w.world.block(x, y, z);
|
||||
let state = chunk.block(x & 15, y, z & 15);
|
||||
if w.facts.is_air(state) {
|
||||
continue;
|
||||
}
|
||||
let Some(poi) = poi_at(w, x, y, z) else {
|
||||
let Some(poi) = kind(state.0) else {
|
||||
continue;
|
||||
};
|
||||
if !poi_matches(m, f, poi) {
|
||||
|
||||
@@ -108,6 +108,13 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// The address of list row `row` (saved servers, then LAN games).
|
||||
pub fn address_of(&self, row: usize) -> Option<String> {
|
||||
match self {
|
||||
Self::List { servers, lan, .. } => entry_address(servers, lan, row),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn list(saves: &Path) -> Self {
|
||||
let servers = servers::load(&servers_file(saves));
|
||||
let pings: Pings = Arc::default();
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! The options screen in Horizon's menu style, laid out like the launcher's
|
||||
//! Settings page: a sidebar with the sliding selection (one item per
|
||||
//! section), then the section's page header and settings rows (label column,
|
||||
//! value, control on the right) between 1px dividers.
|
||||
//!
|
||||
//! Adding settings (for example the Simple/Advanced graphics menu): add rows
|
||||
//! to a section in [`SECTIONS`], or a new section. A row is an [`HRow`]: an
|
||||
//! existing option (`Opt`, drawn as a switch, a slider or a value button from
|
||||
//! its kind), a header, or a button opening one of vanilla's pages. New kinds
|
||||
//! of control belong in `ui_theme` so the launcher-matching look stays in one
|
||||
//! place. Everything else (sliders dragging, scrolling, Done) is handled here.
|
||||
use crate::{
|
||||
graphics_style::Cost,
|
||||
graphics_ui::{self, Item, Level},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
options_ui::{Event, Kind, Opt, OptionsUi, Page, apply_preset},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
ui_theme::{
|
||||
self as t, CONTROL, Face, Kind as Button, MenuStyle, ROW_LABEL, SIDEBAR, SMALL, Ui, color,
|
||||
id,
|
||||
},
|
||||
};
|
||||
|
||||
/// One row of a section.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum HRow {
|
||||
Header(&'static str),
|
||||
Opt(Opt),
|
||||
/// A label, a value and a button opening one of vanilla's pages.
|
||||
Page(&'static str, &'static str, Page),
|
||||
/// Menu style: Horizon / Classic.
|
||||
Style,
|
||||
/// Graphics settings level: Simple / Advanced.
|
||||
Level,
|
||||
/// A graphics setting with its frame-time cost and description.
|
||||
Graphics(Item, Cost, &'static str),
|
||||
}
|
||||
/// A sidebar section: name, description, rows.
|
||||
pub struct Section {
|
||||
pub name: &'static str,
|
||||
pub blurb: &'static str,
|
||||
pub rows: fn(&Settings) -> Vec<HRow>,
|
||||
}
|
||||
pub const SECTIONS: &[Section] = &[
|
||||
Section {
|
||||
name: "Video",
|
||||
blurb: "Graphics, Simple or Advanced: each setting says what it costs.",
|
||||
rows: |s| {
|
||||
let level = if s.horizon.advanced {
|
||||
Level::Advanced
|
||||
} else {
|
||||
Level::Simple
|
||||
};
|
||||
let mut r = vec![HRow::Level];
|
||||
r.extend(graphics_ui::rows(level).into_iter().map(|row| match row {
|
||||
graphics_ui::Row::Header(name) => HRow::Header(name),
|
||||
graphics_ui::Row::Setting(g) => HRow::Graphics(g.item, g.cost, g.text),
|
||||
}));
|
||||
r
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Audio",
|
||||
blurb: "Volume for every kind of sound, and subtitles.",
|
||||
rows: |_| {
|
||||
let mut r: Vec<HRow> = (0..SOUND_SOURCES.len())
|
||||
.map(|i| HRow::Opt(Opt::Volume(i)))
|
||||
.collect();
|
||||
r.push(HRow::Opt(Opt::ShowSubtitles));
|
||||
r
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Controls",
|
||||
blurb: "Keys, the mouse and how movement keys behave.",
|
||||
rows: |_| {
|
||||
let mut r = vec![HRow::Page(
|
||||
"Key binds",
|
||||
"Every action and its key",
|
||||
Page::KeyBinds,
|
||||
)];
|
||||
r.push(HRow::Header("Mouse"));
|
||||
r.extend(
|
||||
[
|
||||
Opt::Sensitivity,
|
||||
Opt::WheelSensitivity,
|
||||
Opt::InvertY,
|
||||
Opt::DiscreteScroll,
|
||||
]
|
||||
.map(HRow::Opt),
|
||||
);
|
||||
r.push(HRow::Header("Movement"));
|
||||
r.extend([Opt::ToggleCrouch, Opt::ToggleSprint, Opt::AutoJump].map(HRow::Opt));
|
||||
r
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Accessibility",
|
||||
blurb: "Contrast, motion, text backgrounds and notifications.",
|
||||
rows: |_| {
|
||||
[
|
||||
Opt::HighContrast,
|
||||
Opt::MenuBlur,
|
||||
Opt::TextBackgroundOpacity,
|
||||
Opt::NotificationDisplayTime,
|
||||
Opt::ShowSubtitles,
|
||||
Opt::ViewBobbing,
|
||||
Opt::ChatLineSpacing,
|
||||
]
|
||||
.map(HRow::Opt)
|
||||
.to_vec()
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Language",
|
||||
blurb: "The game's language and its font.",
|
||||
rows: |_| {
|
||||
vec![
|
||||
HRow::Page("Language", "", Page::Language),
|
||||
HRow::Opt(Opt::ForceUnicode),
|
||||
HRow::Opt(Opt::JapaneseGlyphs),
|
||||
]
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Chat",
|
||||
blurb: "What chat shows and how it looks.",
|
||||
rows: |_| {
|
||||
[
|
||||
Opt::ChatVisibility,
|
||||
Opt::ChatColors,
|
||||
Opt::ChatLinks,
|
||||
Opt::ChatOpacity,
|
||||
Opt::ChatScale,
|
||||
Opt::ChatWidth,
|
||||
Opt::ChatHeightFocused,
|
||||
Opt::ChatHeightUnfocused,
|
||||
Opt::AutoSuggestions,
|
||||
Opt::ReducedDebugInfo,
|
||||
]
|
||||
.map(HRow::Opt)
|
||||
.to_vec()
|
||||
},
|
||||
},
|
||||
Section {
|
||||
name: "Interface",
|
||||
blurb: "Menus and resource packs.",
|
||||
rows: |_| {
|
||||
vec![
|
||||
HRow::Style,
|
||||
HRow::Page("Resource packs", "Textures, sounds and fonts", Page::Packs),
|
||||
]
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const NAV: u64 = id("options.nav", 0);
|
||||
const DONE: u64 = id("options.done", 0);
|
||||
const ROW: u64 = id("options.row", 0);
|
||||
const STYLE: u64 = id("options.style", 0);
|
||||
const LEVEL: u64 = id("options.level", 0);
|
||||
const ROW_H: f32 = 12.0 + CONTROL + 12.0;
|
||||
const HEADER_H: f32 = 40.0;
|
||||
|
||||
/// Whether an option is an on/off switch, and its value.
|
||||
fn switch_value(opt: Opt, s: &Settings) -> Option<bool> {
|
||||
Some(match opt {
|
||||
Opt::InvertY => s.invert_y,
|
||||
Opt::DiscreteScroll => s.discrete_scroll,
|
||||
Opt::ViewBobbing => s.view_bobbing,
|
||||
Opt::Vsync => s.vsync,
|
||||
Opt::Fullscreen => s.fullscreen,
|
||||
Opt::SmoothLighting => s.smooth_lighting,
|
||||
Opt::EntityShadows => s.entity_shadows,
|
||||
Opt::Vignette => s.vignette,
|
||||
Opt::Autosave => s.autosave_indicator,
|
||||
Opt::CutoutLeaves => s.cutout_leaves,
|
||||
Opt::ImprovedTransparency => s.improved_transparency,
|
||||
Opt::AutoJump => s.auto_jump,
|
||||
Opt::ChatColors => s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links,
|
||||
Opt::AutoSuggestions => s.auto_suggestions,
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles,
|
||||
Opt::ForceUnicode => s.force_unicode,
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The option's caption and value text, from its vanilla label
|
||||
/// ("Caption: Value") and its caption key.
|
||||
fn caption_value(opt: Opt, s: &Settings) -> (String, String) {
|
||||
let label = opt.label(s).plain();
|
||||
let caption = crate::text::Text::translate(opt.caption(), &[]).plain();
|
||||
if let Some(rest) = label.strip_prefix(&caption) {
|
||||
return (caption, rest.trim_start_matches([':', ' ']).to_owned());
|
||||
}
|
||||
match label.split_once(": ") {
|
||||
Some((c, v)) => (c.to_owned(), v.to_owned()),
|
||||
None => (caption, label),
|
||||
}
|
||||
}
|
||||
/// What a click hit.
|
||||
enum Target {
|
||||
Nav(usize),
|
||||
Done,
|
||||
Row(usize),
|
||||
Slider(Item, f32),
|
||||
Style(usize),
|
||||
Level(usize),
|
||||
}
|
||||
impl OptionsUi {
|
||||
/// Lay out (and draw, on a real frame) the Horizon options shell.
|
||||
/// Returns the sliders' tracks (option, x, width) for dragging.
|
||||
fn horizon_layout(
|
||||
&mut self,
|
||||
s: &Settings,
|
||||
ui: &mut Ui,
|
||||
backdrop: f32,
|
||||
) -> Vec<(Item, f32, f32)> {
|
||||
let (w, h) = (ui.w, ui.h);
|
||||
if self.in_world {
|
||||
ui.atmosphere(0.45, 1.0);
|
||||
} else {
|
||||
if backdrop < 1.0 {
|
||||
ui.cold_background(1.0 - backdrop);
|
||||
}
|
||||
ui.atmosphere(0.66, 1.0);
|
||||
}
|
||||
let section = self.section.min(SECTIONS.len() - 1);
|
||||
// The sidebar.
|
||||
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
|
||||
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
|
||||
ui.text_line(
|
||||
Face::Display,
|
||||
22.0,
|
||||
30.0,
|
||||
22.0,
|
||||
16.0,
|
||||
color::TEXT,
|
||||
"Options",
|
||||
);
|
||||
let names: Vec<&str> = SECTIONS.iter().map(|s| s.name).collect();
|
||||
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &names, section);
|
||||
let dy = h - 12.0 - CONTROL;
|
||||
ui.button(
|
||||
DONE,
|
||||
[12.0, dy, SIDEBAR - 24.0, CONTROL],
|
||||
"Done",
|
||||
Button::Secondary,
|
||||
true,
|
||||
);
|
||||
// The page fades in and rises 8 when the section changes.
|
||||
let p = ui.tween(id("options.page", section as u64), 1.0, t::move_());
|
||||
if self.section_shown != Some(section) {
|
||||
self.section_shown = Some(section);
|
||||
ui.restart(id("options.page", section as u64), 0.0, 1.0, t::move_());
|
||||
}
|
||||
let x = SIDEBAR + 48.0;
|
||||
let cw = (w - x - 48.0).min(760.0);
|
||||
ui.opacity = p;
|
||||
ui.offset = (0.0, 8.0 * (1.0 - p));
|
||||
let sec = &SECTIONS[section];
|
||||
let mut y = 36.0;
|
||||
y += ui.page_header(x, y, sec.name, sec.blurb) + 24.0;
|
||||
let top = y;
|
||||
let rows = (sec.rows)(s);
|
||||
let content: f32 = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
if matches!(r, HRow::Header(_)) {
|
||||
HEADER_H
|
||||
} else {
|
||||
ROW_H
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
let bottom = h - 24.0;
|
||||
let max = (content - (bottom - top)).max(0.0);
|
||||
self.hscroll = self.hscroll.clamp(0.0, max);
|
||||
y -= self.hscroll;
|
||||
let vstart = ui.batch.vertices.len();
|
||||
let hstart = ui.hits.len();
|
||||
let mut sliders = Vec::new();
|
||||
ui.divider(x, y, cw);
|
||||
let control_x = x + cw;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
match *row {
|
||||
HRow::Header(label) => {
|
||||
ui.text(Face::Medium, SMALL, x, y + 18.0, color::FAINT, label);
|
||||
y += HEADER_H;
|
||||
ui.divider(x, y, cw);
|
||||
continue;
|
||||
}
|
||||
HRow::Opt(opt) => {
|
||||
let (caption, value) = caption_value(opt, s);
|
||||
let cy = y + ROW_H / 2.0;
|
||||
if let Some(on) = switch_value(opt, s) {
|
||||
ui.row(x, y, &caption, "", 0.0);
|
||||
ui.switch(ROW + i as u64, control_x, cy, on);
|
||||
} else if let Kind::Slider(v) = opt.kind(s) {
|
||||
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
|
||||
ui.row(x, y, &caption, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
|
||||
let sx = control_x - sw - 8.0;
|
||||
ui.slider(ROW + i as u64, sx, cy, sw, v);
|
||||
sliders.push((Item::Vanilla(opt), sx, sw));
|
||||
} else {
|
||||
ui.row(x, y, &caption, "", 0.0);
|
||||
let bw = ui.button_width(&value, false).max(96.0);
|
||||
ui.button(
|
||||
ROW + i as u64,
|
||||
[control_x - bw, y + 12.0, bw, CONTROL],
|
||||
&value,
|
||||
Button::Secondary,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
HRow::Page(label, value, _) => {
|
||||
let value = if value.is_empty() {
|
||||
self.languages
|
||||
.iter()
|
||||
.find(|l| l.code == s.language)
|
||||
.map_or_else(|| s.language.clone(), |l| l.label())
|
||||
} else {
|
||||
value.to_owned()
|
||||
};
|
||||
ui.row(x, y, label, &value, cw - ROW_LABEL - 140.0);
|
||||
let bw = ui.button_width("Change…", false);
|
||||
let open = if label == "Language" {
|
||||
"Change…"
|
||||
} else {
|
||||
"Open…"
|
||||
};
|
||||
let bw = bw.max(ui.button_width(open, false));
|
||||
ui.button(
|
||||
ROW + i as u64,
|
||||
[control_x - bw, y + 12.0, bw, CONTROL],
|
||||
open,
|
||||
Button::Secondary,
|
||||
true,
|
||||
);
|
||||
}
|
||||
HRow::Level => {
|
||||
ui.row(x, y, "Settings", "", 0.0);
|
||||
let labels = ["Simple", "Advanced"];
|
||||
let probe = ui.segmented(LEVEL, -10_000.0, -10_000.0, &labels, 0);
|
||||
let selected = usize::from(s.horizon.advanced);
|
||||
ui.segmented(LEVEL, control_x - probe, y + 12.0 - 3.0, &labels, selected);
|
||||
}
|
||||
HRow::Graphics(item, cost, text) => {
|
||||
let (caption, value) = item.caption_value(s);
|
||||
let cost = (cost as u8, cost.label());
|
||||
let cy = y + ROW_H / 2.0;
|
||||
let id = ROW + i as u64;
|
||||
if let Some(on) = item.switch(s) {
|
||||
ui.described_row(x, y, &caption, "", 0.0, cost, text, cw - 60.0);
|
||||
ui.switch(id, control_x, cy, on);
|
||||
} else if let Some(v) = item.slider(s) {
|
||||
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
|
||||
let value_w = cw - ROW_LABEL - 16.0 - sw - 24.0;
|
||||
let text_w = cw - sw - 24.0;
|
||||
ui.described_row(x, y, &caption, &value, value_w, cost, text, text_w);
|
||||
let sx = control_x - sw - 8.0;
|
||||
ui.slider(id, sx, cy, sw, v);
|
||||
sliders.push((item, sx, sw));
|
||||
} else {
|
||||
let bw = ui.button_width(&value, false).max(96.0);
|
||||
let text_w = cw - bw - 24.0;
|
||||
ui.described_row(x, y, &caption, "", 0.0, cost, text, text_w);
|
||||
ui.button(
|
||||
id,
|
||||
[control_x - bw, y + 12.0, bw, CONTROL],
|
||||
&value,
|
||||
Button::Secondary,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
HRow::Style => {
|
||||
ui.row(x, y, "Menu style", "", 0.0);
|
||||
let labels = ["Horizon", "Classic"];
|
||||
let probe = ui.segmented(STYLE, -10_000.0, -10_000.0, &labels, 0);
|
||||
let selected = usize::from(t::style() == MenuStyle::Classic);
|
||||
ui.segmented(STYLE, control_x - probe, y + 12.0 - 3.0, &labels, selected);
|
||||
}
|
||||
}
|
||||
y += ROW_H;
|
||||
ui.divider(x, y, cw);
|
||||
}
|
||||
// Clip the rows to the page below the header, like the launcher's scroll view.
|
||||
let (ox, oy) = ui.offset;
|
||||
let scale = ui.batch.scale;
|
||||
let k = ui.density / scale;
|
||||
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
|
||||
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
|
||||
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
|
||||
let kept: Vec<_> = ui
|
||||
.hits
|
||||
.drain(hstart..)
|
||||
.filter(|h| h.rect[1] >= cy0 && h.rect[3] <= cy1)
|
||||
.collect();
|
||||
ui.hits.extend(kept);
|
||||
if max > 0.0 {
|
||||
// A thin scroll indicator.
|
||||
let track = bottom - top;
|
||||
let bar = (track * track / (track + max)).max(24.0);
|
||||
let by = top + (track - bar) * (self.hscroll / max);
|
||||
ui.rounded(
|
||||
x + cw + 16.0,
|
||||
by,
|
||||
3.0,
|
||||
bar,
|
||||
1.5,
|
||||
color::with_alpha(color::MUTED, 0.35),
|
||||
);
|
||||
}
|
||||
let _ = ox;
|
||||
ui.opacity = 1.0;
|
||||
ui.offset = (0.0, 0.0);
|
||||
sliders
|
||||
}
|
||||
pub(crate) fn draw_horizon(
|
||||
&mut self,
|
||||
s: &Settings,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
let mut ui = Ui::new(atlas, batch, cursor);
|
||||
let backdrop = t::backdrop();
|
||||
self.horizon_layout(s, &mut ui, backdrop);
|
||||
ui.finish();
|
||||
}
|
||||
fn horizon_target(&mut self, s: &Settings, atlas: &HudAtlas, x: f32, y: f32) -> Option<Target> {
|
||||
let mut batch = t::scratch_batch();
|
||||
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
|
||||
let sliders = self.horizon_layout(s, &mut ui, 1.0);
|
||||
let hit = ui.hit((x, y))?;
|
||||
let section = self.section.min(SECTIONS.len() - 1);
|
||||
if (NAV..NAV + SECTIONS.len() as u64).contains(&hit) {
|
||||
return Some(Target::Nav((hit - NAV) as usize));
|
||||
}
|
||||
if hit == DONE {
|
||||
return Some(Target::Done);
|
||||
}
|
||||
if hit == STYLE || hit == STYLE + 1 {
|
||||
return Some(Target::Style((hit - STYLE) as usize));
|
||||
}
|
||||
if hit == LEVEL || hit == LEVEL + 1 {
|
||||
return Some(Target::Level((hit - LEVEL) as usize));
|
||||
}
|
||||
let i = hit.checked_sub(ROW)? as usize;
|
||||
let rows = (SECTIONS[section].rows)(s);
|
||||
let item = match rows.get(i) {
|
||||
Some(HRow::Opt(opt)) => Some(Item::Vanilla(*opt)),
|
||||
Some(HRow::Graphics(item, ..)) => Some(*item),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = item
|
||||
&& let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == item)
|
||||
{
|
||||
let v = t::slider_value(&ui, sx, sw, x);
|
||||
return Some(Target::Slider(item, v));
|
||||
}
|
||||
(i < rows.len()).then_some(Target::Row(i))
|
||||
}
|
||||
/// A click (or a drag with the button held) on the Horizon shell.
|
||||
pub(crate) fn click_horizon(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
atlas: &HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
if drag {
|
||||
if let Some(item) = self.hdragging {
|
||||
let mut batch = t::scratch_batch();
|
||||
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
|
||||
let sliders = self.horizon_layout(s, &mut ui, 1.0);
|
||||
if let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == item) {
|
||||
set_item_slider(item, s, t::slider_value(&ui, sx, sw, x));
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
match self.horizon_target(s, atlas, x, y) {
|
||||
Some(Target::Nav(i)) => {
|
||||
self.section = i;
|
||||
self.hscroll = 0.0;
|
||||
}
|
||||
Some(Target::Done) => events.extend(self.back(s)),
|
||||
Some(Target::Style(i)) => {
|
||||
t::set_style(if i == 1 {
|
||||
MenuStyle::Classic
|
||||
} else {
|
||||
MenuStyle::Horizon
|
||||
});
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
t::save_preferences(&dir);
|
||||
}
|
||||
}
|
||||
Some(Target::Level(i)) => {
|
||||
s.horizon.advanced = i == 1;
|
||||
self.hscroll = 0.0;
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Some(Target::Slider(item, v)) => {
|
||||
self.hdragging = Some(item);
|
||||
set_item_slider(item, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Some(Target::Row(i)) => {
|
||||
let section = self.section.min(SECTIONS.len() - 1);
|
||||
match (SECTIONS[section].rows)(s).get(i) {
|
||||
Some(HRow::Opt(opt)) => {
|
||||
opt.cycle(s, right);
|
||||
if *opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Some(HRow::Graphics(item, ..)) => {
|
||||
item.cycle(s, right);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Some(HRow::Page(_, _, page)) => self.open_page(*page),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
events
|
||||
}
|
||||
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
|
||||
self.hscroll -= amount * 40.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// A slider moved: vanilla options keep their preset handling, Horizon's own
|
||||
/// settings go through `graphics_ui`.
|
||||
fn set_item_slider(item: Item, s: &mut Settings, v: f32) {
|
||||
match item {
|
||||
Item::Vanilla(opt) => {
|
||||
opt.set_slider(s, v);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
}
|
||||
Item::Horizon(_) => item.set_slider(s, v),
|
||||
}
|
||||
}
|
||||
@@ -92,13 +92,13 @@ fn chat_px(caption: &str, px: f32) -> Text {
|
||||
&[tr(caption), Text::literal((px as i32).to_string())],
|
||||
)
|
||||
}
|
||||
enum Kind {
|
||||
pub(crate) enum Kind {
|
||||
/// Slider position 0..1.
|
||||
Slider(f32),
|
||||
Cycle,
|
||||
}
|
||||
impl Opt {
|
||||
fn caption(self) -> &'static str {
|
||||
pub(crate) fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Opt::Fov => "options.fov",
|
||||
Opt::RenderDistance => "options.renderDistance",
|
||||
@@ -155,7 +155,7 @@ impl Opt {
|
||||
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
|
||||
}
|
||||
}
|
||||
fn kind(self, s: &Settings) -> Kind {
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
|
||||
match self {
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
@@ -190,6 +190,13 @@ impl Opt {
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
/// A slider's position (0..1), or `None` for a cycle button.
|
||||
pub fn slider_position(self, s: &Settings) -> Option<f32> {
|
||||
match self.kind(s) {
|
||||
Kind::Slider(v) => Some(v),
|
||||
Kind::Cycle => None,
|
||||
}
|
||||
}
|
||||
/// The widget message (OptionInstance.toString / CycleButton full name).
|
||||
pub fn label(self, s: &Settings) -> Text {
|
||||
let c = self.caption();
|
||||
@@ -532,6 +539,9 @@ pub enum Page {
|
||||
Language,
|
||||
Font,
|
||||
Packs,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -590,7 +600,14 @@ pub enum Event {
|
||||
pub struct OptionsUi {
|
||||
pub stack: Vec<Page>,
|
||||
scroll: f32,
|
||||
dragging: Option<Opt>,
|
||||
pub(crate) dragging: Option<Opt>,
|
||||
/// Horizon style: the selected sidebar section, the one last drawn and
|
||||
/// the rows' scroll in dp (`options_horizon`).
|
||||
pub section: usize,
|
||||
pub(crate) section_shown: Option<usize>,
|
||||
pub(crate) hscroll: f32,
|
||||
/// The slider being dragged on the Horizon options shell.
|
||||
pub(crate) hdragging: Option<crate::graphics_ui::Item>,
|
||||
/// Key Binds: the mapping waiting for a key.
|
||||
pub selected_key: Option<&'static str>,
|
||||
pub languages: Vec<LanguageInfo>,
|
||||
@@ -606,6 +623,7 @@ pub struct OptionsUi {
|
||||
pub in_world: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -615,6 +633,10 @@ impl Default for OptionsUi {
|
||||
stack: vec![Page::Main],
|
||||
scroll: 0.0,
|
||||
dragging: None,
|
||||
section: 0,
|
||||
section_shown: None,
|
||||
hscroll: 0.0,
|
||||
hdragging: None,
|
||||
selected_key: None,
|
||||
languages: Vec::new(),
|
||||
language_choice: None,
|
||||
@@ -625,6 +647,7 @@ impl Default for OptionsUi {
|
||||
pack_focus: std::time::Instant::now(),
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,6 +716,10 @@ impl OptionsUi {
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
}
|
||||
/// Open one of vanilla's pages over the current one.
|
||||
pub(crate) fn open_page(&mut self, page: Page) {
|
||||
self.push(page);
|
||||
}
|
||||
fn push(&mut self, page: Page) {
|
||||
self.stack.push(page);
|
||||
self.scroll = 0.0;
|
||||
@@ -704,6 +731,17 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
/// The graphics screen level of `page`, if it is one.
|
||||
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn title(page: Page) -> &'static str {
|
||||
match page {
|
||||
@@ -718,12 +756,30 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
}
|
||||
}
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::Video => {
|
||||
let mut rows = vec![Row::Header("options.video.display.header")];
|
||||
// Horizon's graphics screens, Simple and Advanced.
|
||||
let mut rows = vec![Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Header("options.video.display.header"));
|
||||
rows.extend(pairs(&[
|
||||
Opt::MaxFps,
|
||||
Opt::Vsync,
|
||||
@@ -1175,6 +1231,15 @@ impl OptionsUi {
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if crate::ui_theme::horizon() && self.page() == Page::Main {
|
||||
self.draw_horizon(s, batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
@@ -1324,6 +1389,31 @@ impl OptionsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Event> {
|
||||
if crate::ui_theme::horizon() && self.page() == Page::Main {
|
||||
return self.click_horizon(s, atlas, x, y, drag, right);
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in self.graphics.click(level, s, (x, y), size, drag, right) {
|
||||
match action {
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
{
|
||||
events.extend(self.back(s));
|
||||
} else {
|
||||
self.stack.pop();
|
||||
self.push(Page::Graphics);
|
||||
}
|
||||
}
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
if drag || right {
|
||||
@@ -1421,12 +1511,22 @@ impl OptionsUi {
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.dragging = None;
|
||||
self.hdragging = None;
|
||||
self.graphics.release();
|
||||
}
|
||||
pub fn scroll(&mut self, amount: f32) {
|
||||
self.scroll -= amount * 18.0;
|
||||
}
|
||||
/// The mouse wheel at the cursor.
|
||||
pub fn scroll_at(&mut self, cursor: (f32, f32), amount: f32) {
|
||||
if crate::ui_theme::horizon() && self.page() == Page::Main {
|
||||
self.scroll_horizon(amount);
|
||||
return;
|
||||
}
|
||||
if Self::graphics_level(self.page()).is_some() {
|
||||
self.graphics.scroll(amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
Some(p) if self.stack.last() == Some(&Page::Packs) => {
|
||||
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The performance overlay (Horizon's Overlay setting): frames per second,
|
||||
//! frame, CPU and GPU times, and a graph of recent frame times.
|
||||
use crate::{
|
||||
graphics::Overlay,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const HISTORY: usize = 160;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FrameGraph {
|
||||
/// Frame, CPU and GPU milliseconds of recent frames, oldest first.
|
||||
frames: VecDeque<(f32, f32, f32)>,
|
||||
}
|
||||
impl FrameGraph {
|
||||
pub fn record(&mut self, frame_ms: f32, cpu_ms: f32, gpu_ms: Option<f32>) {
|
||||
let gpu = gpu_ms.unwrap_or_else(|| self.frames.back().map_or(0.0, |f| f.2));
|
||||
if self.frames.len() == HISTORY {
|
||||
self.frames.pop_front();
|
||||
}
|
||||
self.frames.push_back((frame_ms, cpu_ms, gpu));
|
||||
}
|
||||
pub fn draw(&self, mode: Overlay, batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
if mode == Overlay::Off || self.frames.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = self.frames.len() as f32;
|
||||
let avg = |f: fn(&(f32, f32, f32)) -> f32| self.frames.iter().map(f).sum::<f32>() / n;
|
||||
let (frame, cpu, gpu) = (avg(|f| f.0), avg(|f| f.1), avg(|f| f.2));
|
||||
let worst = self.frames.iter().map(|f| f.0).fold(0.0f32, f32::max);
|
||||
let lines = [
|
||||
format!("{:.0} fps", 1000.0 / frame.max(0.001)),
|
||||
format!("frame {frame:.1} ms max {worst:.1}"),
|
||||
format!("cpu {cpu:.1} gpu {gpu:.1} ms"),
|
||||
];
|
||||
let width = 120.0f32.max(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| atlas.font.width(l))
|
||||
.fold(0.0, f32::max)
|
||||
+ 6.0,
|
||||
);
|
||||
let x = batch.width - width - 2.0;
|
||||
let graph = if mode == Overlay::Graph { 42.0 } else { 0.0 };
|
||||
batch.fill(
|
||||
atlas,
|
||||
x,
|
||||
2.0,
|
||||
width,
|
||||
4.0 + lines.len() as f32 * 10.0 + graph,
|
||||
rgba(0, 0, 0, 144),
|
||||
);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let color = if i == 0 {
|
||||
rgba(255, 255, 255, 255)
|
||||
} else {
|
||||
rgba(200, 200, 200, 255)
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(line.clone()),
|
||||
x + 3.0,
|
||||
4.0 + i as f32 * 10.0,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if mode != Overlay::Graph {
|
||||
return;
|
||||
}
|
||||
// Bars of frame time on a 0..33 ms scale; lines at 60 and 30 fps.
|
||||
let (gy, gh) = (4.0 + lines.len() as f32 * 10.0 + 2.0, 36.0);
|
||||
let scale = gh / 33.3;
|
||||
let bar = (width - 6.0) / HISTORY as f32;
|
||||
for (i, &(ms, cpu, _)) in self.frames.iter().enumerate() {
|
||||
let height = (ms * scale).min(gh);
|
||||
let bx = x + 3.0 + i as f32 * bar;
|
||||
let color = if ms > 33.3 {
|
||||
rgba(240, 90, 80, 255)
|
||||
} else if ms > 16.7 {
|
||||
rgba(240, 200, 90, 255)
|
||||
} else {
|
||||
rgba(110, 210, 120, 255)
|
||||
};
|
||||
batch.fill(atlas, bx, gy + gh - height, bar.max(1.0), height, color);
|
||||
let cpu_height = (cpu * scale).min(height);
|
||||
batch.fill(
|
||||
atlas,
|
||||
bx,
|
||||
gy + gh - cpu_height,
|
||||
bar.max(1.0),
|
||||
cpu_height,
|
||||
rgba(80, 140, 230, 200),
|
||||
);
|
||||
}
|
||||
for target in [16.7, 33.3] {
|
||||
let ly = gy + gh - target * scale;
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 3.0,
|
||||
ly,
|
||||
width - 6.0,
|
||||
1.0,
|
||||
rgba(255, 255, 255, 90),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! The menus' backdrop treatment (`ui_theme`'s Horizon style): the finished
|
||||
//! world frame is blurred (dual Kawase: three halvings down, back up) and
|
||||
//! colour graded before the HUD is drawn over it. Needs a swapchain that can
|
||||
//! be copied from; without one the frame is left as it is.
|
||||
|
||||
/// How the frame under a Horizon-style menu is treated.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PostGrade {
|
||||
/// Colour multiplier and lift (`c * tint + lift`).
|
||||
pub tint: [f32; 3],
|
||||
pub lift: [f32; 3],
|
||||
/// Toward grey, 0..1.
|
||||
pub desaturate: f32,
|
||||
/// Darkening toward the corners, 0..1.
|
||||
pub vignette: f32,
|
||||
}
|
||||
impl PostGrade {
|
||||
/// The title backdrop: icy, slightly lifted shadows.
|
||||
pub const FROST: Self = Self {
|
||||
tint: [0.62, 0.78, 1.0],
|
||||
lift: [0.0, 0.01, 0.035],
|
||||
desaturate: 0.45,
|
||||
vignette: 0.5,
|
||||
};
|
||||
/// Behind the pause menu: the world stays recognisable.
|
||||
pub const PAUSE: Self = Self {
|
||||
tint: [0.8, 0.86, 0.98],
|
||||
lift: [0.0, 0.004, 0.012],
|
||||
desaturate: 0.25,
|
||||
vignette: 0.3,
|
||||
};
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
texel: [f32; 4],
|
||||
tint: [f32; 4],
|
||||
lift: [f32; 4],
|
||||
params: [f32; 4],
|
||||
}
|
||||
const LEVELS: usize = 3;
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
down: wgpu::RenderPipeline,
|
||||
up: wgpu::RenderPipeline,
|
||||
finish: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// The copied frame, then the halvings.
|
||||
levels: Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
|
||||
uniforms: Vec<wgpu::Buffer>,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
}
|
||||
impl Post {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu backdrop"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Menu backdrop bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Menu backdrop"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Menu backdrop pass"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Menu backdrop"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = (0..LEVELS * 2 + 1)
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu backdrop pass"),
|
||||
size: std::mem::size_of::<Uniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
down: pipeline("fs_down"),
|
||||
up: pipeline("fs_up"),
|
||||
finish: pipeline("fs_final"),
|
||||
layout,
|
||||
sampler,
|
||||
levels: Vec::new(),
|
||||
uniforms,
|
||||
size: [0, 0],
|
||||
format,
|
||||
}
|
||||
}
|
||||
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if self.size == size && !self.levels.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
self.levels = (0..=LEVELS)
|
||||
.map(|i| {
|
||||
let s = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu backdrop level"),
|
||||
size: wgpu::Extent3d {
|
||||
width: s[0],
|
||||
height: s[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
(texture, view, s)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
fn pass(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
(from, uniform): (usize, usize),
|
||||
target: &wgpu::TextureView,
|
||||
data: Uniform,
|
||||
) {
|
||||
let (_, view, s) = &self.levels[from];
|
||||
let mut data = data;
|
||||
data.texel = [0.5 / s[0] as f32, 0.5 / s[1] as f32, 0.0, 0.0];
|
||||
queue.write_buffer(&self.uniforms[uniform], 0, bytemuck::bytes_of(&data));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu backdrop pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[uniform].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu backdrop pass"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
/// Blur and grade `frame` (the swapchain texture) into `target`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &wgpu::Texture,
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
grade: &PostGrade,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.levels[0].0.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let data = Uniform {
|
||||
texel: [0.0; 4],
|
||||
tint: [grade.tint[0], grade.tint[1], grade.tint[2], 1.0],
|
||||
lift: [grade.lift[0], grade.lift[1], grade.lift[2], 0.0],
|
||||
params: [grade.desaturate, grade.vignette, 0.0, 0.0],
|
||||
};
|
||||
let mut u = 0;
|
||||
for i in 1..=LEVELS {
|
||||
let target = &self.levels[i].1;
|
||||
self.pass(device, queue, encoder, &self.down, (i - 1, u), target, data);
|
||||
u += 1;
|
||||
}
|
||||
for i in (1..LEVELS).rev() {
|
||||
let target = &self.levels[i].1;
|
||||
self.pass(device, queue, encoder, &self.up, (i + 1, u), target, data);
|
||||
u += 1;
|
||||
}
|
||||
self.pass(device, queue, encoder, &self.finish, (1, u), target, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Menu backdrop: dual-Kawase blur (down and up passes) and a cold colour grade.
|
||||
struct Post {
|
||||
// Half a source texel (u, v), unused, unused.
|
||||
texel: vec4<f32>,
|
||||
// Multiplier and lift of the grade.
|
||||
tint: vec4<f32>,
|
||||
lift: vec4<f32>,
|
||||
// Desaturation, vignette, unused, unused.
|
||||
params: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var src: texture_2d<f32>;
|
||||
@group(0) @binding(1) var samp: sampler;
|
||||
@group(0) @binding(2) var<uniform> post: Post;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
fn s(uv: vec2<f32>) -> vec3<f32> {
|
||||
return textureSampleLevel(src, samp, uv, 0.0).rgb;
|
||||
}
|
||||
@fragment fn fs_down(in: Out) -> @location(0) vec4<f32> {
|
||||
let h = post.texel.xy;
|
||||
let c = s(in.uv) * 4.0 + s(in.uv - h) + s(in.uv + h) + s(in.uv + vec2(h.x, -h.y)) + s(in.uv - vec2(h.x, -h.y));
|
||||
return vec4(c / 8.0, 1.0);
|
||||
}
|
||||
fn up(uv: vec2<f32>) -> vec3<f32> {
|
||||
let h = post.texel.xy;
|
||||
var c = s(uv + vec2(-h.x * 2.0, 0.0)) + s(uv + vec2(h.x * 2.0, 0.0));
|
||||
c += s(uv + vec2(0.0, -h.y * 2.0)) + s(uv + vec2(0.0, h.y * 2.0));
|
||||
c += (s(uv + vec2(-h.x, h.y)) + s(uv + vec2(h.x, h.y)) + s(uv + vec2(-h.x, -h.y)) + s(uv + vec2(h.x, -h.y))) * 2.0;
|
||||
return c / 12.0;
|
||||
}
|
||||
@fragment fn fs_up(in: Out) -> @location(0) vec4<f32> {
|
||||
return vec4(up(in.uv), 1.0);
|
||||
}
|
||||
@fragment fn fs_final(in: Out) -> @location(0) vec4<f32> {
|
||||
var c = up(in.uv);
|
||||
let l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(c, vec3(l), post.params.x);
|
||||
c = c * post.tint.rgb + post.lift.rgb;
|
||||
let d = length((in.uv - 0.5) * vec2(1.25, 1.0));
|
||||
c *= 1.0 - post.params.y * smoothstep(0.35, 0.95, d);
|
||||
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
|
||||
}
|
||||
@@ -38,7 +38,11 @@ use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
};
|
||||
|
||||
pub const VERSION_NAME: &str = "26.3";
|
||||
@@ -1108,12 +1112,159 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
}
|
||||
|
||||
type RegionCache = Mutex<HashMap<(i32, i32), Option<Arc<Mutex<RegionFile>>>>>;
|
||||
/// Rewrite every region file in `folder` touched by `records`, keeping its
|
||||
/// other chunks. `None` deletes a chunk.
|
||||
fn write_region_records(
|
||||
root: &Path,
|
||||
cache: &RegionCache,
|
||||
folder: &str,
|
||||
records: Vec<((i32, i32), Option<Arc<NamedTag>>)>,
|
||||
) -> Result<()> {
|
||||
let mut by_region: BTreeMap<(i32, i32), Vec<((i32, i32), Option<Arc<NamedTag>>)>> =
|
||||
BTreeMap::new();
|
||||
for (key, tag) in records {
|
||||
by_region
|
||||
.entry((key.0 >> 5, key.1 >> 5))
|
||||
.or_default()
|
||||
.push((key, tag));
|
||||
}
|
||||
if by_region.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let staging = root.join(folder).join(".staging");
|
||||
fs::create_dir_all(&staging)?;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as u32);
|
||||
for ((rx, rz), list) in by_region {
|
||||
let path = region_path(root, folder, rx, rz);
|
||||
let mut records: BTreeMap<(i32, i32), ChunkRecord> = BTreeMap::new();
|
||||
let only_deletes = list.iter().all(|(_, tag)| tag.is_none());
|
||||
if let Ok(mut existing) = RegionFile::open(&path) {
|
||||
let positions: Vec<ChunkPos> = existing.chunks().map(|(p, _)| p).collect();
|
||||
if only_deletes
|
||||
&& !positions
|
||||
.iter()
|
||||
.any(|p| list.iter().any(|(k, _)| *k == (p.x, p.z)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in positions {
|
||||
if let Ok(Some(r)) = existing.read(p) {
|
||||
records.insert((p.x, p.z), r);
|
||||
}
|
||||
}
|
||||
} else if only_deletes {
|
||||
continue;
|
||||
}
|
||||
// Deleting chunks the region doesn't hold changes nothing: leave
|
||||
// the file alone (unloading empty entity chunks asks for that).
|
||||
let mut changed = false;
|
||||
for (key, tag) in list {
|
||||
match tag {
|
||||
Some(tag) => {
|
||||
changed = true;
|
||||
records.insert(
|
||||
key,
|
||||
ChunkRecord::from_nbt(
|
||||
&tag,
|
||||
Compression::Zlib,
|
||||
timestamp,
|
||||
Limits::default(),
|
||||
)?,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
changed |= records.remove(&key).is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
if records.is_empty() {
|
||||
let _ = fs::remove_file(&path);
|
||||
} else {
|
||||
let temporary = staging.join(format!("r.{rx}.{rz}.mca"));
|
||||
let _ = fs::remove_file(&temporary);
|
||||
let mut writer = RegionWriter::create(&temporary)?;
|
||||
for ((x, z), r) in &records {
|
||||
writer.write(ChunkPos::new(*x, *z), r)?;
|
||||
}
|
||||
writer.finish()?;
|
||||
fs::rename(&temporary, &path)?;
|
||||
}
|
||||
cache
|
||||
.lock()
|
||||
.map_err(|_| "region cache poisoned")?
|
||||
.remove(&(rx, rz));
|
||||
}
|
||||
let _ = fs::remove_dir(&staging);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn region_path(root: &Path, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
root.join(format!("{folder}/r.{rx}.{rz}.mca"))
|
||||
}
|
||||
|
||||
/// A record waiting for the writer thread.
|
||||
#[derive(Clone)]
|
||||
enum Record {
|
||||
Tag(Arc<NamedTag>),
|
||||
/// A chunk to encode on the writer thread.
|
||||
Chunk(Arc<Chunk>, Arc<Encoding>),
|
||||
Delete,
|
||||
}
|
||||
/// What encoding a chunk needs, captured when it is queued.
|
||||
struct Encoding {
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: Arc<Biomes>,
|
||||
tables: Option<Arc<SaveTables>>,
|
||||
sky_light: bool,
|
||||
}
|
||||
impl Record {
|
||||
fn tag(&self) -> Option<Arc<NamedTag>> {
|
||||
match self {
|
||||
Record::Tag(t) => Some(t.clone()),
|
||||
Record::Chunk(c, e) => Some(Arc::new(encode_with(
|
||||
c,
|
||||
e.registry,
|
||||
&e.biomes,
|
||||
e.tables.as_deref(),
|
||||
e.sky_light,
|
||||
))),
|
||||
Record::Delete => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Chunk and entity records waiting for the writer thread, by (entities?,
|
||||
/// x, z): reads see them before the region files do.
|
||||
type PendingWrites = Mutex<HashMap<(bool, i32, i32), (u64, Record)>>;
|
||||
type WriteBatch = (bool, Vec<((i32, i32), Record, u64)>);
|
||||
|
||||
/// The background thread that encodes chunks and rewrites region files, in
|
||||
/// submission order.
|
||||
struct Writer {
|
||||
send: mpsc::Sender<WriteBatch>,
|
||||
thread: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Writes still queued or running, with a condition to wait for none.
|
||||
#[derive(Default)]
|
||||
struct Outstanding {
|
||||
count: Mutex<usize>,
|
||||
done: Condvar,
|
||||
}
|
||||
/// One dimension folder (`dimensions/minecraft/<name>`). Region files are
|
||||
/// read lazily by worker threads.
|
||||
pub struct SaveDir {
|
||||
pub root: PathBuf,
|
||||
regions: RegionCache,
|
||||
entity_regions: RegionCache,
|
||||
regions: Arc<RegionCache>,
|
||||
entity_regions: Arc<RegionCache>,
|
||||
pending: Arc<PendingWrites>,
|
||||
sequence: AtomicU64,
|
||||
writer: Mutex<Option<Writer>>,
|
||||
outstanding: Arc<Outstanding>,
|
||||
/// Set by `Persistent` before the first load.
|
||||
pub tables: OnceLock<Arc<SaveTables>>,
|
||||
/// False in dimensions without skylight (no SkyLight layers are written).
|
||||
@@ -1124,16 +1275,29 @@ impl SaveDir {
|
||||
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
|
||||
Self {
|
||||
root,
|
||||
regions: Mutex::new(HashMap::new()),
|
||||
entity_regions: Mutex::new(HashMap::new()),
|
||||
regions: Default::default(),
|
||||
entity_regions: Default::default(),
|
||||
pending: Default::default(),
|
||||
sequence: AtomicU64::new(0),
|
||||
writer: Mutex::new(None),
|
||||
outstanding: Default::default(),
|
||||
tables: OnceLock::new(),
|
||||
sky_light,
|
||||
}
|
||||
}
|
||||
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
self.root.join(format!("{folder}/r.{rx}.{rz}.mca"))
|
||||
region_path(&self.root, folder, rx, rz)
|
||||
}
|
||||
fn read_record(&self, folder: &str, x: i32, z: i32) -> Option<NamedTag> {
|
||||
let pending = self
|
||||
.pending
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&(folder != "region", x, z))
|
||||
.map(|(_, r)| r.clone());
|
||||
if let Some(record) = pending {
|
||||
return record.tag().map(|t| (*t).clone());
|
||||
}
|
||||
let key = (x >> 5, z >> 5);
|
||||
let cache = if folder == "region" {
|
||||
&self.regions
|
||||
@@ -1172,83 +1336,98 @@ impl SaveDir {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Rewrite every region file in `folder` touched by `records`, keeping
|
||||
/// its other chunks. `None` deletes a chunk.
|
||||
/// Queue `records` for the writer thread; with `wait`, return once
|
||||
/// they and every earlier write are on disk.
|
||||
fn write_records(
|
||||
&self,
|
||||
folder: &str,
|
||||
records: Vec<((i32, i32), Option<NamedTag>)>,
|
||||
wait: bool,
|
||||
) -> Result<()> {
|
||||
let mut by_region: BTreeMap<(i32, i32), Vec<((i32, i32), Option<NamedTag>)>> =
|
||||
BTreeMap::new();
|
||||
for (key, tag) in records {
|
||||
by_region
|
||||
.entry((key.0 >> 5, key.1 >> 5))
|
||||
.or_default()
|
||||
.push((key, tag));
|
||||
}
|
||||
if by_region.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let staging = self.root.join(folder).join(".staging");
|
||||
fs::create_dir_all(&staging)?;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as u32);
|
||||
let cache = if folder == "region" {
|
||||
&self.regions
|
||||
} else {
|
||||
&self.entity_regions
|
||||
};
|
||||
for ((rx, rz), list) in by_region {
|
||||
let path = self.region_path(folder, rx, rz);
|
||||
let mut records: BTreeMap<(i32, i32), ChunkRecord> = BTreeMap::new();
|
||||
if let Ok(mut existing) = RegionFile::open(&path) {
|
||||
let positions: Vec<ChunkPos> = existing.chunks().map(|(p, _)| p).collect();
|
||||
for p in positions {
|
||||
if let Ok(Some(r)) = existing.read(p) {
|
||||
records.insert((p.x, p.z), r);
|
||||
}
|
||||
let records = records
|
||||
.into_iter()
|
||||
.map(|(k, t)| (k, t.map_or(Record::Delete, |t| Record::Tag(Arc::new(t)))))
|
||||
.collect();
|
||||
self.queue(folder != "region", records, wait)
|
||||
}
|
||||
fn queue(&self, entities: bool, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> {
|
||||
if !records.is_empty() {
|
||||
let mut batch = Vec::with_capacity(records.len());
|
||||
{
|
||||
let mut pending = self.pending.lock().map_err(|_| "pending writes poisoned")?;
|
||||
for (key, record) in records {
|
||||
let seq = self.sequence.fetch_add(1, Ordering::Relaxed);
|
||||
pending.insert((entities, key.0, key.1), (seq, record.clone()));
|
||||
batch.push((key, record, seq));
|
||||
}
|
||||
}
|
||||
for (key, tag) in list {
|
||||
match tag {
|
||||
Some(tag) => {
|
||||
records.insert(
|
||||
key,
|
||||
ChunkRecord::from_nbt(
|
||||
&tag,
|
||||
Compression::Zlib,
|
||||
timestamp,
|
||||
Limits::default(),
|
||||
)?,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
records.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if records.is_empty() {
|
||||
let _ = fs::remove_file(&path);
|
||||
} else {
|
||||
let temporary = staging.join(format!("r.{rx}.{rz}.mca"));
|
||||
let _ = fs::remove_file(&temporary);
|
||||
let mut writer = RegionWriter::create(&temporary)?;
|
||||
for ((x, z), r) in &records {
|
||||
writer.write(ChunkPos::new(*x, *z), r)?;
|
||||
}
|
||||
writer.finish()?;
|
||||
fs::rename(&temporary, &path)?;
|
||||
}
|
||||
cache
|
||||
*self
|
||||
.outstanding
|
||||
.count
|
||||
.lock()
|
||||
.map_err(|_| "region cache poisoned")?
|
||||
.remove(&(rx, rz));
|
||||
.map_err(|_| "writer poisoned")? += 1;
|
||||
let mut writer = self.writer.lock().map_err(|_| "writer poisoned")?;
|
||||
let writer = writer.get_or_insert_with(|| self.start_writer());
|
||||
if writer.send.send((entities, batch)).is_err() {
|
||||
return Err("the region writer stopped".into());
|
||||
}
|
||||
}
|
||||
if wait {
|
||||
self.flush();
|
||||
}
|
||||
let _ = fs::remove_dir(&staging);
|
||||
Ok(())
|
||||
}
|
||||
fn start_writer(&self) -> Writer {
|
||||
let (send, receive) = mpsc::channel::<WriteBatch>();
|
||||
let root = self.root.clone();
|
||||
let (regions, entity_regions) = (self.regions.clone(), self.entity_regions.clone());
|
||||
let pending = self.pending.clone();
|
||||
let outstanding = self.outstanding.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("horizon-region-writer".into())
|
||||
.spawn(move || {
|
||||
for (entities, batch) in receive {
|
||||
let (folder, cache) = if entities {
|
||||
("entities", &entity_regions)
|
||||
} else {
|
||||
("region", ®ions)
|
||||
};
|
||||
let records = batch.iter().map(|(k, r, _)| (*k, r.tag())).collect();
|
||||
if let Err(e) = write_region_records(&root, cache, folder, records) {
|
||||
eprintln!("Horizon: failed to write {folder} files: {e}");
|
||||
}
|
||||
// Readers find the files (and a fresh region cache) from here on.
|
||||
if let Ok(mut pending) = pending.lock() {
|
||||
for (k, _, seq) in &batch {
|
||||
if pending
|
||||
.get(&(entities, k.0, k.1))
|
||||
.is_some_and(|p| p.0 == *seq)
|
||||
{
|
||||
pending.remove(&(entities, k.0, k.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut count) = outstanding.count.lock() {
|
||||
*count -= 1;
|
||||
outstanding.done.notify_all();
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn region writer");
|
||||
Writer { send, thread }
|
||||
}
|
||||
/// Wait until every queued write is on disk.
|
||||
pub fn flush(&self) {
|
||||
let Ok(mut count) = self.outstanding.count.lock() else {
|
||||
return;
|
||||
};
|
||||
while *count > 0 {
|
||||
count = match self.outstanding.done.wait(count) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
}
|
||||
}
|
||||
/// Rewrite every region touched by `chunks`, keeping their other chunks.
|
||||
pub fn save(
|
||||
&self,
|
||||
@@ -1266,11 +1445,39 @@ impl SaveDir {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.write_records("region", records)?;
|
||||
self.write_records("region", records, true)?;
|
||||
Ok(chunks.len())
|
||||
}
|
||||
/// `save` on the writer thread: returns before the files are written;
|
||||
/// loads see the chunks meanwhile.
|
||||
pub fn save_later(
|
||||
&self,
|
||||
chunks: &[Arc<Chunk>],
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: &Arc<Biomes>,
|
||||
) -> Result<usize> {
|
||||
let encoding = Arc::new(Encoding {
|
||||
registry,
|
||||
biomes: biomes.clone(),
|
||||
tables: self.tables.get().cloned(),
|
||||
sky_light: self.sky_light,
|
||||
});
|
||||
let records = chunks
|
||||
.iter()
|
||||
.map(|c| ((c.x, c.z), Record::Chunk(c.clone(), encoding.clone())))
|
||||
.collect();
|
||||
self.queue(false, records, false)?;
|
||||
Ok(chunks.len())
|
||||
}
|
||||
/// Write the entity chunks (`EntityStorage`): an empty list deletes it.
|
||||
pub fn save_entities(&self, chunks: Vec<((i32, i32), Vec<Tag>)>) -> Result<()> {
|
||||
self.write_entities(chunks, true)
|
||||
}
|
||||
/// `save_entities` on the writer thread.
|
||||
pub fn save_entities_later(&self, chunks: Vec<((i32, i32), Vec<Tag>)>) -> Result<()> {
|
||||
self.write_entities(chunks, false)
|
||||
}
|
||||
fn write_entities(&self, chunks: Vec<((i32, i32), Vec<Tag>)>, wait: bool) -> Result<()> {
|
||||
let records = chunks
|
||||
.into_iter()
|
||||
.map(|((x, z), entities)| {
|
||||
@@ -1285,7 +1492,17 @@ impl SaveDir {
|
||||
((x, z), tag)
|
||||
})
|
||||
.collect();
|
||||
self.write_records("entities", records)
|
||||
self.write_records("entities", records, wait)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SaveDir {
|
||||
/// Finish queued writes before the world is let go.
|
||||
fn drop(&mut self) {
|
||||
if let Some(writer) = self.writer.get_mut().ok().and_then(Option::take) {
|
||||
drop(writer.send);
|
||||
let _ = writer.thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
@@ -243,6 +245,7 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
horizon: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,10 +276,20 @@ fn put_s(f: &mut OptionsFile, key: &str, v: &str) {
|
||||
}
|
||||
impl Settings {
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let horizon = path
|
||||
.parent()
|
||||
.map(crate::graphics::Graphics::load)
|
||||
.unwrap_or_default();
|
||||
let Ok(bytes) = fs::read(path) else {
|
||||
return Self::default();
|
||||
return Self {
|
||||
horizon,
|
||||
..Self::default()
|
||||
};
|
||||
};
|
||||
Self::parse(&String::from_utf8_lossy(&bytes))
|
||||
Self {
|
||||
horizon,
|
||||
..Self::parse(&String::from_utf8_lossy(&bytes))
|
||||
}
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
Self::from_file(OptionsFile::parse(text))
|
||||
@@ -332,6 +345,7 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
horizon: Default::default(),
|
||||
auto_jump: f.bool("autoJump"),
|
||||
language: f.string("lang"),
|
||||
force_unicode: f.bool("forceUnicodeFont"),
|
||||
@@ -461,6 +475,9 @@ impl Settings {
|
||||
}
|
||||
pub fn save(&self, path: &Path) {
|
||||
let _ = fs::write(path, self.serialize());
|
||||
if let Some(dir) = path.parent() {
|
||||
self.horizon.save(dir);
|
||||
}
|
||||
}
|
||||
/// Volume of a sound source (master excluded).
|
||||
pub fn volume(&self, source: &str) -> f32 {
|
||||
|
||||
@@ -445,6 +445,17 @@ impl SoundEngine {
|
||||
self.instances.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Table sizes, for soak tests watching what the engine holds.
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"instances": self.instances.len(),
|
||||
"playing": self.instance_to_channel.len(),
|
||||
"handles": self.handles.len(),
|
||||
"ticking": self.ticking.len(),
|
||||
"queued": self.queued.len(),
|
||||
"used": self.used,
|
||||
})
|
||||
}
|
||||
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
|
||||
self.instances.get(&id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Named timings within a frame, for finding what makes a frame slow:
|
||||
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
|
||||
//! exceeds MS milliseconds. Costs one clock read per span when enabled and
|
||||
//! a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
thread_local! {
|
||||
static SPANS: RefCell<Vec<(&'static str, f32)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return f();
|
||||
}
|
||||
let started = Instant::now();
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
SPANS.with(|s| s.borrow_mut().push((name, ms)));
|
||||
out
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<(&'static str, f32)> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
let mut sums: Vec<(&'static str, f32)> = Vec::new();
|
||||
for (name, ms) in spans {
|
||||
match sums.iter_mut().find(|(n, _)| *n == name) {
|
||||
Some(entry) => entry.1 += ms,
|
||||
None => sums.push((name, ms)),
|
||||
}
|
||||
}
|
||||
sums.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
sums
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! around the player, closest first. The game thread owns the world and only
|
||||
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, SECTIONS},
|
||||
light::{LightTables, light_chunk},
|
||||
@@ -10,7 +11,7 @@ use crate::{
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashMap, HashSet},
|
||||
collections::{BinaryHeap, HashSet},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -98,19 +99,19 @@ pub struct Streaming {
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
generating: HashSet<(i32, i32)>,
|
||||
generating: FxHashSet<(i32, i32)>,
|
||||
/// Proto chunks: `None` while checked out to a decoration job.
|
||||
protos: HashMap<(i32, i32), Option<Proto>>,
|
||||
decorated: HashSet<(i32, i32)>,
|
||||
protos: FxHashMap<(i32, i32), Option<Proto>>,
|
||||
decorated: FxHashSet<(i32, i32)>,
|
||||
decorating: usize,
|
||||
finishing: HashSet<(i32, i32)>,
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
lighting: HashMap<(i32, i32), u64>,
|
||||
meshing: HashMap<(i32, i32, i32), u64>,
|
||||
lighting: FxHashMap<(i32, i32), u64>,
|
||||
meshing: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// Chunks whose light is current.
|
||||
lit: HashSet<(i32, i32)>,
|
||||
lit: FxHashSet<(i32, i32)>,
|
||||
/// Columns whose sections have been meshed at least once.
|
||||
meshed: HashSet<(i32, i32)>,
|
||||
meshed: FxHashSet<(i32, i32)>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
pub radius: i32,
|
||||
@@ -133,7 +134,7 @@ pub struct Streaming {
|
||||
light_log: Vec<(u64, (i32, i32, i32))>,
|
||||
light_edit_sequence: u64,
|
||||
/// Edit sequence at each in-flight light job's snapshot.
|
||||
light_snapshots: HashMap<(i32, i32), u64>,
|
||||
light_snapshots: FxHashMap<(i32, i32), u64>,
|
||||
pub generated_total: u64,
|
||||
pub meshed_total: u64,
|
||||
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
|
||||
@@ -234,16 +235,16 @@ impl Streaming {
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
generating: HashSet::new(),
|
||||
protos: HashMap::new(),
|
||||
decorated: HashSet::new(),
|
||||
generating: Default::default(),
|
||||
protos: Default::default(),
|
||||
decorated: Default::default(),
|
||||
decorating: 0,
|
||||
finishing: HashSet::new(),
|
||||
finishing: Default::default(),
|
||||
decorates: generator.decorates(),
|
||||
lighting: HashMap::new(),
|
||||
meshing: HashMap::new(),
|
||||
lit: HashSet::new(),
|
||||
meshed: HashSet::new(),
|
||||
lighting: Default::default(),
|
||||
meshing: Default::default(),
|
||||
lit: Default::default(),
|
||||
meshed: Default::default(),
|
||||
sequence: 0,
|
||||
center: (0, 0),
|
||||
radius: 8,
|
||||
@@ -258,7 +259,7 @@ impl Streaming {
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
light_snapshots: HashMap::new(),
|
||||
light_snapshots: Default::default(),
|
||||
generated_total: 0,
|
||||
meshed_total: 0,
|
||||
remote: false,
|
||||
@@ -358,20 +359,24 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(mut chunk) => {
|
||||
let key = (chunk.x, chunk.z);
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generating.remove(&key);
|
||||
self.finishing.remove(&key);
|
||||
self.decorated.insert(key);
|
||||
// A column that finished after the player moved away is
|
||||
// dropped whole: its block entities and saved entities
|
||||
// too, or they would stay in the world (and the entities
|
||||
// spawn again, duplicated, when the column loads later).
|
||||
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generated_total += 1;
|
||||
// A chunk saved with its light needs no light job; its
|
||||
// unlit neighbours still light themselves from it.
|
||||
@@ -424,6 +429,12 @@ impl Streaming {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.meshed_total += 1;
|
||||
uploads.push(((x, sy, z), mesh));
|
||||
@@ -449,6 +460,13 @@ impl Streaming {
|
||||
}
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
self.light_snapshots.remove(key);
|
||||
}
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
}
|
||||
// Drop far proto chunks that are not checked out.
|
||||
let areas: Vec<((i32, i32), i64)> =
|
||||
@@ -614,13 +632,19 @@ impl Streaming {
|
||||
}
|
||||
// Meshing once the column and its neighbours are lit.
|
||||
let dirty: Vec<_> = world.dirty_sections.iter().copied().collect();
|
||||
let mut columns: HashMap<(i32, i32), Option<Box<Region>>> = HashMap::new();
|
||||
let mut columns: FxHashMap<(i32, i32), Option<Box<Region>>> = FxHashMap::default();
|
||||
let mut ready_columns: FxHashMap<(i32, i32), bool> = FxHashMap::default();
|
||||
for (x, sy, z) in dirty {
|
||||
if !world.chunks.contains_key(&(x, z)) || !self.in_main_range(x, z, 0) {
|
||||
world.dirty_sections.remove(&(x, sy, z));
|
||||
continue;
|
||||
}
|
||||
let ready = (-1..=1).all(|dz| (-1..=1).all(|dx| self.lit.contains(&(x + dx, z + dz))));
|
||||
// Once per column: the 3x3 lit check would otherwise repeat for
|
||||
// each of its sections every frame.
|
||||
let lit = &self.lit;
|
||||
let ready = *ready_columns.entry((x, z)).or_insert_with(|| {
|
||||
(-1..=1).all(|dz| (-1..=1).all(|dx| lit.contains(&(x + dx, z + dz))))
|
||||
});
|
||||
if !ready || self.lighting.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ pub struct Title {
|
||||
pub mode: Mode,
|
||||
pub saves: PathBuf,
|
||||
pub started: std::time::Instant,
|
||||
/// The mode last drawn and when it appeared (Horizon's screen fades).
|
||||
shown: std::cell::Cell<(std::mem::Discriminant<Mode>, std::time::Instant)>,
|
||||
/// The launcher-style opening (Horizon style, first title of the session).
|
||||
pub opening: std::cell::Cell<Option<crate::front::Opening>>,
|
||||
/// The saves listed for Home, re-read every couple of seconds.
|
||||
recent: std::cell::RefCell<Option<(std::time::Instant, Vec<WorldEntry>)>>,
|
||||
}
|
||||
struct Button {
|
||||
x: f32,
|
||||
@@ -96,10 +102,78 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
}
|
||||
impl Title {
|
||||
pub fn new(saves: PathBuf) -> Self {
|
||||
let mode = Mode::Main;
|
||||
let now = std::time::Instant::now();
|
||||
Self {
|
||||
mode: Mode::Main,
|
||||
shown: std::cell::Cell::new((std::mem::discriminant(&mode), now)),
|
||||
mode,
|
||||
saves,
|
||||
started: std::time::Instant::now(),
|
||||
started: now,
|
||||
opening: std::cell::Cell::new(None),
|
||||
recent: std::cell::RefCell::new(None),
|
||||
}
|
||||
}
|
||||
/// The worlds, newest first, cached for two seconds (drawn every frame).
|
||||
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
|
||||
let mut recent = self.recent.borrow_mut();
|
||||
match &*recent {
|
||||
Some((at, worlds)) if at.elapsed().as_secs() < 2 => worlds.clone(),
|
||||
_ => {
|
||||
let worlds = list_worlds(&self.saves);
|
||||
*recent = Some((std::time::Instant::now(), worlds.clone()));
|
||||
worlds
|
||||
}
|
||||
}
|
||||
}
|
||||
/// When the current mode first appeared.
|
||||
pub fn entered(&self) -> std::time::Instant {
|
||||
let now = std::mem::discriminant(&self.mode);
|
||||
let (mode, at) = self.shown.get();
|
||||
if mode == now {
|
||||
return at;
|
||||
}
|
||||
let at = std::time::Instant::now();
|
||||
self.shown.set((now, at));
|
||||
at
|
||||
}
|
||||
/// Create New World: the folder name and options (FileUtil.findAvailableName).
|
||||
pub fn create_action(&self) -> Action {
|
||||
let Mode::Create {
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
..
|
||||
} = &self.mode
|
||||
else {
|
||||
return Action::None;
|
||||
};
|
||||
let base: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let base = if base.trim().is_empty() {
|
||||
"World".to_owned()
|
||||
} else {
|
||||
base.trim().to_owned()
|
||||
};
|
||||
// FileUtil.findAvailableName: append (n) until unused.
|
||||
let mut folder = base.clone();
|
||||
let mut n = 1;
|
||||
while self.saves.join(&folder).exists() {
|
||||
folder = format!("{base} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
Action::Create {
|
||||
folder,
|
||||
name: name.clone(),
|
||||
seed: parse_seed(seed),
|
||||
creative: *creative,
|
||||
}
|
||||
}
|
||||
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
|
||||
@@ -446,44 +520,7 @@ impl Title {
|
||||
*creative = !*creative;
|
||||
Action::None
|
||||
}
|
||||
(
|
||||
Mode::Create {
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
..
|
||||
},
|
||||
Some(21),
|
||||
) => {
|
||||
let base: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let base = if base.trim().is_empty() {
|
||||
"World".to_owned()
|
||||
} else {
|
||||
base.trim().to_owned()
|
||||
};
|
||||
// FileUtil.findAvailableName: append (n) until unused.
|
||||
let mut folder = base.clone();
|
||||
let mut n = 1;
|
||||
while self.saves.join(&folder).exists() {
|
||||
folder = format!("{base} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
Action::Create {
|
||||
folder,
|
||||
name: name.clone(),
|
||||
seed: parse_seed(seed),
|
||||
creative: *creative,
|
||||
}
|
||||
}
|
||||
(Mode::Create { .. }, Some(21)) => self.create_action(),
|
||||
(Mode::Create { .. }, Some(22)) => {
|
||||
self.mode = Mode::Select {
|
||||
worlds: list_worlds(&self.saves),
|
||||
@@ -510,6 +547,7 @@ impl Title {
|
||||
if let Mode::Create {
|
||||
name, seed, focus, ..
|
||||
} = &mut self.mode
|
||||
&& *focus < 2
|
||||
{
|
||||
let field = if *focus == 0 { name } else { seed };
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
@@ -526,6 +564,7 @@ impl Title {
|
||||
if let Mode::Create {
|
||||
name, seed, focus, ..
|
||||
} = &mut self.mode
|
||||
&& *focus < 2
|
||||
{
|
||||
let field = if *focus == 0 { name } else { seed };
|
||||
field.pop();
|
||||
@@ -554,6 +593,10 @@ impl Title {
|
||||
};
|
||||
}
|
||||
}
|
||||
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
|
||||
pub fn date_label(secs: u64) -> String {
|
||||
date_string(secs)
|
||||
}
|
||||
fn date_string(secs: u64) -> String {
|
||||
// Civil date from Unix time (days-from-civil inverse), UTC.
|
||||
let days = (secs / 86400) as i64;
|
||||
|
||||
@@ -174,6 +174,23 @@ impl LimiterClock for SystemClock {
|
||||
}
|
||||
}
|
||||
|
||||
/// The real clock without busy waiting: where vanilla spins on
|
||||
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
|
||||
/// steps, so a capped frame rate leaves the core idle.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SleepingClock(pub SystemClock);
|
||||
impl LimiterClock for SleepingClock {
|
||||
fn nanos(&mut self) -> i64 {
|
||||
self.0.nanos()
|
||||
}
|
||||
fn park(&mut self, nanos: i64) {
|
||||
self.0.park(nanos);
|
||||
}
|
||||
fn spin(&mut self) {
|
||||
std::thread::sleep(std::time::Duration::from_micros(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.runTick`'s frame-limiter step: only limits below 260
|
||||
/// (the "unlimited" setting) pace frames.
|
||||
pub fn end_frame(
|
||||
|
||||
@@ -9,15 +9,15 @@ use std::{
|
||||
};
|
||||
|
||||
pub struct World {
|
||||
pub chunks: HashMap<(i32, i32), Arc<Chunk>>,
|
||||
pub chunks: crate::fast_hash::FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
pub random: LegacyRandom,
|
||||
pub revision: u64,
|
||||
pub health_rules: crate::health::HealthRules,
|
||||
pub air: BlockStateId,
|
||||
/// Sections whose geometry changed and need remeshing.
|
||||
pub dirty_sections: HashSet<(i32, i32, i32)>,
|
||||
pub dirty_sections: crate::fast_hash::FxHashSet<(i32, i32, i32)>,
|
||||
/// Chunks whose light must be recomputed.
|
||||
pub dirty_light: HashSet<(i32, i32)>,
|
||||
pub dirty_light: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Edits on lit chunks (position, old state, new state), relit
|
||||
/// incrementally by streaming.
|
||||
pub light_edits: Vec<((i32, i32, i32), u16, u16)>,
|
||||
@@ -108,13 +108,13 @@ impl Weather {
|
||||
impl World {
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
chunks: HashMap::new(),
|
||||
chunks: Default::default(),
|
||||
random: LegacyRandom::new(seed),
|
||||
revision: 0,
|
||||
health_rules: crate::health::HealthRules::default(),
|
||||
air,
|
||||
dirty_sections: HashSet::new(),
|
||||
dirty_light: HashSet::new(),
|
||||
dirty_sections: Default::default(),
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Horizon's world post pass: the world, rendered at the resolution scale,
|
||||
// copied (bilinear) or anti-aliased with FXAA onto the screen before the
|
||||
// hand and HUD. Only used when a non-vanilla setting asks for it.
|
||||
@group(0) @binding(0) var world: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_sampler: sampler;
|
||||
|
||||
struct PostOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex fn vs_post(@builtin(vertex_index) i: u32) -> PostOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: PostOut;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_copy(in: PostOut) -> @location(0) vec4<f32> {
|
||||
return vec4(textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb, 1.0);
|
||||
}
|
||||
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
// FXAA 3.11 (console quality): blend along the local edge direction.
|
||||
@fragment fn fs_fxaa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let texel = 1.0 / vec2<f32>(textureDimensions(world));
|
||||
let rgb_m = textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb;
|
||||
let nw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, -0.5) * texel, 0.0).rgb);
|
||||
let ne = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, -0.5) * texel, 0.0).rgb);
|
||||
let sw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, 0.5) * texel, 0.0).rgb);
|
||||
let se = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, 0.5) * texel, 0.0).rgb);
|
||||
let m = luma(rgb_m);
|
||||
let lo = min(m, min(min(nw, ne), min(sw, se)));
|
||||
let hi = max(m, max(max(nw, ne), max(sw, se)));
|
||||
if hi - lo < max(0.0312, hi * 0.125) {
|
||||
return vec4(rgb_m, 1.0);
|
||||
}
|
||||
var dir = vec2(-((nw + ne) - (sw + se)), (nw + sw) - (ne + se));
|
||||
let reduce = max((nw + ne + sw + se) * 0.25 * 0.125, 1.0 / 128.0);
|
||||
let scale = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce);
|
||||
dir = clamp(dir * scale, vec2(-8.0), vec2(8.0)) * texel;
|
||||
let a = 0.5 * (textureSampleLevel(world, world_sampler, in.uv + dir * (1.0 / 3.0 - 0.5), 0.0).rgb
|
||||
+ textureSampleLevel(world, world_sampler, in.uv + dir * (2.0 / 3.0 - 0.5), 0.0).rgb);
|
||||
let b = a * 0.5 + 0.25 * (textureSampleLevel(world, world_sampler, in.uv - dir * 0.5, 0.0).rgb
|
||||
+ textureSampleLevel(world, world_sampler, in.uv + dir * 0.5, 0.0).rgb);
|
||||
let lb = luma(b);
|
||||
if lb < lo || lb > hi {
|
||||
return vec4(a, 1.0);
|
||||
}
|
||||
return vec4(b, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "horizon-theme"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Horizon's design tokens, shared by the launcher (`launcher/src/theme.rs`)
|
||||
//! and the game's menus (`horizon-client`'s `ui_theme`), so the two read as
|
||||
//! one product: change a value here and both follow.
|
||||
//!
|
||||
//! One neutral palette in two appearances and one accent. Spacing follows
|
||||
//! 4/8/12/16/24/32. Type: Geist for the interface, Instrument Serif for
|
||||
//! titles, Geist Mono for consoles and commands, all bundled here under the
|
||||
//! SIL Open Font License (`fonts/OFL-*.txt`). Five sizes: 12, 13, 15, 28 and 56.
|
||||
//! Colours are `0xRRGGBB`; sizes are in logical pixels.
|
||||
use std::time::Duration;
|
||||
|
||||
pub const FONT: &str = "Geist";
|
||||
pub const DISPLAY: &str = "Instrument Serif";
|
||||
pub const MONO: &str = "Geist Mono";
|
||||
|
||||
/// The bundled font files.
|
||||
pub mod fonts {
|
||||
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
|
||||
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
|
||||
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
|
||||
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
|
||||
pub const INSTRUMENT_SERIF: &[u8] = include_bytes!("../fonts/InstrumentSerif-Regular.ttf");
|
||||
}
|
||||
|
||||
pub const SMALL: f32 = 12.;
|
||||
pub const BODY: f32 = 13.;
|
||||
pub const LARGE: f32 = 15.;
|
||||
pub const TITLE: f32 = 28.;
|
||||
pub const HERO: f32 = 56.;
|
||||
/// The opening's wordmark.
|
||||
pub const WORDMARK: f32 = 96.;
|
||||
|
||||
/// The spacing scale.
|
||||
pub const SPACE: [f32; 6] = [4., 8., 12., 16., 24., 32.];
|
||||
|
||||
/// Corner radii: buttons, fields, nav items and rows; segments inside a
|
||||
/// segmented control; the segmented control's frame; menus and cards.
|
||||
pub const RADIUS: f32 = 6.;
|
||||
pub const RADIUS_SEGMENT: f32 = 5.;
|
||||
pub const RADIUS_GROUP: f32 = 7.;
|
||||
pub const RADIUS_CARD: f32 = 8.;
|
||||
/// Dividers and borders are one pixel.
|
||||
pub const HAIRLINE: f32 = 1.;
|
||||
|
||||
/// Control heights: buttons and nav items, segments, the large Play button.
|
||||
pub const CONTROL: f32 = 30.;
|
||||
pub const SEGMENT: f32 = 26.;
|
||||
pub const CONTROL_LARGE: f32 = 40.;
|
||||
/// A pressed primary button's opacity (secondary and ghost: `PRESSED_SOFT`).
|
||||
pub const PRESSED: f32 = 0.82;
|
||||
pub const PRESSED_SOFT: f32 = 0.8;
|
||||
/// Disabled controls' opacity.
|
||||
pub const DISABLED: f32 = 0.4;
|
||||
/// The hover layer of a primary button: white at this opacity.
|
||||
pub const PRIMARY_HOVER: f32 = 0.10;
|
||||
/// Sidebar width and the label column of settings rows.
|
||||
pub const SIDEBAR: f32 = 200.;
|
||||
pub const ROW_LABEL: f32 = 150.;
|
||||
|
||||
/// Motion: short and eased out, never bouncy.
|
||||
pub const QUICK: Duration = Duration::from_millis(160);
|
||||
pub const MOVE: Duration = Duration::from_millis(240);
|
||||
/// The opening: the wordmark holds, then the interface appears.
|
||||
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
|
||||
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
|
||||
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
|
||||
|
||||
/// GPUI's `ease_out_quint`, used for hovers, the sliding selection and page changes.
|
||||
pub fn ease_out_quint(t: f32) -> f32 {
|
||||
1. - (1. - t.clamp(0., 1.)).powi(5)
|
||||
}
|
||||
pub fn ease_out_cubic(t: f32) -> f32 {
|
||||
1. - (1. - t.clamp(0., 1.)).powi(3)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
pub bg: u32,
|
||||
pub sidebar: u32,
|
||||
pub raised: u32,
|
||||
pub hover: u32,
|
||||
pub border: u32,
|
||||
pub text: u32,
|
||||
pub muted: u32,
|
||||
pub faint: u32,
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
pub danger: u32,
|
||||
pub dark: bool,
|
||||
}
|
||||
|
||||
pub const DARK: Palette = Palette {
|
||||
bg: 0x111113,
|
||||
sidebar: 0x0c0c0d,
|
||||
raised: 0x1a1a1d,
|
||||
hover: 0x202024,
|
||||
border: 0x26262a,
|
||||
text: 0xe8e8ea,
|
||||
muted: 0x9a9aa1,
|
||||
faint: 0x626269,
|
||||
accent: 0x7c5cc4,
|
||||
on_accent: 0xffffff,
|
||||
danger: 0xd9705f,
|
||||
dark: true,
|
||||
};
|
||||
|
||||
pub const LIGHT: Palette = Palette {
|
||||
bg: 0xfcfcfb,
|
||||
sidebar: 0xf4f4f2,
|
||||
raised: 0xf2f2f0,
|
||||
hover: 0xebebe8,
|
||||
border: 0xe3e3df,
|
||||
text: 0x19191b,
|
||||
muted: 0x63636a,
|
||||
faint: 0x9a9a9f,
|
||||
accent: 0x6a4bb3,
|
||||
on_accent: 0xffffff,
|
||||
danger: 0xb84a3a,
|
||||
dark: false,
|
||||
};
|
||||
@@ -6,7 +6,9 @@ To play, use [the launcher](../launcher/README.md). These pages are for develope
|
||||
|
||||
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
|
||||
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
|
||||
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
|
||||
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
|
||||
- Menus: the title, world list, create world, multiplayer, options and pause screens share the launcher's design tokens and fonts (`crates/horizon-theme`), drawn by `ui_theme` over a live frozen backdrop (`game/backdrop.rs`, blurred and graded by `post.rs`). Options → Interface → Menu style switches to vanilla's screens (Classic, kept in `horizon.txt`; `HORIZON_MENU_STYLE=horizon|classic` overrides it, and scripted runs default to Classic). [Title](media/menu-title.png) ([webm](media/menu-title.webm)), [worlds](media/menu-worlds.png), [options](media/menu-options.png), [pause](media/menu-pause.png).
|
||||
|
||||
## Parity
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Graphics: settings and performance
|
||||
|
||||
## Settings
|
||||
|
||||
In the Horizon menu style, the Options screen's **Video** section holds the graphics settings, with a Simple / Advanced switch at the top (remembered between sessions):
|
||||
|
||||
- **Simple**: a preset (Performance, Balanced, Quality, Ultra or Custom), render distance, FOV, max framerate, VSync and GUI scale.
|
||||
- **Advanced**: every setting, grouped under Rendering, Quality, Display, Preferences, Performance and Overlay.
|
||||
|
||||
In the Classic style, Video Settings starts with two buttons opening the same settings as vanilla-looking screens.
|
||||
|
||||
Every row has a one-line description and its cost in frame time (none, low, medium, high), and applies at once, except worker threads, which apply at the next start. A preset sets the quality settings together; changing one of them turns it into Custom. Display preferences (FOV, framerate, VSync, GUI scale, fullscreen, present mode, pacing) leave the preset as it is.
|
||||
|
||||

|
||||

|
||||
|
||||
| Group | Setting | Stored in |
|
||||
| --- | --- | --- |
|
||||
| Rendering | render distance, simulation distance, entity distance | `options.txt` (vanilla keys) |
|
||||
| | cave culling, chunk updates per frame | `horizon-graphics.txt` |
|
||||
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
|
||||
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
|
||||
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
|
||||
| | resolution scale, anti-aliasing (FXAA), present mode, frame pacing | `horizon-graphics.txt` |
|
||||
| Performance | worker threads, upload budget per frame | `horizon-graphics.txt` |
|
||||
| Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` |
|
||||
|
||||
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no FXAA, the present mode following VSync. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels.
|
||||
|
||||
Which settings each level shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); the Horizon shell draws them in `options_horizon.rs` with `ui_theme::described_row`, and the Classic screens use `graphics_style.rs`.
|
||||
|
||||
Not implemented: distant-terrain LOD, anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
|
||||
|
||||
## Benchmark
|
||||
|
||||
`tools/graphics_bench.py` opens a fresh creative world (seed 1234) on a virtual screen and flies the camera along four fixed 192-block paths, over a village, a forest, the ocean and a mountain, at render distances 8, 16 and 32, one game per distance. Each flight takes 12 seconds and reports average FPS, 1% lows, frame-time percentiles, CPU time per frame (frame time minus waits for the GPU, the display and the limiter), the split of that CPU time (world update, ticks, frame preparation, renderer) for all frames and for the slowest 1%, and GPU time per frame from timestamp queries.
|
||||
|
||||
```sh
|
||||
cargo build --release -p horizon-client --bins
|
||||
python3 tools/graphics_bench.py --label mine # lavapipe on vscreen: CPU-side numbers
|
||||
python3 tools/graphics_bench.py --label mine-gpu --offscreen # the desktop's RTX 4080
|
||||
python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown table
|
||||
```
|
||||
|
||||
vscreen is a headless compositor whose frames are software-rendered (lavapipe), so its frame times measure the CPU, rasterisation included. `--offscreen` (`horizon-client --offscreen`) renders into a texture on the most powerful GPU instead of the window, which measures the RTX 4080 under the same compositor. `--distances`, `--scenes` and `--build` pick a subset or another build. Results go to `benchmarks/graphics/<label>.json`, with the load average before and after: the desktop is shared, and other sessions' builds move the numbers.
|
||||
|
||||
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
|
||||
|
||||
## Results
|
||||
|
||||
Baseline `27a32cc3` (main) against `9006566b`, the same full run (render distances 8, 16 then 32 in one fresh world), on ludodesktop.
|
||||
|
||||
RTX 4080, `--offscreen` (`benchmarks/graphics/baseline-4080.json`, `after-4080.json`):
|
||||
|
||||
| RD | scene | avg FPS | 1% low FPS | p99 ms | CPU ms/frame | GPU ms/frame |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 8 | village | 363 → 2409 | 21.6 → 160.8 | 37 → 1 | 2.7 → 0.4 | 0.42 → 0.10 |
|
||||
| 8 | forest | 613 → 1099 | 49.6 → 82.9 | 10 → 10 | 1.6 → 0.9 | 0.40 → 0.26 |
|
||||
| 8 | ocean | 323 → 687 | 5.4 → 47.5 | 33 → 16 | 2.7 → 1.4 | 0.24 → 0.37 |
|
||||
| 8 | mountain | 28 → 344 | 7.4 → 30.0 | 111 → 33 | 12.4 → 1.6 | 0.46 → 0.34 |
|
||||
| 16 | village | 8 → 1041 | 2.4 → 64.0 | 409 → 10 | 138.3 → 1.0 | 3.59 → 0.23 |
|
||||
| 16 | forest | 28 → 316 | 0.5 → 49.6 | 159 → 18 | 37.9 → 3.2 | 2.68 → 0.75 |
|
||||
| 16 | ocean | 59 → 385 | 10.5 → 72.0 | 85 → 12 | 16.8 → 2.6 | 1.96 → 0.68 |
|
||||
| 16 | mountain | 27 → 220 | 7.8 → 30.0 | 109 → 33 | 20.2 → 3.1 | 7.14 → 0.94 |
|
||||
| 32 | village | 4 → 48 | 2.6 → 0.8 | 387 → 76 | 244.2 → 9.4 | 10.36 → 1.77 |
|
||||
| 32 | forest | 8 → 28 | 3.7 → 0.9 | 261 → 1108 | 118.7 → 40.1 | 15.90 → 2.30 |
|
||||
| 32 | ocean | 2 → 67 | 1.6 → 10.1 | 626 → 95 | 424.8 → 14.7 | 9.48 → 2.91 |
|
||||
| 32 | mountain | 3 → 29 | 2.5 → 15.2 | 407 → 64 | 300.5 → 16.1 | 9.73 → 8.47 |
|
||||
|
||||
lavapipe on vscreen, CPU-rendered (`baseline-lavapipe.json`, `after-lavapipe.json`); at 16 and 32 chunks most of the frame is software rasterisation, which no change here touches:
|
||||
|
||||
| RD | scene | avg FPS | 1% low FPS | p99 ms | CPU ms/frame | GPU ms/frame |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 8 | village | 16 → 58 | 6.6 → 31.3 | 134 → 29 | 48.2 → 11.4 | 26.73 → 13.52 |
|
||||
| 8 | forest | 20 → 42 | 9.9 → 20.0 | 95 → 37 | 31.0 → 18.0 | 37.27 → 21.36 |
|
||||
| 8 | ocean | 28 → 60 | 1.5 → 34.8 | 181 → 27 | 25.7 → 10.9 | 18.86 → 12.74 |
|
||||
| 8 | mountain | 27 → 38 | 11.2 → 27.3 | 75 → 34 | 22.5 → 11.5 | 23.76 → 14.11 |
|
||||
| 16 | village | 11 → 21 | 4.2 → 8.8 | 227 → 75 | 69.2 → 31.8 | 49.04 → 40.50 |
|
||||
| 16 | forest | 12 → 18 | 5.7 → 11.5 | 137 → 81 | 58.4 → 36.8 | 56.45 → 47.82 |
|
||||
| 16 | ocean | 8 → 8 | 2.6 → 4.8 | 385 → 209 | 98.4 → 96.7 | 71.51 → 57.30 |
|
||||
| 16 | mountain | 6 → 12 | 3.1 → 4.9 | 321 → 189 | 124.9 → 56.7 | 95.47 → 70.81 |
|
||||
| 32 | village | 3 → 5 | 2.0 → 3.6 | 497 → 278 | 345.6 → 111.2 | 85.50 → 121.35 |
|
||||
| 32 | forest | 2 → 4 | 1.5 → 3.4 | 656 → 296 | 354.8 → 155.5 | 201.54 → 192.78 |
|
||||
| 32 | ocean | 3 → 3 | 1.2 → 2.2 | 849 → 465 | 330.8 → 288.0 | 113.80 → 116.97 |
|
||||
| 32 | mountain | 3 → 4 | 1.8 → 2.4 | 570 → 414 | 325.2 → 171.4 | 104.99 → 166.80 |
|
||||
|
||||
Render distance 32 still has long stalls in the forest and village flights: the slowest frames are game ticks (mob AI bursts as chunks load, and thousands of dropped items piling up in some chunks, which the sequential run accumulates), not rendering. Results swing between runs on the shared desktop; the probe runs during the work saw RD 32 forest from 15 to 335 FPS depending on what the world had accumulated.
|
||||
|
||||
## What changed
|
||||
|
||||
The baseline was CPU-bound everywhere: on the RTX 4080 the GPU spent 0.2 to 16 ms per frame, the CPU 2 to 425.
|
||||
|
||||
- **Ticking**: mobs, minecarts and dropped items outside the entity-ticking chunks (the simulation distance, as `DistanceManager.inEntityTickingRange`) only check despawning, as in `ServerLevel.tick`; every loaded one used to tick, so render distance 32 ticked seven times as many as vanilla. Villager POI searches skip sections whose palette holds no POI block. Sky darkening is sampled once per time and weather instead of once per mob, and the world's chunk map uses a fast hash.
|
||||
- **Stalls**: chunks and entity chunks unloaded while moving are encoded and written by a background region writer (loads see queued records first, saves wait for the queue); deleting entity chunks a region doesn't hold no longer rewrites the region file; unloading drops block entities in one pass instead of one per chunk.
|
||||
- **Rendering**: terrain sections share one vertex arena drawn with one indirect multi-draw per pass, instead of a buffer and draw call each; the cave-culling search runs on dense arrays; mobs are culled by the view frustum and vanilla's per-entity render distance, block entities by the frustum; per-frame scratch buffers are reused.
|
||||
- **Streaming**: its bookkeeping uses the fast hash, and a column's readiness for meshing is checked once per frame rather than once per dirty section.
|
||||
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 989 KiB |
@@ -55,6 +55,15 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
|
||||
On headless Linux use `vscreen start ./target/release/horizon-client` and
|
||||
`vscreen shot`.
|
||||
|
||||
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
|
||||
why it has none (`Horizon: no sound: ...`). It opens none with
|
||||
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
|
||||
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
|
||||
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
|
||||
mute a test run's stream in the mixer: PipeWire remembers the mute for every
|
||||
later `horizon-client`. The launcher starts the game as "Horizon" for that
|
||||
reason.
|
||||
|
||||
### Drive the game from scripts (`horizon-ctl`)
|
||||
|
||||
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
|
||||
@@ -74,6 +83,10 @@ $C bench 10 # avg/1%-low FPS and p50/p95/p99/max frame tim
|
||||
$C quit # save and exit
|
||||
```
|
||||
|
||||
`fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH]` moves the camera along a line at
|
||||
constant speed and replies with the same summary as `bench`, plus CPU and GPU
|
||||
time per frame; [the graphics benchmark](graphics.md#benchmark) is built on it.
|
||||
|
||||
Other commands: `status`, `key KEY down|up|tap`, `mouse left|right|middle
|
||||
down|up|click`, `turn`, `tp X Y Z`, `hotbar N`, `give ITEM [COUNT]`,
|
||||
`render-distance N`, `time TICKS`, `resume`, `stats [reset]`, `open WORLD [SEED]
|
||||
|
||||
@@ -2565,12 +2565,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "horizon-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"gpui",
|
||||
"horizon-theme",
|
||||
"image",
|
||||
"pulldown-cmark",
|
||||
"self-replace",
|
||||
@@ -2581,6 +2582,10 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-theme"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Downloads, prepares and starts Horizon builds"
|
||||
@@ -20,6 +20,7 @@ dirs = "5"
|
||||
self-replace = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
horizon-theme = { path = "../crates/horizon-theme" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -17,16 +17,23 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
|
||||
26.3 server jar from that version's own json, Python 3.10+ and Java 25), then starts the
|
||||
client, or a server (`--dedicated --nogui`) in a folder you choose, with a live console.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. A quiet "Join Discord" link sits in the sidebar footer.
|
||||
- **What's next**: the README's roadmap, from main.
|
||||
|
||||
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
|
||||
`~/Library/Application Support/horizon-launcher` on macOS, `%APPDATA%\horizon-launcher` on
|
||||
Windows): `versions/<tag>/`, `server-jars/`, `server/` and `settings.json`.
|
||||
|
||||
- **Settings**: install new Horizon versions automatically (off by default; the selected
|
||||
version only moves to the new one if you were on the newest), the opening animation, and the
|
||||
launcher's own version and update state.
|
||||
- **Settings**: install new Horizon versions automatically (on unless you turn it off; the
|
||||
selected version only moves to the new one if you were on the newest), the opening animation,
|
||||
the game's menu style, and the launcher's own version and update state.
|
||||
- **Menu style**: Horizon's own menus or Classic (vanilla's screens). Classic has no way back
|
||||
inside the game, so the launcher is where you return to Horizon's. It is the `menuStyle:` line
|
||||
of `horizon.txt` in the game's home folder (`HORIZON_HOME`, else `horizon` in the data
|
||||
directory), which the game reads when it starts.
|
||||
- **Links**: the Discord invite and the Sponsors page come from `launcher/links.json` on main
|
||||
(`{"discord": "...", "sponsor": "..."}`), so they change without a launcher release. Offline,
|
||||
the launcher uses the same values built in; an empty address hides its button.
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -56,10 +63,12 @@ The same binary is a command-line tool; without a subcommand it opens the window
|
||||
horizon-launcher server [--dir <dir>] [--version <tag>] [--accept-eula]
|
||||
horizon-launcher notes [<tag>] patch notes as plain text
|
||||
horizon-launcher next the roadmap
|
||||
horizon-launcher links the Discord and sponsor links
|
||||
horizon-launcher menu-style [horizon|classic] show or set the game's menu style
|
||||
horizon-launcher update [--check] Horizon and the launcher
|
||||
|
||||
`--json` prints JSON where there is something to report (`versions`, `install`, `notes`,
|
||||
`next`, `update --check`). Errors go to stderr with exit code 1. `server` runs in the terminal,
|
||||
`next`, `links`, `menu-style`, `update --check`). Errors go to stderr with exit code 1. `server` runs in the terminal,
|
||||
so lines you type reach its console. `HORIZON_LAUNCHER_HOME` replaces the data folder (for
|
||||
scripts and tests).
|
||||
|
||||
@@ -71,7 +80,7 @@ scripts and tests).
|
||||
|
||||
(with the full path to the binary if it is not on your `PATH`). Its tools: `list_versions`,
|
||||
`install_version`, `remove_version`, `prepare_resources`, `launch_client`, `start_server`,
|
||||
`server_command`, `server_log`, `stop_server`, `patch_notes`, `roadmap` and `check_updates`.
|
||||
`server_command`, `server_log`, `stop_server`, `patch_notes`, `roadmap`, `links`, `menu_style` and `check_updates`.
|
||||
A server started through MCP stops (cleanly, with `stop`) when the session ends. Starting one
|
||||
needs `accept_eula`, which an agent should only pass with the player's consent.
|
||||
|
||||
@@ -83,11 +92,14 @@ The window, the command line and the MCP server all call the same core, `src/bac
|
||||
|
||||
On NixOS, `nix-shell` in this folder provides the libraries GPUI links to (Wayland, X11,
|
||||
xkbcommon, Vulkan, fontconfig). macOS needs Xcode's command line tools. The `launcher`
|
||||
workflow builds all three platforms on every push to the `launcher` branch.
|
||||
workflow builds all three platforms on every push to the `launcher` branch (or a
|
||||
`launcher-*` one), then installs the latest Horizon into a temporary folder and checks that the
|
||||
client starts (`--help`) and that the binaries import only system libraries (Windows).
|
||||
|
||||
cargo test --lib # tag separation, version order, update selection, notes as text
|
||||
cargo test --lib # tag separation, version order, update selection, notes as text,
|
||||
# the auto-install default, the links fallback, the menu style file
|
||||
|
||||
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `assets/fonts/OFL-*.txt`) are
|
||||
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `crates/horizon-theme/fonts/OFL-*.txt`, shared with the game's menus) are
|
||||
bundled so the type is the same on every platform.
|
||||
|
||||
Background image (`assets/background.jpg`): backgrounds.supply (free preview), licence to confirm before release.
|
||||
|
||||
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 408 KiB |
@@ -0,0 +1 @@
|
||||
{"discord": "https://discord.gg/HcTeMZxe5E", "sponsor": "https://github.com/sponsors/ludovic111"}
|
||||
@@ -9,7 +9,6 @@ use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub const REPO: &str = "ludovic111/horizon";
|
||||
pub const SPONSOR_URL: &str = "https://github.com/sponsors/ludovic111";
|
||||
pub const README_URL: &str = "https://github.com/ludovic111/horizon/blob/main/launcher/README.md";
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
/// Launcher releases are tagged `launcher-vX.Y.Z`; Horizon's own are `vYYYY.MM.DD`.
|
||||
@@ -163,6 +162,43 @@ pub fn roadmap() -> Result<String, String> {
|
||||
Err("The README has no roadmap section yet.".into())
|
||||
}
|
||||
|
||||
/// The community and sponsor links, from `launcher/links.json` on main, so they can change
|
||||
/// without a launcher release. An empty address hides its button.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct Links {
|
||||
pub discord: String,
|
||||
pub sponsor: String,
|
||||
}
|
||||
|
||||
impl Default for Links {
|
||||
/// What the launcher uses until (or unless) it can read links.json.
|
||||
fn default() -> Self {
|
||||
Self { discord: "https://discord.gg/HcTeMZxe5E".into(), sponsor: "https://github.com/sponsors/ludovic111".into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Links {
|
||||
/// Reads links.json. A key that is missing keeps its default, an empty one hides the link,
|
||||
/// and text that is not a JSON object gives the defaults.
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut links = Self::default();
|
||||
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(text) {
|
||||
for (key, slot) in [("discord", &mut links.discord), ("sponsor", &mut links.sponsor)] {
|
||||
if let Some(url) = map.get(key).and_then(|v| v.as_str()) {
|
||||
*slot = url.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
links
|
||||
}
|
||||
}
|
||||
|
||||
/// The links from main, or the built-in ones when offline.
|
||||
pub fn links() -> Links {
|
||||
let url = format!("https://raw.githubusercontent.com/{REPO}/main/launcher/links.json");
|
||||
get(&url).ok().and_then(|r| r.into_string().ok()).map_or_else(Links::default, |text| Links::parse(&text))
|
||||
}
|
||||
|
||||
/// The launcher's data folder; `HORIZON_LAUNCHER_HOME` overrides it (for tests and scripts).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
|
||||
@@ -346,11 +382,72 @@ pub fn game_home() -> PathBuf {
|
||||
dirs::data_dir().unwrap_or_else(std::env::temp_dir).join("horizon")
|
||||
}
|
||||
|
||||
/// Which drawing the game's menus use. Classic is vanilla's own screens, which have no way to
|
||||
/// switch back, so the launcher is where the player returns to Horizon's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuStyle {
|
||||
Horizon,
|
||||
Classic,
|
||||
}
|
||||
|
||||
impl MenuStyle {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MenuStyle::Horizon => "horizon",
|
||||
MenuStyle::Classic => "classic",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"horizon" => Some(MenuStyle::Horizon),
|
||||
"classic" => Some(MenuStyle::Classic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The style `horizon.txt` holds: Horizon unless it has the line `menuStyle:classic`, as the
|
||||
/// game reads it (crates/horizon-client/src/ui_theme.rs).
|
||||
fn from_file(text: &str) -> Self {
|
||||
if text.lines().any(|l| l.trim() == "menuStyle:classic") {
|
||||
MenuStyle::Classic
|
||||
} else {
|
||||
MenuStyle::Horizon
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with its `menuStyle:` line set to this style and every other line kept.
|
||||
fn into_file(self, text: &str) -> String {
|
||||
let mut out: Vec<String> = text.lines().filter(|l| !l.trim().starts_with("menuStyle:")).map(String::from).collect();
|
||||
out.insert(0, format!("menuStyle:{}", self.name()));
|
||||
out.join("\n") + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu style saved in the game home's `horizon.txt` (Horizon when there is none).
|
||||
pub fn menu_style() -> MenuStyle {
|
||||
MenuStyle::from_file(&fs::read_to_string(game_home().join("horizon.txt")).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn set_menu_style(style: MenuStyle) -> Result<(), String> {
|
||||
let home = game_home();
|
||||
fs::create_dir_all(&home).map_err(|e| e.to_string())?;
|
||||
let file = home.join("horizon.txt");
|
||||
let text = style.into_file(&fs::read_to_string(&file).unwrap_or_default());
|
||||
fs::write(file, text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn client_command(tag: &str) -> Command {
|
||||
let home = game_home();
|
||||
let _ = fs::create_dir_all(&home);
|
||||
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
|
||||
cmd.current_dir(home).arg("--resources").arg(resources(tag));
|
||||
// PipeWire remembers volume and mute per application name. The game's own name is
|
||||
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
|
||||
// muting it once left every later game silent: played from here it is "Horizon".
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
|
||||
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -701,6 +798,7 @@ pub enum Job {
|
||||
pub enum Msg {
|
||||
Releases(Result<Vec<Release>, String>),
|
||||
Roadmap(Result<String, String>),
|
||||
Links(Links),
|
||||
Progress(String, f32),
|
||||
Installed(String, Result<(), String>),
|
||||
Line(Job, String),
|
||||
@@ -774,8 +872,10 @@ pub struct Settings {
|
||||
pub selected: Option<String>,
|
||||
pub minecraft_dir: Option<PathBuf>,
|
||||
pub server_dir: Option<PathBuf>,
|
||||
/// Unset means on: only a player who turned it off has `false` here. 0.1.1 wrote its
|
||||
/// `auto_install` (off by default) for everyone, so this is a new key and that one is ignored.
|
||||
#[serde(default)]
|
||||
pub auto_install: bool,
|
||||
pub install_new_versions: Option<bool>,
|
||||
/// Off by default, so the opening animation shows unless the player turns it off.
|
||||
#[serde(default)]
|
||||
pub skip_opening: bool,
|
||||
@@ -783,10 +883,14 @@ pub struct Settings {
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
fs::read_to_string(data_dir().join("settings.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
Self::parse(&fs::read_to_string(data_dir().join("settings.json")).unwrap_or_default())
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
serde_json::from_str(text).unwrap_or_default()
|
||||
}
|
||||
/// Whether new Horizon versions are installed automatically.
|
||||
pub fn auto_install(&self) -> bool {
|
||||
self.install_new_versions.unwrap_or(true)
|
||||
}
|
||||
pub fn save(&self) {
|
||||
let _ = fs::create_dir_all(data_dir());
|
||||
@@ -799,6 +903,16 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::*;
|
||||
|
||||
#[test]
|
||||
fn the_game_plays_under_its_own_audio_stream_name() {
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
|
||||
return;
|
||||
}
|
||||
let cmd = client_command("v2026.10.13");
|
||||
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
|
||||
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_never_mix() {
|
||||
assert!(is_game_tag("v2026.10.13"));
|
||||
@@ -843,6 +957,53 @@ mod tests {
|
||||
assert!(select_after_update(None, &installed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_versions_install_automatically_unless_turned_off() {
|
||||
// No settings file, an old one that never mentioned it, and 0.1.1's default `false`.
|
||||
assert!(Settings::parse("").auto_install());
|
||||
assert!(Settings::parse("{}").auto_install());
|
||||
assert!(Settings::parse(r#"{"selected": "v2026.10.13", "auto_install": false}"#).auto_install());
|
||||
assert!(Settings::parse(r#"{"install_new_versions": true}"#).auto_install());
|
||||
assert!(!Settings::parse(r#"{"install_new_versions": false}"#).auto_install());
|
||||
// Saving after the player turned it off keeps it off.
|
||||
let off = Settings { install_new_versions: Some(false), ..Settings::default() };
|
||||
assert!(!Settings::parse(&serde_json::to_string(&off).unwrap()).auto_install());
|
||||
// The selection rule is unchanged: the new version is selected only from the newest.
|
||||
let installed = ["v2026.10.12".to_string(), "v2026.10.1".to_string()];
|
||||
assert!(select_after_update(Some("v2026.10.12"), &installed));
|
||||
assert!(!select_after_update(Some("v2026.10.1"), &installed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_fall_back_to_the_built_in_ones() {
|
||||
let defaults = Links::default();
|
||||
assert_eq!(defaults.discord, "https://discord.gg/HcTeMZxe5E");
|
||||
assert_eq!(defaults.sponsor, "https://github.com/sponsors/ludovic111");
|
||||
// The file on main says the same thing as the defaults.
|
||||
let file = Links::parse(r#"{"discord": "https://discord.gg/HcTeMZxe5E", "sponsor": "https://github.com/sponsors/ludovic111"}"#);
|
||||
assert_eq!(file, defaults);
|
||||
// Unreadable or missing, offline included: the defaults.
|
||||
assert_eq!(Links::parse(""), defaults);
|
||||
assert_eq!(Links::parse("<html>404</html>"), defaults);
|
||||
assert_eq!(Links::parse("[1]"), defaults);
|
||||
assert_eq!(Links::parse(r#"{"discord": 5}"#), defaults);
|
||||
// A key that is missing keeps its default; a new address replaces it; empty hides it.
|
||||
assert_eq!(Links::parse(r#"{"discord": "https://x.y/z"}"#), Links { discord: "https://x.y/z".into(), ..defaults.clone() });
|
||||
assert_eq!(Links::parse(r#"{"discord": "", "sponsor": " "}"#), Links { discord: String::new(), sponsor: String::new() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_style_file_matches_the_games() {
|
||||
assert_eq!(MenuStyle::from_file(""), MenuStyle::Horizon);
|
||||
assert_eq!(MenuStyle::from_file("menuStyle:horizon\n"), MenuStyle::Horizon);
|
||||
assert_eq!(MenuStyle::from_file("menuStyle:classic\n"), MenuStyle::Classic);
|
||||
assert_eq!(MenuStyle::from_file("other:1\nmenuStyle:classic\n"), MenuStyle::Classic);
|
||||
assert_eq!(MenuStyle::Classic.into_file(""), "menuStyle:classic\n");
|
||||
assert_eq!(MenuStyle::Horizon.into_file("menuStyle:classic\nother:1\n"), "menuStyle:horizon\nother:1\n");
|
||||
assert_eq!(MenuStyle::parse("classic"), Some(MenuStyle::Classic));
|
||||
assert_eq!(MenuStyle::parse("vanilla"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_notes() {
|
||||
let text = markdown_to_text("# Title\n\nSome *words* and [a link](https://x.y).\n\n- one\n- two\n");
|
||||
|
||||
@@ -57,6 +57,14 @@ enum Cmd {
|
||||
Notes { tag: Option<String> },
|
||||
/// Show the roadmap
|
||||
Next,
|
||||
/// Show the Discord and sponsor links
|
||||
Links,
|
||||
/// Show or set the game's menu style (classic is vanilla's own screens)
|
||||
MenuStyle {
|
||||
/// Omit to show the current style
|
||||
#[arg(value_parser = ["horizon", "classic"])]
|
||||
style: Option<String>,
|
||||
},
|
||||
/// Look for a newer Horizon release and launcher, and install them
|
||||
Update {
|
||||
/// Only report what is available
|
||||
@@ -198,6 +206,27 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
print!("{}", backend::markdown_to_text(&roadmap));
|
||||
}
|
||||
}
|
||||
Cmd::Links => {
|
||||
let links = backend::links();
|
||||
if as_json {
|
||||
json(serde_json::to_value(&links).unwrap());
|
||||
} else {
|
||||
for (name, url) in [("Discord", &links.discord), ("Sponsor", &links.sponsor)] {
|
||||
println!("{name:<8} {}", if url.is_empty() { "(none)" } else { url });
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::MenuStyle { style } => {
|
||||
if let Some(style) = style.as_deref().and_then(backend::MenuStyle::parse) {
|
||||
backend::set_menu_style(style)?;
|
||||
}
|
||||
let style = backend::menu_style().name();
|
||||
if as_json {
|
||||
json(serde_json::json!({ "menu_style": style }));
|
||||
} else {
|
||||
println!("Menu style: {style}");
|
||||
}
|
||||
}
|
||||
Cmd::Update { check } => update(check, as_json)?,
|
||||
Cmd::Mcp => unreachable!(),
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ enum SelfUpdate {
|
||||
|
||||
/// The opening: the wordmark over the image holds at least `OPENING_HOLD` (until the release
|
||||
/// list is in, at most `OPENING_MAX`), then the launcher appears over `OPENING_REVEAL`.
|
||||
const OPENING_HOLD: Duration = Duration::from_millis(1400);
|
||||
const OPENING_MAX: Duration = Duration::from_millis(2500);
|
||||
const OPENING_REVEAL: Duration = Duration::from_millis(800);
|
||||
const OPENING_HOLD: Duration = horizon_theme::OPENING_HOLD;
|
||||
const OPENING_MAX: Duration = horizon_theme::OPENING_MAX;
|
||||
const OPENING_REVEAL: Duration = horizon_theme::OPENING_REVEAL;
|
||||
|
||||
/// The phase of the opening at one moment: how far the reveal has gone, 0 to 1.
|
||||
struct Opening {
|
||||
@@ -78,7 +78,7 @@ fn background_image() -> Arc<RenderImage> {
|
||||
}
|
||||
|
||||
fn ease_out_cubic(x: f32) -> f32 {
|
||||
1. - (1. - x.clamp(0., 1.)).powi(3)
|
||||
horizon_theme::ease_out_cubic(x)
|
||||
}
|
||||
|
||||
/// How often the launcher looks for new releases while it is open.
|
||||
@@ -124,6 +124,9 @@ struct Launcher {
|
||||
confirm_remove: Option<String>,
|
||||
picker_open: bool,
|
||||
settings: Settings,
|
||||
/// The Discord and sponsor links: built in until links.json on main has been read.
|
||||
links: backend::Links,
|
||||
menu_style: backend::MenuStyle,
|
||||
preparing: bool,
|
||||
prepare_log: VecDeque<String>,
|
||||
prepare_error: Option<String>,
|
||||
@@ -192,6 +195,8 @@ impl Launcher {
|
||||
confirm_remove: None,
|
||||
picker_open: false,
|
||||
settings,
|
||||
links: backend::Links::default(),
|
||||
menu_style: backend::menu_style(),
|
||||
preparing: false,
|
||||
prepare_log: VecDeque::new(),
|
||||
prepare_error: None,
|
||||
@@ -210,6 +215,7 @@ impl Launcher {
|
||||
};
|
||||
this.fetch_releases();
|
||||
this.fetch_roadmap();
|
||||
this.fetch_links();
|
||||
this.check_updates();
|
||||
this
|
||||
}
|
||||
@@ -262,6 +268,11 @@ impl Launcher {
|
||||
std::thread::spawn(move || tx.send(Msg::Releases(backend::releases())));
|
||||
}
|
||||
|
||||
fn fetch_links(&self) {
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || tx.send(Msg::Links(backend::links())));
|
||||
}
|
||||
|
||||
fn fetch_roadmap(&mut self) {
|
||||
self.roadmap = Load::Loading;
|
||||
let tx = self.tx.clone();
|
||||
@@ -275,6 +286,7 @@ impl Launcher {
|
||||
match msg {
|
||||
Msg::Releases(r) => self.releases = r.map_or_else(Load::Failed, Load::Ready),
|
||||
Msg::Roadmap(r) => self.roadmap = r.map_or_else(Load::Failed, Load::Ready),
|
||||
Msg::Links(links) => self.links = links,
|
||||
Msg::Progress(tag, p) => {
|
||||
self.bar = (self.bar.1, p);
|
||||
self.installing = Some((tag, p));
|
||||
@@ -300,7 +312,7 @@ impl Launcher {
|
||||
Msg::Updates(Ok(updates)) => {
|
||||
self.update_error = None;
|
||||
self.updates = updates;
|
||||
if self.settings.auto_install {
|
||||
if self.settings.auto_install() {
|
||||
self.install_update();
|
||||
}
|
||||
if let Some(release) = self.updates.launcher.clone() {
|
||||
@@ -560,7 +572,7 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
|
||||
let id: SharedString = id.into();
|
||||
let t = *t;
|
||||
let hover = match kind {
|
||||
Kind::Primary => gpui::white().opacity(0.10),
|
||||
Kind::Primary => gpui::white().opacity(theme::PRIMARY_HOVER),
|
||||
_ => t.hover,
|
||||
};
|
||||
let base = div()
|
||||
@@ -570,19 +582,19 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h(px(30.))
|
||||
.h(px(theme::CONTROL))
|
||||
.px(px(12.))
|
||||
.rounded(px(6.))
|
||||
.rounded(px(theme::RADIUS))
|
||||
.text_size(px(theme::BODY))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.cursor_pointer()
|
||||
.child(hover_layer(&id, hover, 6.))
|
||||
.child(hover_layer(&id, hover, theme::RADIUS))
|
||||
.child(div().relative().child(label.into()));
|
||||
let base = track_hover(base, id);
|
||||
match kind {
|
||||
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(0.82)),
|
||||
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(0.8)),
|
||||
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(0.8)),
|
||||
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(theme::PRESSED)),
|
||||
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(theme::PRESSED_SOFT)),
|
||||
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(theme::PRESSED_SOFT)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,7 +619,7 @@ fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful<Div> {
|
||||
}
|
||||
|
||||
fn disabled(b: Stateful<Div>) -> Stateful<Div> {
|
||||
b.opacity(0.4).cursor_default()
|
||||
b.opacity(theme::DISABLED).cursor_default()
|
||||
}
|
||||
|
||||
fn page_header(title: &str, description: &str, t: &Theme) -> Div {
|
||||
@@ -637,7 +649,7 @@ fn row(label: &str, value: impl IntoElement, action: Option<Stateful<Div>>, t: &
|
||||
.items_center()
|
||||
.gap(px(16.))
|
||||
.py(px(12.))
|
||||
.child(div().w(px(150.)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
|
||||
.child(div().w(px(theme::ROW_LABEL)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
|
||||
.child(div().flex_1().min_w_0().text_size(px(13.)).text_color(t.text).child(value))
|
||||
.children(action)
|
||||
}
|
||||
@@ -687,7 +699,7 @@ fn megabytes(bytes: u64) -> String {
|
||||
impl Launcher {
|
||||
fn sidebar(&self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
const STEP: f32 = 32.;
|
||||
const STEP: f32 = theme::CONTROL + 2.;
|
||||
let mut nav = div().relative().flex().flex_col().gap(px(2.));
|
||||
// The selection is one pill that slides to the chosen item.
|
||||
let (from, to) = (self.previous_page.index() as f32 * STEP, self.page.index() as f32 * STEP);
|
||||
@@ -696,8 +708,8 @@ impl Launcher {
|
||||
.absolute()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(px(30.))
|
||||
.rounded(px(6.))
|
||||
.h(px(theme::CONTROL))
|
||||
.rounded(px(theme::RADIUS))
|
||||
.bg(t.raised)
|
||||
.border_1()
|
||||
.border_color(t.border)
|
||||
@@ -715,12 +727,12 @@ impl Launcher {
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(30.))
|
||||
.h(px(theme::CONTROL))
|
||||
.px(px(10.))
|
||||
.rounded(px(6.))
|
||||
.rounded(px(theme::RADIUS))
|
||||
.text_size(px(theme::BODY))
|
||||
.cursor_pointer()
|
||||
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), 6.)))
|
||||
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), theme::RADIUS)))
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
@@ -752,11 +764,22 @@ impl Launcher {
|
||||
),
|
||||
_ => div().child(format!("Launcher {}", backend::VERSION)),
|
||||
};
|
||||
let discord = (!self.links.discord.is_empty()).then(|| {
|
||||
let url = self.links.discord.clone();
|
||||
div()
|
||||
.id("discord")
|
||||
.mb(px(4.))
|
||||
.text_color(t.muted)
|
||||
.cursor_pointer()
|
||||
.hover(move |s| s.text_color(t.text))
|
||||
.child("Join Discord")
|
||||
.on_click(move |_, _, cx| cx.open_url(&url))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_none()
|
||||
.w(px(200.))
|
||||
.w(px(theme::SIDEBAR))
|
||||
.h_full()
|
||||
.bg(t.sidebar.opacity(0.8))
|
||||
.border_r_1()
|
||||
@@ -774,7 +797,7 @@ impl Launcher {
|
||||
)
|
||||
.child(nav)
|
||||
.child(div().flex_1())
|
||||
.child(div().px(px(10.)).text_size(px(theme::SMALL)).text_color(t.faint).child(footer))
|
||||
.child(div().px(px(10.)).text_size(px(theme::SMALL)).text_color(t.faint).children(discord).child(footer))
|
||||
}
|
||||
|
||||
fn segmented(&self, cx: &mut Context<Self>) -> Div {
|
||||
@@ -785,9 +808,9 @@ impl Launcher {
|
||||
.id(label)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(26.))
|
||||
.h(px(theme::SEGMENT))
|
||||
.px(px(14.))
|
||||
.rounded(px(5.))
|
||||
.rounded(px(theme::RADIUS_SEGMENT))
|
||||
.text_size(px(13.))
|
||||
.cursor_pointer()
|
||||
.when(active, |d| {
|
||||
@@ -804,7 +827,7 @@ impl Launcher {
|
||||
.flex()
|
||||
.p(px(2.))
|
||||
.gap(px(2.))
|
||||
.rounded(px(7.))
|
||||
.rounded(px(theme::RADIUS_GROUP))
|
||||
.bg(t.raised)
|
||||
.border_1()
|
||||
.border_color(t.border)
|
||||
@@ -1134,11 +1157,11 @@ impl Launcher {
|
||||
|
||||
fn settings_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let toggle = switch("auto-install", self.settings.auto_install, &t).on_click(cx.listener(
|
||||
let toggle = switch("auto-install", self.settings.auto_install(), &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.auto_install = !this.settings.auto_install;
|
||||
this.settings.install_new_versions = Some(!this.settings.auto_install());
|
||||
this.settings.save();
|
||||
if this.settings.auto_install {
|
||||
if this.settings.auto_install() {
|
||||
this.install_update();
|
||||
}
|
||||
cx.notify();
|
||||
@@ -1151,6 +1174,46 @@ impl Launcher {
|
||||
cx.notify();
|
||||
},
|
||||
));
|
||||
let menus = div()
|
||||
.id("menu-style")
|
||||
.flex()
|
||||
.flex_none()
|
||||
.p(px(2.))
|
||||
.gap(px(2.))
|
||||
.rounded(px(theme::RADIUS_GROUP))
|
||||
.bg(t.raised)
|
||||
.border_1()
|
||||
.border_color(t.border)
|
||||
.children([(backend::MenuStyle::Horizon, "Horizon"), (backend::MenuStyle::Classic, "Classic")].map(
|
||||
|(style, label)| {
|
||||
let active = self.menu_style == style;
|
||||
div()
|
||||
.id(label)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(theme::SEGMENT))
|
||||
.px(px(14.))
|
||||
.rounded(px(theme::RADIUS_SEGMENT))
|
||||
.text_size(px(13.))
|
||||
.cursor_pointer()
|
||||
.when(active, |d| {
|
||||
d.bg(t.bg).border_1().border_color(t.border).text_color(t.text).font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!active, |d| d.text_color(t.muted).hover(move |s| s.text_color(t.text)))
|
||||
.child(label)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
if backend::set_menu_style(style).is_ok() {
|
||||
this.menu_style = style;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
},
|
||||
));
|
||||
let menu_note = div().flex().flex_col().gap(px(2.)).child("Menu style").child(
|
||||
div().text_size(px(theme::SMALL)).text_color(t.muted).child(
|
||||
"Classic is vanilla's own screens, with no way back inside the game: switch back here. Applies the next time the game starts.",
|
||||
),
|
||||
);
|
||||
let auto = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -1225,7 +1288,7 @@ impl Launcher {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(24.))
|
||||
.child(page_header("Settings", "Updates, and the launcher itself.", &t))
|
||||
.child(page_header("Settings", "Updates, menus, and the launcher itself.", &t))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
@@ -1235,6 +1298,8 @@ impl Launcher {
|
||||
.child(divider(&t))
|
||||
.child(row("Opening", div().child("Show opening animation"), Some(opening), &t))
|
||||
.child(divider(&t))
|
||||
.child(row("Game menus", menu_note, Some(menus), &t))
|
||||
.child(divider(&t))
|
||||
.child(row("Horizon", horizon, horizon_action, &t))
|
||||
.child(divider(&t))
|
||||
.child(row("Launcher", div().child(launcher), launcher_action, &t))
|
||||
@@ -1484,7 +1549,7 @@ impl Launcher {
|
||||
|
||||
fn notes_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let sponsor = div()
|
||||
let sponsor = (!self.links.sponsor.is_empty()).then(|| div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
@@ -1506,15 +1571,16 @@ impl Launcher {
|
||||
.child("Horizon is built in the open. Sponsors fund the next release."),
|
||||
),
|
||||
)
|
||||
.child(button("sponsor", "Sponsor on GitHub", Kind::Secondary, &t).on_click(cx.listener(
|
||||
|_, _: &ClickEvent, _, cx| cx.open_url(backend::SPONSOR_URL),
|
||||
)));
|
||||
.child(button("sponsor", "Sponsor on GitHub", Kind::Secondary, &t).on_click({
|
||||
let url = self.links.sponsor.clone();
|
||||
move |_: &ClickEvent, _, cx| cx.open_url(&url)
|
||||
})));
|
||||
let page = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(24.))
|
||||
.child(page_header("Patch notes", "What changed in each release, newest first.", &t))
|
||||
.child(sponsor);
|
||||
.children(sponsor);
|
||||
let releases = match &self.releases {
|
||||
Load::Loading => return page.child(centered("Loading patch notes…", &t)),
|
||||
Load::Failed(e) => {
|
||||
@@ -1769,11 +1835,11 @@ fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
cx.text_system()
|
||||
.add_fonts(vec![
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Regular.ttf").as_slice()),
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Medium.ttf").as_slice()),
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-SemiBold.ttf").as_slice()),
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/GeistMono-Regular.ttf").as_slice()),
|
||||
Cow::Borrowed(include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf").as_slice()),
|
||||
Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR),
|
||||
Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
Cow::Borrowed(horizon_theme::fonts::GEIST_MONO),
|
||||
Cow::Borrowed(horizon_theme::fonts::INSTRUMENT_SERIF),
|
||||
])
|
||||
.expect("bundled fonts");
|
||||
let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx);
|
||||
|
||||
@@ -38,6 +38,9 @@ fn tools() -> Value {
|
||||
{ "name": "patch_notes", "description": "A release's patch notes as markdown.",
|
||||
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "Default: the newest release." } } } },
|
||||
{ "name": "roadmap", "description": "What's planned next for Horizon, from the README's roadmap.", "inputSchema": none },
|
||||
{ "name": "links", "description": "The Horizon community links: the Discord invite and the GitHub Sponsors page (an empty one is not offered).", "inputSchema": none },
|
||||
{ "name": "menu_style", "description": "Show or set the game's menu style. \"horizon\" is Horizon's own menus, \"classic\" is vanilla's screens, which have no way back inside the game. The game reads it when it starts.",
|
||||
"inputSchema": { "type": "object", "properties": { "style": { "type": "string", "enum": ["horizon", "classic"], "description": "Omit to show the current style." } } } },
|
||||
{ "name": "check_updates", "description": "Check GitHub for a Horizon release newer than every installed version, and for a newer launcher.", "inputSchema": none },
|
||||
])
|
||||
}
|
||||
@@ -136,6 +139,14 @@ impl Session {
|
||||
Ok(format!("# {} ({}, {})\n\n{}", if r.name.is_empty() { &r.tag } else { &r.name }, r.tag, r.date, r.body))
|
||||
}
|
||||
"roadmap" => backend::roadmap(),
|
||||
"links" => Ok(serde_json::to_string_pretty(&backend::links()).unwrap()),
|
||||
"menu_style" => {
|
||||
if let Some(name) = arg_str(args, "style") {
|
||||
let style = backend::MenuStyle::parse(name).ok_or("style is \"horizon\" or \"classic\"")?;
|
||||
backend::set_menu_style(style)?;
|
||||
}
|
||||
Ok(format!("Menu style: {}", backend::menu_style().name()))
|
||||
}
|
||||
"check_updates" => {
|
||||
let u = backend::check_updates()?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
|
||||
//! Type: Geist for the interface, Instrument Serif for titles, Geist Mono for consoles and
|
||||
//! commands, all bundled (SIL Open Font License). Five sizes: 12, 13, 15, 28 and 56.
|
||||
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`), which the game's
|
||||
//! menus share: one neutral palette in two appearances and one accent, spacing 4/8/12/16/24/32,
|
||||
//! Geist / Instrument Serif / Geist Mono (bundled, SIL Open Font License), five sizes.
|
||||
use gpui::{rgb, Hsla, WindowAppearance};
|
||||
use horizon_theme as tokens;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const FONT: &str = "Geist";
|
||||
pub const DISPLAY: &str = "Instrument Serif";
|
||||
pub const MONO: &str = "Geist Mono";
|
||||
pub const FONT: &str = tokens::FONT;
|
||||
pub const DISPLAY: &str = tokens::DISPLAY;
|
||||
pub const MONO: &str = tokens::MONO;
|
||||
|
||||
pub const SMALL: f32 = 12.;
|
||||
pub const BODY: f32 = 13.;
|
||||
pub const LARGE: f32 = 15.;
|
||||
pub const TITLE: f32 = 28.;
|
||||
pub const HERO: f32 = 56.;
|
||||
pub const SMALL: f32 = tokens::SMALL;
|
||||
pub const BODY: f32 = tokens::BODY;
|
||||
pub const LARGE: f32 = tokens::LARGE;
|
||||
pub const TITLE: f32 = tokens::TITLE;
|
||||
pub const HERO: f32 = tokens::HERO;
|
||||
|
||||
/// Shapes and controls, shared with the game's menus.
|
||||
pub use tokens::{
|
||||
CONTROL, CONTROL_LARGE, DISABLED, PRESSED, PRESSED_SOFT, PRIMARY_HOVER, RADIUS, RADIUS_CARD, RADIUS_GROUP,
|
||||
RADIUS_SEGMENT, ROW_LABEL, SEGMENT, SIDEBAR,
|
||||
};
|
||||
|
||||
/// Motion: short and eased out, never bouncy.
|
||||
pub const QUICK: Duration = Duration::from_millis(160);
|
||||
pub const MOVE: Duration = Duration::from_millis(240);
|
||||
pub const QUICK: Duration = tokens::QUICK;
|
||||
pub const MOVE: Duration = tokens::MOVE;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Theme {
|
||||
@@ -47,36 +54,27 @@ impl Theme {
|
||||
}
|
||||
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
bg: c(0x111113),
|
||||
sidebar: c(0x0c0c0d),
|
||||
raised: c(0x1a1a1d),
|
||||
hover: c(0x202024),
|
||||
border: c(0x26262a),
|
||||
text: c(0xe8e8ea),
|
||||
muted: c(0x9a9aa1),
|
||||
faint: c(0x626269),
|
||||
accent: c(0x7c5cc4),
|
||||
on_accent: c(0xffffff),
|
||||
danger: c(0xd9705f),
|
||||
dark: true,
|
||||
}
|
||||
Self::from(tokens::DARK)
|
||||
}
|
||||
|
||||
pub fn light() -> Self {
|
||||
Self::from(tokens::LIGHT)
|
||||
}
|
||||
|
||||
fn from(p: tokens::Palette) -> Self {
|
||||
Self {
|
||||
bg: c(0xfcfcfb),
|
||||
sidebar: c(0xf4f4f2),
|
||||
raised: c(0xf2f2f0),
|
||||
hover: c(0xebebe8),
|
||||
border: c(0xe3e3df),
|
||||
text: c(0x19191b),
|
||||
muted: c(0x63636a),
|
||||
faint: c(0x9a9a9f),
|
||||
accent: c(0x6a4bb3),
|
||||
on_accent: c(0xffffff),
|
||||
danger: c(0xb84a3a),
|
||||
dark: false,
|
||||
bg: c(p.bg),
|
||||
sidebar: c(p.sidebar),
|
||||
raised: c(p.raised),
|
||||
hover: c(p.hover),
|
||||
border: c(p.border),
|
||||
text: c(p.text),
|
||||
muted: c(p.muted),
|
||||
faint: c(p.faint),
|
||||
accent: c(p.accent),
|
||||
on_accent: c(p.on_accent),
|
||||
danger: c(p.danger),
|
||||
dark: p.dark,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a release binary needs a library its players may not have.
|
||||
|
||||
Windows .exe: every DLL in the import and delay-import tables must ship with
|
||||
Windows (no VCRUNTIME140.dll, vulkan-1.dll, ...): Vulkan and the like are
|
||||
loaded at run time, so a missing driver is an error message, not a dialog.
|
||||
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
|
||||
needed libraries the ones every desktop distribution has.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# DLLs that are part of Windows 10 and 11. api-ms-win-* and ext-ms-win-* API
|
||||
# sets resolve inside Windows too.
|
||||
WINDOWS_SYSTEM = {
|
||||
"advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "cfgmgr32.dll", "combase.dll",
|
||||
"comctl32.dll", "comdlg32.dll", "crypt32.dll", "d2d1.dll", "d3d11.dll", "d3d12.dll",
|
||||
"d3dcompiler_47.dll", "dbghelp.dll", "dcomp.dll", "dwmapi.dll", "dwrite.dll", "dxgi.dll",
|
||||
"gdi32.dll", "hid.dll", "icuuc.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll",
|
||||
"kernelbase.dll", "mfplat.dll", "mmdevapi.dll", "msvcrt.dll", "ncrypt.dll", "ntdll.dll",
|
||||
"ole32.dll", "oleaut32.dll", "opengl32.dll", "propsys.dll", "secur32.dll", "setupapi.dll",
|
||||
"shcore.dll", "shell32.dll", "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll",
|
||||
"uxtheme.dll", "version.dll", "winmm.dll", "ws2_32.dll", "xinput1_4.dll",
|
||||
}
|
||||
LINUX_LOADER = "/lib64/ld-linux-x86-64.so.2"
|
||||
LINUX_SYSTEM = {
|
||||
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
|
||||
"ld-linux-x86-64.so.2", "libasound.so.2",
|
||||
}
|
||||
|
||||
|
||||
def windows_allowed(name):
|
||||
name = name.lower()
|
||||
return name in WINDOWS_SYSTEM or name.startswith(("api-ms-win-", "ext-ms-win-"))
|
||||
|
||||
|
||||
def pe_imports(data):
|
||||
"""DLL names in a PE32+ file's import and delay-import tables."""
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError("not a PE file")
|
||||
sections_count, optional_size = struct.unpack_from("<H12xH", data, pe + 6)
|
||||
optional = pe + 24
|
||||
magic = struct.unpack_from("<H", data, optional)[0]
|
||||
directories = optional + (112 if magic == 0x20B else 96)
|
||||
sections = [struct.unpack_from("<8xIIII", data, optional + optional_size + 40 * i)
|
||||
for i in range(sections_count)]
|
||||
|
||||
def offset(rva):
|
||||
for size, address, raw_size, raw in sections:
|
||||
if address <= rva < address + max(size, raw_size):
|
||||
return rva - address + raw
|
||||
raise ValueError(f"RVA {rva:#x} outside every section")
|
||||
|
||||
def name_at(rva):
|
||||
start = offset(rva)
|
||||
return data[start:data.index(b"\0", start)].decode("ascii")
|
||||
|
||||
names = []
|
||||
# Directory 1: imports (20-byte descriptors, name at +12); 13: delay imports (32 bytes, name at +4).
|
||||
for index, size, name_field in ((1, 20, 12), (13, 32, 4)):
|
||||
rva = struct.unpack_from("<I", data, directories + 8 * index)[0]
|
||||
if not rva:
|
||||
continue
|
||||
at = offset(rva)
|
||||
while any(data[at:at + size]):
|
||||
names.append(name_at(struct.unpack_from("<I", data, at + name_field)[0]))
|
||||
at += size
|
||||
return names
|
||||
|
||||
|
||||
def elf_needs(data):
|
||||
"""The interpreter and DT_NEEDED libraries of a little-endian ELF64 file."""
|
||||
if data[:4] != b"\x7fELF" or data[4] != 2:
|
||||
raise ValueError("not an ELF64 file")
|
||||
phoff, phentsize, phnum = struct.unpack_from("<Q", data, 32)[0], *struct.unpack_from("<HH", data, 54)
|
||||
interpreter, dynamic, loads = None, None, []
|
||||
for i in range(phnum):
|
||||
kind, _, offset, vaddr, _, filesz = struct.unpack_from("<IIQQQQ", data, phoff + i * phentsize)
|
||||
if kind == 3:
|
||||
interpreter = data[offset:offset + filesz].rstrip(b"\0").decode()
|
||||
elif kind == 2:
|
||||
dynamic = (offset, filesz)
|
||||
elif kind == 1:
|
||||
loads.append((vaddr, offset, filesz))
|
||||
|
||||
def file_offset(address):
|
||||
for vaddr, offset, size in loads:
|
||||
if vaddr <= address < vaddr + size:
|
||||
return address - vaddr + offset
|
||||
raise ValueError(f"address {address:#x} outside every segment")
|
||||
|
||||
entries = []
|
||||
if dynamic:
|
||||
for at in range(dynamic[0], dynamic[0] + dynamic[1], 16):
|
||||
tag, value = struct.unpack_from("<qQ", data, at)
|
||||
if tag == 0:
|
||||
break
|
||||
entries.append((tag, value))
|
||||
strtab = next((file_offset(v) for t, v in entries if t == 5), None)
|
||||
|
||||
def string(index):
|
||||
start = strtab + index
|
||||
return data[start:data.index(b"\0", start)].decode()
|
||||
|
||||
needed = [string(v) for t, v in entries if t == 1]
|
||||
runpath = [string(v) for t, v in entries if t in (15, 29)]
|
||||
return interpreter, needed, runpath
|
||||
|
||||
|
||||
def check(path):
|
||||
data = Path(path).read_bytes()
|
||||
problems = []
|
||||
if data[:2] == b"MZ":
|
||||
names = pe_imports(data)
|
||||
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
|
||||
summary = ", ".join(sorted(set(n.lower() for n in names)))
|
||||
else:
|
||||
interpreter, needed, runpath = elf_needs(data)
|
||||
if interpreter not in (None, LINUX_LOADER):
|
||||
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
|
||||
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
|
||||
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
|
||||
summary = f"{interpreter}; " + ", ".join(needed)
|
||||
print(f"{path}: {summary}")
|
||||
for problem in problems:
|
||||
print(f" {path} {problem}", file=sys.stderr)
|
||||
return not problems
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("binaries", nargs="+")
|
||||
args = parser.parse_args()
|
||||
ok = all([check(path) for path in args.binaries])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repeatable graphics benchmark for the native client.
|
||||
|
||||
Opens a fresh seeded creative world on a virtual screen (vscreen) and flies the
|
||||
camera along fixed straight paths over a village, a forest, the ocean and a
|
||||
mountain, at render distances 8, 16 and 32. Each flight replies with frame
|
||||
times (average FPS, 1% lows, percentiles), the CPU part of each frame and the
|
||||
GPU time from timestamp queries.
|
||||
|
||||
python3 tools/graphics_bench.py --label baseline
|
||||
python3 tools/graphics_bench.py --label baseline --offscreen # the discrete GPU
|
||||
|
||||
`--offscreen` renders into a texture instead of the window, so the desktop's
|
||||
GPU can be measured even though the headless compositor (vscreen) only
|
||||
presents software-rendered (lavapipe) frames. Without it, frame times on
|
||||
vscreen are lavapipe's: use them for CPU-side numbers.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SEED = "1234"
|
||||
WORLD = "GraphicsBench"
|
||||
# (name, from xyz, to xyz, pitch): straight flights at a constant speed over
|
||||
# fixed scenery of seed 1234. Each path is 192 blocks long.
|
||||
SCENES = [
|
||||
("village", (-960.0, 125.0, 80.0), (-960.0, 125.0, 272.0), 35.0),
|
||||
("forest", (-1100.0, 120.0, -560.0), (-1100.0, 120.0, -368.0), 25.0),
|
||||
("ocean", (-300.0, 85.0, -420.0), (-300.0, 85.0, -228.0), 10.0),
|
||||
("mountain", (560.0, 260.0, 860.0), (752.0, 260.0, 860.0), 30.0),
|
||||
]
|
||||
OPTIONS = """version:4790
|
||||
enableVsync:false
|
||||
maxFps:260
|
||||
renderDistance:{distance}
|
||||
simulationDistance:12
|
||||
fov:0.0
|
||||
guiScale:0
|
||||
"""
|
||||
|
||||
|
||||
def vscreen_env():
|
||||
out = subprocess.run(["vscreen", "env"], cwd=ROOT, capture_output=True, text=True, check=True)
|
||||
env = dict(os.environ)
|
||||
for part in out.stdout.replace(";", " ").split():
|
||||
if "=" in part and not part.startswith("export"):
|
||||
k, v = part.split("=", 1)
|
||||
env[k] = v
|
||||
env.pop("DISPLAY", None)
|
||||
# Benchmark games never play sound on the machine's speakers.
|
||||
env["HORIZON_NO_AUDIO"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
class Game:
|
||||
def __init__(self, ctl, socket, env, cwd):
|
||||
self.ctl, self.socket, self.env, self.cwd = ctl, socket, env, cwd
|
||||
|
||||
def run(self, *words, check=True):
|
||||
out = subprocess.run(
|
||||
[str(self.ctl), "--socket", str(self.socket), *map(str, words)],
|
||||
cwd=self.cwd, env=self.env, capture_output=True, text=True,
|
||||
)
|
||||
line = out.stdout.strip().splitlines()[-1] if out.stdout.strip() else "{}"
|
||||
reply = json.loads(line) if line.startswith("{") else {"error": out.stderr.strip()}
|
||||
if check and "error" in reply:
|
||||
raise RuntimeError(f"{' '.join(map(str, words))}: {reply} {out.stderr.strip()}")
|
||||
return reply
|
||||
|
||||
|
||||
def run_distance(args, ctl, env, cwd, distance):
|
||||
(cwd / "options.txt").write_text(OPTIONS.format(distance=distance))
|
||||
socket = cwd / "control.sock"
|
||||
game = Game(ctl, socket, env, cwd)
|
||||
launch = [
|
||||
"launch", "--world", WORLD, "--seed", SEED, "--creative",
|
||||
"--render-distance", distance, "--resources", args.resources,
|
||||
"--no-pause", "--weather", "clear", "--time", "6000",
|
||||
]
|
||||
if args.offscreen:
|
||||
launch.append("--offscreen")
|
||||
game.run(*launch)
|
||||
results = {}
|
||||
try:
|
||||
game.run("wait-ready", 300)
|
||||
status = game.run("status")
|
||||
results["renderer"] = status.get("renderer")
|
||||
for name, start, end, pitch in SCENES:
|
||||
if args.scenes and name not in args.scenes.split(","):
|
||||
continue
|
||||
game.run("tp", *start)
|
||||
game.run("look", 0, pitch)
|
||||
game.run("time", 6000)
|
||||
game.run("wait-ready", 300)
|
||||
time.sleep(args.settle)
|
||||
summary = game.run("fly", *start, *end, args.seconds, pitch)
|
||||
results[name] = summary
|
||||
print(
|
||||
f" rd {distance:>2} {name:<9} {summary.get('avg_fps', 0):7.1f} fps"
|
||||
f" 1% low {summary.get('low_1pct_fps', 0):7.1f}"
|
||||
f" p99 {summary.get('p99_ms', 0):6.2f} ms"
|
||||
f" cpu {((summary.get('cpu') or {}).get('avg_ms') or 0):6.2f} ms"
|
||||
f" gpu {((summary.get('gpu') or {}).get('avg_ms') or 0):6.2f} ms"
|
||||
+ " [" + " ".join(f"{k} {v:.2f}" for k, v in (summary.get("phases_avg_ms") or {}).items()) + "]"
|
||||
+ " slow [" + " ".join(f"{k} {v:.1f}" for k, v in (summary.get("slow_phases_avg_ms") or {}).items()) + "]",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
game.run("quit", check=False)
|
||||
for _ in range(100):
|
||||
if not socket.exists():
|
||||
break
|
||||
time.sleep(0.2)
|
||||
# Keep each game's log (a crash or a slow-frame report) per run.
|
||||
log = socket.with_suffix(".log")
|
||||
if log.exists():
|
||||
shutil.copy(log, cwd / f"{args.label}-rd{distance}.log")
|
||||
return results
|
||||
|
||||
|
||||
def compare(before, after):
|
||||
"""A markdown table of two saved runs (labels under benchmarks/graphics)."""
|
||||
load = lambda label: json.loads((ROOT / "benchmarks/graphics" / f"{label}.json").read_text())
|
||||
a, b = load(before), load(after)
|
||||
print(f"| RD | scene | avg FPS | 1% low FPS | p99 ms | CPU ms/frame | GPU ms/frame |")
|
||||
print("| --- | --- | --- | --- | --- | --- | --- |")
|
||||
cell = lambda x, y, f: f"{f(x)} → {f(y)}"
|
||||
for rd, scenes in a["distances"].items():
|
||||
for name, old in scenes.items():
|
||||
new = b["distances"].get(rd, {}).get(name)
|
||||
if name == "renderer" or not new:
|
||||
continue
|
||||
ms = lambda v, k: (v.get(k) or {}).get("avg_ms") or 0
|
||||
print(f"| {rd} | {name} | {cell(old['avg_fps'], new['avg_fps'], lambda v: f'{v:.0f}')} "
|
||||
f"| {cell(old['low_1pct_fps'], new['low_1pct_fps'], lambda v: f'{v:.1f}')} "
|
||||
f"| {cell(old['p99_ms'], new['p99_ms'], lambda v: f'{v:.0f}')} "
|
||||
f"| {cell(ms(old, 'cpu'), ms(new, 'cpu'), lambda v: f'{v:.1f}')} "
|
||||
f"| {cell(ms(old, 'gpu'), ms(new, 'gpu'), lambda v: f'{v:.2f}')} |")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--label", default="run")
|
||||
p.add_argument("--distances", default="8,16,32")
|
||||
p.add_argument("--scenes", help="comma-separated subset of " + ",".join(s[0] for s in SCENES))
|
||||
p.add_argument("--seconds", type=float, default=12.0, help="flight duration per scene")
|
||||
p.add_argument("--settle", type=float, default=2.0, help="seconds to wait before each flight")
|
||||
p.add_argument("--offscreen", action="store_true", help="render offscreen on the discrete GPU")
|
||||
p.add_argument("--resources", default=str(ROOT / ".local/horizon-client/26.3"))
|
||||
p.add_argument("--build", default=str(ROOT / "target/release"))
|
||||
p.add_argument("--out", help="JSON file (default benchmarks/graphics/<label>.json)")
|
||||
p.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER"),
|
||||
help="print a markdown table comparing two saved runs, and exit")
|
||||
args = p.parse_args()
|
||||
if args.compare:
|
||||
compare(*args.compare)
|
||||
return
|
||||
ctl = Path(args.build).resolve() / "horizon-ctl"
|
||||
if not ctl.exists():
|
||||
sys.exit(f"{ctl} missing: cargo build --release -p horizon-client --bins")
|
||||
cwd = ROOT / ".local/graphics-bench"
|
||||
shutil.rmtree(cwd / "saves" / WORLD, ignore_errors=True)
|
||||
cwd.mkdir(parents=True, exist_ok=True)
|
||||
env = vscreen_env()
|
||||
subprocess.run(["vscreen", "up"], cwd=ROOT, check=True, capture_output=True)
|
||||
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
report = {
|
||||
"label": args.label,
|
||||
"date": datetime.date.today().isoformat(),
|
||||
"commit": commit,
|
||||
"host": os.uname().nodename,
|
||||
"offscreen": args.offscreen,
|
||||
"seed": SEED,
|
||||
"flight_seconds": args.seconds,
|
||||
"scenes": {n: {"from": a, "to": b, "pitch": c} for n, a, b, c in SCENES},
|
||||
"distances": {},
|
||||
"load_before": os.getloadavg(),
|
||||
}
|
||||
for d in [int(x) for x in args.distances.split(",")]:
|
||||
report["distances"][str(d)] = run_distance(args, ctl, env, cwd, d)
|
||||
report["load_after"] = os.getloadavg()
|
||||
out = Path(args.out) if args.out else ROOT / "benchmarks/graphics" / f"{args.label}.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||