mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
325
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb317c771 | ||
|
|
aef97f1e43 | ||
|
|
3f3bdfa663 | ||
|
|
9610099341 | ||
|
|
5482b975fb | ||
|
|
18a02fe574 | ||
|
|
6f54a627d6 | ||
|
|
97873acc8b | ||
|
|
5bdb8885e6 | ||
|
|
6f30860d03 | ||
|
|
bcba31c931 | ||
|
|
6ff6183171 | ||
|
|
995dc2cef0 | ||
|
|
8421d34846 | ||
|
|
486c22db0d | ||
|
|
a6d37d7f68 | ||
|
|
f03d1b80a0 | ||
|
|
0817971f73 | ||
|
|
f860ccf026 | ||
|
|
737a34b70c | ||
|
|
795b56ee09 | ||
|
|
f113b76de6 | ||
|
|
cb356bf994 | ||
|
|
6f1501d309 | ||
|
|
de2577f9ff | ||
|
|
c6a691be44 | ||
|
|
7b97de798d | ||
|
|
9513756c20 | ||
|
|
f9ccf49446 | ||
|
|
67ebb0257e | ||
|
|
c05ef84a56 | ||
|
|
5b9ffaaec6 | ||
|
|
f4d7883389 | ||
|
|
3bcd9c3372 | ||
|
|
bb1a6be3fe | ||
|
|
9d15888d8e | ||
|
|
97f84d9beb | ||
|
|
d1a804f466 | ||
|
|
57d83bfa07 | ||
|
|
6c0bf45fd8 | ||
|
|
cdda320c95 | ||
|
|
0d4edd409c | ||
|
|
887f6e9750 | ||
|
|
28aa93145d | ||
|
|
15cc1e4d8e | ||
|
|
e6eed3e11f | ||
|
|
c58aeb2c52 | ||
|
|
e937f37995 | ||
|
|
9d56f3371a | ||
|
|
2ac1a8e83b | ||
|
|
4b2196179a | ||
|
|
34cc13febe | ||
|
|
0190d098d6 | ||
|
|
71b1ed56fe | ||
|
|
ffb93f3791 | ||
|
|
8d724b6150 | ||
|
|
a61d6c8a5f | ||
|
|
7785e9c909 | ||
|
|
de41830167 | ||
|
|
75b1798f1d | ||
|
|
8b11051f3b | ||
|
|
c80a4f57d5 | ||
|
|
24ef0d341d | ||
|
|
e0d42f6719 | ||
|
|
4aa6854a88 | ||
|
|
081172f935 | ||
|
|
dc105082c2 | ||
|
|
4b87c54096 | ||
|
|
a498176bd7 | ||
|
|
dd3e33c0ec | ||
|
|
885e287a2e | ||
|
|
3995900d53 | ||
|
|
89e1f987ec | ||
|
|
b7b413178f | ||
|
|
7979de85a9 | ||
|
|
2d681d7794 | ||
|
|
2dba644e25 | ||
|
|
938f0323ac | ||
|
|
f6ad13d707 | ||
|
|
c9e839b403 | ||
|
|
86f6b049f1 | ||
|
|
39b41a8d85 | ||
|
|
12b31598e2 | ||
|
|
693e8b0892 | ||
|
|
e80ee4d10a | ||
|
|
e4b8313747 | ||
|
|
8dc3e7fbc5 | ||
|
|
502ffb18e0 | ||
|
|
dbe150799f | ||
|
|
d139c570fa | ||
|
|
0db0837641 | ||
|
|
4078411d22 | ||
|
|
de25a4d0a6 | ||
|
|
701e3ca790 | ||
|
|
5106f5300e | ||
|
|
ebf11e2430 | ||
|
|
7799e903f6 | ||
|
|
130acf3d3d | ||
|
|
373a59f4e9 | ||
|
|
5c8fbc0c3e | ||
|
|
4789f92dd3 | ||
|
|
d6e5c5a974 | ||
|
|
380c15e6d3 | ||
|
|
9d0e0a0be5 | ||
|
|
3c7da2b819 | ||
|
|
fe6d441ac7 | ||
|
|
2ec0f2bdc1 | ||
|
|
7fd48091ae | ||
|
|
40704247c7 | ||
|
|
865f808792 | ||
|
|
326532f859 | ||
|
|
f73179c3f1 | ||
|
|
598fcb7b86 | ||
|
|
dd96a77905 | ||
|
|
62e9cf2ba9 | ||
|
|
8c1478525e | ||
|
|
f0b0890d6a | ||
|
|
9e18079acc | ||
|
|
81ae4a7d8f | ||
|
|
0de577b509 | ||
|
|
aeadf92f84 | ||
|
|
d02dd55ae3 | ||
|
|
805d85cec0 | ||
|
|
e9351841b5 | ||
|
|
b80d1a0851 | ||
|
|
9a02a6a0ed | ||
|
|
e0d512e890 | ||
|
|
24399301ba | ||
|
|
a9e7b49f04 | ||
|
|
6ee16d87cb | ||
|
|
1f6b07c82f | ||
|
|
e1ac85c149 | ||
|
|
867ed9b2eb | ||
|
|
c05bd1a78c | ||
|
|
12f28cf6c3 | ||
|
|
27a5ccdbca | ||
|
|
aaf25ac9a9 | ||
|
|
5c785a252c | ||
|
|
7ec69de302 | ||
|
|
9ec5db50d1 | ||
|
|
dae5b4bce9 | ||
|
|
19fcd0d8a9 | ||
|
|
394725e84e | ||
|
|
374b50d48f | ||
|
|
aa4622805d | ||
|
|
0ce75665e4 | ||
|
|
10531b1d64 | ||
|
|
ce65bf2fd8 | ||
|
|
0bf5c156fe | ||
|
|
b01a37b403 | ||
|
|
499093d3da | ||
|
|
2a1dd555d0 | ||
|
|
8c02c0b853 | ||
|
|
30dfdc3e1c | ||
|
|
e855aea99b | ||
|
|
35f5e95f5d | ||
|
|
cb11c1d221 | ||
|
|
82f8d0a0be | ||
|
|
7a767d30db | ||
|
|
4a6ed638b3 | ||
|
|
2aa6aa28cd | ||
|
|
f8d5d60c0d | ||
|
|
1dc2f505bb | ||
|
|
b8b5020fbb | ||
|
|
df6e0f0547 | ||
|
|
ff6cad6206 | ||
|
|
d635bc57f9 | ||
|
|
7bca933bb5 | ||
|
|
d0afa1edc1 | ||
|
|
5bbecdc497 | ||
|
|
98e49e36c8 | ||
|
|
49e91c58c2 | ||
|
|
675fba85db | ||
|
|
97c18d7127 | ||
|
|
b1d9448018 | ||
|
|
055ce500a0 | ||
|
|
f2b508c4f2 | ||
|
|
dd4b209ae7 | ||
|
|
02122e2532 | ||
|
|
21249821f2 | ||
|
|
5ae8f36192 | ||
|
|
f0590964bb | ||
|
|
56d304c724 | ||
|
|
ea9b10f290 | ||
|
|
21b8af4bf3 | ||
|
|
d2e4a36055 | ||
|
|
e6b365b01a | ||
|
|
99eb871255 | ||
|
|
d44fb4367a | ||
|
|
452910ffce | ||
|
|
d3fe2797d9 | ||
|
|
645326f21e | ||
|
|
ad4877f432 | ||
|
|
e241409e65 | ||
|
|
42a5981e94 | ||
|
|
20c472f008 | ||
|
|
87fb637889 | ||
|
|
55f58be289 | ||
|
|
13327b6e0a | ||
|
|
ad462ede29 | ||
|
|
7b2497f52c | ||
|
|
e918209069 | ||
|
|
2daf81f487 | ||
|
|
2c78a5f1b0 | ||
|
|
39c90ba242 | ||
|
|
5cd320b24c | ||
|
|
0f3ee35c21 | ||
|
|
911aebaaf2 | ||
|
|
d6c1bf1791 | ||
|
|
6548234ac5 | ||
|
|
1cdc20c2cc | ||
|
|
24f539c633 | ||
|
|
c107eeb1a4 | ||
|
|
91e754dd88 | ||
|
|
c627a02c56 | ||
|
|
b6709fee4f | ||
|
|
9025a1ac46 | ||
|
|
3e9805e295 | ||
|
|
713798e681 | ||
|
|
ad13187cf7 | ||
|
|
4a02c7d5b9 | ||
|
|
aa21d36fff | ||
|
|
a75f58462f | ||
|
|
66ac20304e | ||
|
|
49e08de26d | ||
|
|
e7f99e7ce1 | ||
|
|
a303d7d105 | ||
|
|
d34380935d | ||
|
|
8187bdafaf | ||
|
|
791ccd18da | ||
|
|
eb47e82bac | ||
|
|
19a4e7c31c | ||
|
|
047b17f57b | ||
|
|
dd658dc0b3 | ||
|
|
670584316e | ||
|
|
ba01d1e9f7 | ||
|
|
d172784bdd | ||
|
|
07901bae47 | ||
|
|
58ffd7ed33 | ||
|
|
80a8c274d1 | ||
|
|
e3fcbf1b3c | ||
|
|
f641477ff6 | ||
|
|
8860e928fc | ||
|
|
64d0b1a4e5 | ||
|
|
d3980fec3a | ||
|
|
b88a1aad19 | ||
|
|
ddfb64045f | ||
|
|
d454a1fcbd | ||
|
|
2c4c4f83d0 | ||
|
|
a0144420a9 | ||
|
|
344faf4ee9 | ||
|
|
3a16789202 | ||
|
|
883cf5b01e | ||
|
|
25023c28c9 | ||
|
|
c0be27ae15 | ||
|
|
a52700ebae | ||
|
|
d0377992db | ||
|
|
5eeb2cdd7e | ||
|
|
af00d7af2b | ||
|
|
9469537b14 | ||
|
|
d8c627e75e | ||
|
|
0eb8fc511b | ||
|
|
34a47de0bf | ||
|
|
c7782f2656 | ||
|
|
10469d5c99 | ||
|
|
bd837ade74 | ||
|
|
6ad0a9df83 | ||
|
|
5fb3fa4c32 | ||
|
|
022472cea8 | ||
|
|
48b338836f | ||
|
|
4a7c664eb8 | ||
|
|
9fdf206c4b | ||
|
|
eb837f2edf | ||
|
|
9d3c50dd6c | ||
|
|
d9350b549e | ||
|
|
a23b218ae0 | ||
|
|
91b523cc48 | ||
|
|
e0af360ef4 | ||
|
|
4647ce7af7 | ||
|
|
02a522b868 | ||
|
|
82dee152fb | ||
|
|
63eb8a3b8c | ||
|
|
ad13f8c39c | ||
|
|
49cdc5aa61 | ||
|
|
564206afb7 | ||
|
|
a307a24a63 | ||
|
|
985647d587 | ||
|
|
f9fe596cba | ||
|
|
f795e86510 | ||
|
|
b2a09a2118 | ||
|
|
82ea5d145f | ||
|
|
ab8797a5bd | ||
|
|
5a1f9ed66f | ||
|
|
b617e637b4 | ||
|
|
1c8a721dee | ||
|
|
d1eca94f9c | ||
|
|
622c1a077a | ||
|
|
14cbc388bb | ||
|
|
e7b5953ba4 | ||
|
|
e878abf1fd | ||
|
|
cd85269d59 | ||
|
|
022306f5d8 | ||
|
|
60851159b2 | ||
|
|
85c781e56e | ||
|
|
cf7b5ce636 | ||
|
|
0294c117fe | ||
|
|
8e6281bb91 | ||
|
|
7f4784397a | ||
|
|
dc1fc94a4c | ||
|
|
1e0fe9bb79 | ||
|
|
af806fd1c3 | ||
|
|
0fb25759e1 | ||
|
|
3888ac4613 | ||
|
|
181d5f5ed6 | ||
|
|
e2d852088b | ||
|
|
71245c0aaf | ||
|
|
2d79156569 | ||
|
|
618660d072 | ||
|
|
f9030e2da9 | ||
|
|
55ed606b37 | ||
|
|
4b7fc5ca8c | ||
|
|
557f0da93d | ||
|
|
fc9cf63829 | ||
|
|
8a7bc780ed | ||
|
|
9a4e65ef6e |
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: mojang republish
|
||||
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
|
||||
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
|
||||
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
|
||||
# gh workflow run "mojang republish" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: mojang-republish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -39,7 +39,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -56,6 +57,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -83,8 +85,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -101,8 +103,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -127,8 +132,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -159,7 +167,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
|
||||
Generated
+59
@@ -1314,6 +1314,7 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1373,6 +1374,62 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-lib"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
@@ -1478,6 +1535,7 @@ dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1513,6 +1571,7 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"regex",
|
||||
]
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
|
||||
@@ -6,5 +6,5 @@ authors = []
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -39,6 +39,11 @@ pub enum Change {
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
|
||||
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
|
||||
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
|
||||
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
|
||||
Sprinting(bool),
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
[package]
|
||||
name = "horizon-client-lib"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
path = "../horizon-client/src/client_lib.rs"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
|
||||
[[example]]
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "json_load_bench"
|
||||
path = "../horizon-client/examples/json_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mod_port_probe"
|
||||
path = "../horizon-client/examples/mod_port_probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_library_bench"
|
||||
path = "../horizon-client/examples/model_library_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_states_bench"
|
||||
path = "../horizon-client/examples/model_states_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "old_world_fixture"
|
||||
path = "../horizon-client/examples/old_world_fixture.rs"
|
||||
|
||||
[[example]]
|
||||
name = "portal_scan_bench"
|
||||
path = "../horizon-client/examples/portal_scan_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "random_tick_eligibility_bench"
|
||||
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "resource_memory"
|
||||
path = "../horizon-client/examples/resource_memory.rs"
|
||||
|
||||
[[example]]
|
||||
name = "save_batch_bench"
|
||||
path = "../horizon-client/examples/save_batch_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "shared_json_startup_profile"
|
||||
path = "../horizon-client/examples/shared_json_startup_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "sprite_cache_parity"
|
||||
path = "../horizon-client/examples/sprite_cache_parity.rs"
|
||||
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "../horizon-client/examples/probe/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_api"
|
||||
path = "../horizon-client/tests/custom_component_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_unregistered"
|
||||
path = "../horizon-client/tests/custom_component_unregistered.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_wire"
|
||||
path = "../horizon-client/tests/custom_component_wire.rs"
|
||||
|
||||
[[test]]
|
||||
name = "forced_chunks"
|
||||
path = "../horizon-client/tests/forced_chunks.rs"
|
||||
|
||||
[[test]]
|
||||
name = "generation_saves"
|
||||
path = "../horizon-client/tests/generation_saves.rs"
|
||||
|
||||
[[test]]
|
||||
name = "lan_bot"
|
||||
path = "../horizon-client/tests/lan_bot.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_meshes"
|
||||
path = "../horizon-client/tests/streaming_meshes.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tall_column"
|
||||
path = "../horizon-client/tests/tall_column.rs"
|
||||
|
||||
[[test]]
|
||||
name = "wide_states"
|
||||
path = "../horizon-client/tests/wide_states.rs"
|
||||
@@ -1179,6 +1179,15 @@ impl Audio {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
pub fn set_listener_reserve(&mut self, on: bool) {
|
||||
self.engine.listener_reserve = if on {
|
||||
crate::sound_engine::HEARD_RESERVE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
|
||||
@@ -387,6 +387,52 @@ impl Biomes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A biome's colours as a mod may change them (`native_biome_colors`), each
|
||||
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
|
||||
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
|
||||
/// water.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BiomeColors {
|
||||
pub grass: u32,
|
||||
pub foliage: u32,
|
||||
pub dry_foliage: u32,
|
||||
pub water: u32,
|
||||
}
|
||||
|
||||
impl Biomes {
|
||||
/// This table with each biome's colours passed through `f`, given the
|
||||
/// biome's name (`minecraft:plains`) and its colours; None when `f`
|
||||
/// changed none. Bits past 0xFFFFFF are dropped.
|
||||
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
|
||||
let mut out = self.clone();
|
||||
let mut changed = false;
|
||||
for b in &mut out.list {
|
||||
let before = BiomeColors {
|
||||
grass: b.grass,
|
||||
foliage: b.foliage,
|
||||
dry_foliage: b.dry_foliage,
|
||||
water: b.water,
|
||||
};
|
||||
let mut colors = before;
|
||||
f(&b.name, &mut colors);
|
||||
let after = BiomeColors {
|
||||
grass: colors.grass & 0xFF_FFFF,
|
||||
foliage: colors.foliage & 0xFF_FFFF,
|
||||
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
|
||||
water: colors.water & 0xFF_FFFF,
|
||||
};
|
||||
if after != before {
|
||||
changed = true;
|
||||
b.grass = after.grass;
|
||||
b.foliage = after.foliage;
|
||||
b.dry_foliage = after.dry_foliage;
|
||||
b.water = after.water;
|
||||
}
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit colour as sRGB components in 0..1.
|
||||
pub fn rgb(c: u32) -> [f32; 3] {
|
||||
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
|
||||
@@ -534,6 +580,81 @@ mod pack_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let effects = |grass: u32, modifier: &str| {
|
||||
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
|
||||
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
|
||||
"grass_color_modifier": modifier}})
|
||||
};
|
||||
let list = vec![
|
||||
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
|
||||
BiomeInfo::from_json(
|
||||
"minecraft:dark_forest".into(),
|
||||
&effects(0x91BD59, "dark_forest"),
|
||||
&maps,
|
||||
),
|
||||
];
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_reaches_the_tints_the_mesher_reads() {
|
||||
let autumn = biomes()
|
||||
.with_colors(|name, c| {
|
||||
c.foliage = 0xD2691E;
|
||||
if name == "minecraft:plains" {
|
||||
c.grass = 0xBFA055;
|
||||
}
|
||||
})
|
||||
.expect("changed");
|
||||
let plains = autumn.id("minecraft:plains");
|
||||
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
|
||||
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
|
||||
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
|
||||
assert_eq!(autumn.get(plains).water, 0x3F76E4);
|
||||
// The dark forest's modifier still darkens its (unchanged) grass:
|
||||
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
|
||||
let dark = autumn.id("minecraft:dark_forest");
|
||||
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
|
||||
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_change_gives_none_and_alpha_bits_are_dropped() {
|
||||
assert!(biomes().with_colors(|_, _| {}).is_none());
|
||||
assert!(
|
||||
biomes()
|
||||
.with_colors(|_, c| c.water |= 0xFF00_0000)
|
||||
.is_none()
|
||||
);
|
||||
let blue = biomes()
|
||||
.with_colors(|_, c| c.water = 0xFF00_00FF)
|
||||
.expect("changed");
|
||||
assert_eq!(blue.get(0).water, 0x0000FF);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -205,7 +205,9 @@ impl Brewing {
|
||||
}
|
||||
// The gate answers the merged file, as the crafting loader's does:
|
||||
// a false-conditioned pack file leaves the lower one gone too.
|
||||
sources.retain(|(_, v)| conditions_hold(v));
|
||||
// The data's recipe filters, over the final set.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
|
||||
@@ -501,6 +501,19 @@ impl Camera {
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
|
||||
}
|
||||
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
|
||||
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
|
||||
/// First-person views do not call `adjust_distance`.
|
||||
pub fn align_with_entity_distance(
|
||||
&mut self,
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
adjust_distance: impl FnOnce(f32) -> f32,
|
||||
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
|
||||
@@ -528,10 +541,14 @@ impl Camera {
|
||||
mount_scale = s;
|
||||
mount_distance = d as f32;
|
||||
}
|
||||
let zoom = self.max_zoom(
|
||||
java_max(scale * distance, mount_scale * mount_distance),
|
||||
&mut clip,
|
||||
);
|
||||
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
|
||||
let adjusted = adjust_distance(vanilla);
|
||||
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
|
||||
adjusted
|
||||
} else {
|
||||
vanilla
|
||||
};
|
||||
let zoom = self.max_zoom(distance, &mut clip);
|
||||
self.move_by(-zoom, 0.0, 0.0);
|
||||
} else if let Some(LivingCamera {
|
||||
sleeping: Some(bed),
|
||||
@@ -969,6 +986,100 @@ mod tests {
|
||||
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_override_keeps_collision_clipping() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: Some(LivingCamera {
|
||||
scale: 2.0,
|
||||
camera_distance: 3.0,
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: Some((3.0, 4.0)),
|
||||
};
|
||||
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
|
||||
let mut camera = Camera::default();
|
||||
let mut calls = 0;
|
||||
let mut rays = 0;
|
||||
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
calls += 1;
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| {
|
||||
rays += 1;
|
||||
Some([0.0, 0.0, z])
|
||||
},
|
||||
);
|
||||
assert_eq!((calls, rays), (1, 8));
|
||||
assert!((camera.position[2] - z).abs() < 0.2);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: None,
|
||||
mount: None,
|
||||
};
|
||||
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| distance,
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] + 4.0).abs() < 1e-6);
|
||||
}
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| 0.0,
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::FirstPerson,
|
||||
|_| panic!("first person has no detached distance"),
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
}
|
||||
#[test]
|
||||
fn fov_modifier_combines_speed_flight_and_bow() {
|
||||
let mut p = FovPlayer {
|
||||
flying: false,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
|
||||
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
|
||||
pub const CLOUD_HEIGHT: f64 = 192.33;
|
||||
|
||||
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
|
||||
pub fn has_color(alpha: f32) -> bool {
|
||||
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
|
||||
pub fn visible(mode: &str, alpha: f32) -> bool {
|
||||
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
mode != "false" && has_color(alpha)
|
||||
}
|
||||
|
||||
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
|
||||
@@ -110,7 +118,7 @@ impl Input<'_> {
|
||||
z -= (z / th).floor() * th;
|
||||
let cell_x = (x / 12.0).floor() as i64;
|
||||
let cell_z = (z / 12.0).floor() as i64;
|
||||
let bottom = (192.33 - self.camera.y) as f32;
|
||||
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
|
||||
let key = MeshKey {
|
||||
cell_x,
|
||||
cell_z,
|
||||
|
||||
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn windows_processor_name(query: &str) -> Option<String> {
|
||||
query.lines().find_map(|line| {
|
||||
let (_, value) = line.split_once("REG_SZ")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
command_output(
|
||||
"reg",
|
||||
&[
|
||||
"query",
|
||||
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"/v",
|
||||
"ProcessorNameString",
|
||||
],
|
||||
)
|
||||
.and_then(|query| windows_processor_name(&query))
|
||||
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -354,7 +374,8 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
);
|
||||
crate::logging::flush();
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
Ok(path) => {
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
@@ -636,6 +657,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
|
||||
let query = r#"
|
||||
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
|
||||
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
|
||||
"#;
|
||||
assert_eq!(
|
||||
windows_processor_name(query).as_deref(),
|
||||
Some("13th Gen Intel(R) Core(TM) i7-13700F")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_processor_name("ProcessorNameString REG_SZ "),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -575,7 +575,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -583,7 +583,7 @@ pub struct DataStore {
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
@@ -591,7 +591,8 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -628,6 +629,7 @@ impl DataStore {
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"recipe_filter",
|
||||
"tags",
|
||||
"trade_set",
|
||||
"villager_trade",
|
||||
|
||||
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
if in.overlay == 37u {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -141,6 +141,8 @@ pub struct Sky {
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
/// `visual/cloud_height`.
|
||||
pub cloud_height: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -309,6 +311,10 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
.map_or(2048.0, |v| v[0]),
|
||||
cloud_height: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_height")
|
||||
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,13 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// Positional sounds leave a few static sound channels free for UI sounds
|
||||
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
|
||||
pub ui_sound_reserve: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// No particles from breaking or mining a block (other particles stay).
|
||||
pub hide_block_particles: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -113,6 +118,8 @@ pub struct Extras {
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -138,7 +145,9 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
ui_sound_reserve: false,
|
||||
own_name_tag: false,
|
||||
hide_block_particles: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -146,6 +155,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +223,9 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -221,6 +233,7 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -228,7 +241,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -254,7 +267,9 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.ui_sound_reserve),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.hide_block_particles),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -263,6 +278,7 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -383,7 +399,9 @@ pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
HideBlockParticles,
|
||||
AutoReconnect,
|
||||
UiSoundReserve,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
@@ -393,6 +411,8 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -403,6 +423,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +432,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(SortKey),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("Sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -418,7 +442,9 @@ impl Item {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::HideBlockParticles => "Hide block-break particles",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::UiSoundReserve => "Keep channels for UI sounds",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
@@ -426,16 +452,18 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -450,9 +478,15 @@ impl Item {
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::HideBlockParticles => {
|
||||
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::UiSoundReserve => {
|
||||
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
|
||||
}
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -471,6 +505,9 @@ impl Item {
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -481,10 +518,14 @@ impl Item {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -494,13 +535,16 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -521,13 +565,16 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -549,7 +596,8 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -575,11 +623,20 @@ mod tests {
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
|
||||
Item::HideBlockParticles.cycle(&mut e, false);
|
||||
assert!(e.hide_block_particles);
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
|
||||
let only = Extras::parse("hideBlockParticles:true\n");
|
||||
assert!(only.hide_block_particles && !only.own_name_tag);
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ struct Region {
|
||||
cursor: (u32, u32),
|
||||
row: u32,
|
||||
pending: Vec<Patch>,
|
||||
/// A glyph did not fit since the last `Font::begin_frame`.
|
||||
full: bool,
|
||||
}
|
||||
struct Cache {
|
||||
/// Every font by id, the default one first (the common lookup).
|
||||
@@ -546,6 +548,7 @@ impl Region {
|
||||
self.row = 0;
|
||||
}
|
||||
if y + h > region.1 {
|
||||
self.full = true;
|
||||
return None;
|
||||
}
|
||||
let (ax, ay) = (x, region.0 + y);
|
||||
@@ -603,6 +606,7 @@ impl Font {
|
||||
cursor: (0, 0),
|
||||
row: 0,
|
||||
pending: Vec::new(),
|
||||
full: false,
|
||||
},
|
||||
random: horizon_core::random::LegacyRandom::new(
|
||||
std::time::SystemTime::now()
|
||||
@@ -669,8 +673,19 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
cache.region.full = false;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Call once per frame before any text is laid out. When the dynamic
|
||||
/// region ran out of room (vanilla's glyph textures grow instead), the
|
||||
/// glyphs are forgotten so they are rasterized again from the start; it
|
||||
/// waits for the frame boundary because quads already queued hold the old
|
||||
/// rects.
|
||||
pub fn begin_frame(&self) {
|
||||
if self.cache.borrow().region.full {
|
||||
self.reset_cache();
|
||||
}
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
@@ -706,7 +721,16 @@ impl Font {
|
||||
if let Some(g) = set.glyphs.get(&c) {
|
||||
return *g;
|
||||
}
|
||||
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
|
||||
let was_full = std::mem::replace(&mut region.full, false);
|
||||
let found = self.resolve(set, region, c);
|
||||
// No room: the missing glyph for now, not remembered, so the
|
||||
// character is tried again once `begin_frame` cleared the region.
|
||||
let overflowed = std::mem::replace(&mut region.full, was_full);
|
||||
region.full |= overflowed;
|
||||
if overflowed {
|
||||
return self.missing;
|
||||
}
|
||||
let glyph = found.unwrap_or(self.missing);
|
||||
set.glyphs.insert(c, glyph);
|
||||
glyph
|
||||
}
|
||||
@@ -1418,4 +1442,56 @@ mod tests {
|
||||
font.reset_cache();
|
||||
assert_eq!(font.glyph('a').advance, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_glyph_region_recovers_at_the_next_frame() {
|
||||
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
|
||||
let sheet = png(16, 8, |x, _| x % 8 < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"full",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
let a = font.glyph('a');
|
||||
assert!(a.rect.is_some());
|
||||
// No room for 'b': missing for now, and asked again next time.
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
font.begin_frame();
|
||||
assert_ne!(font.epoch(), epoch);
|
||||
let b = font.glyph('b');
|
||||
assert!(b.rect.is_some());
|
||||
assert_eq!(b.advance, a.advance);
|
||||
// Nothing overflowed since: the next frame keeps the glyphs.
|
||||
let epoch = font.epoch();
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_without_a_provider_does_not_reset_the_region() {
|
||||
let sheet = png(8, 8, |x, _| x < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
|
||||
let root = root(
|
||||
"nofill",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
assert!(font.glyph('z').rect.is_none());
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -299,6 +299,7 @@ impl SpriteAnimator {
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
active: Option<&[u64]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if self.collect(tick, active) == 0 {
|
||||
return;
|
||||
@@ -341,7 +342,7 @@ impl SpriteAnimator {
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
for group in &mut self.groups {
|
||||
@@ -859,7 +860,7 @@ mod tests {
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
|
||||
animator.animate(&mut encoder, &mut uploads, tick, None);
|
||||
animator.animate(&mut encoder, &mut uploads, tick, None, None);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! 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.
|
||||
//! Translucent quads can have an arena of their own (`Arena::translucent`),
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
pub const GRAIN: u64 = 256;
|
||||
/// The largest page, in bytes.
|
||||
/// The largest page of the section arena, and of arenas shaders bind whole
|
||||
/// as storage, in bytes.
|
||||
pub const PAGE_BYTES: u64 = 256 << 20;
|
||||
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
|
||||
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
|
||||
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
|
||||
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
|
||||
|
||||
/// Free vertex ranges: start -> length, never adjacent.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -118,7 +127,7 @@ impl Arena {
|
||||
} else {
|
||||
wgpu::BufferUsages::VERTEX
|
||||
};
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -127,16 +136,54 @@ impl Arena {
|
||||
} else {
|
||||
wgpu::BufferUsages::INDEX
|
||||
};
|
||||
Self::with_usage(device, 4, capacity, usage)
|
||||
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
|
||||
}
|
||||
/// Translucent quads' vertices, which no shader reads as storage: pages
|
||||
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
|
||||
/// it (`Renderer::compact_arena`): a page this large has no others to
|
||||
/// empty into, and the compaction moves sections' `page` ranges, not their
|
||||
/// `translucent_at`.
|
||||
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::VERTEX;
|
||||
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// Sorted indices of the translucent arena's quads: pages up to
|
||||
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
|
||||
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::INDEX;
|
||||
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// The sorted-index arena and, when `split`, the translucent arena
|
||||
/// (vertices of `stride`), both with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena, and the indices
|
||||
/// in pages flat draws (`flat`) bind as storage.
|
||||
pub fn translucent_arenas(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
flat: bool,
|
||||
split: bool,
|
||||
) -> (Self, Option<Self>) {
|
||||
if split {
|
||||
let indices = Self::translucent_indices(device, 1 << 16);
|
||||
(indices, Some(Self::translucent(device, stride, 1 << 18)))
|
||||
} else {
|
||||
(Self::indices(device, 1 << 16, flat), None)
|
||||
}
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
page_bytes: u64,
|
||||
) -> Self {
|
||||
// 256 MiB pages, or less where the device's buffers are smaller.
|
||||
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
|
||||
// Pages of `page_bytes`, or less where the device's buffers (or the
|
||||
// storage bindings that read them whole) are smaller.
|
||||
let limits = device.limits();
|
||||
let mut bytes = limits.max_buffer_size.min(page_bytes);
|
||||
if usage.contains(wgpu::BufferUsages::STORAGE) {
|
||||
bytes = bytes.min(limits.max_storage_buffer_binding_size);
|
||||
}
|
||||
let page_limit = (bytes / stride) / GRAIN * GRAIN;
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
|
||||
Self {
|
||||
@@ -151,11 +198,16 @@ impl Arena {
|
||||
video_memory_full: false,
|
||||
}
|
||||
}
|
||||
/// A buffer of `capacity` vertices in video memory or, when that is full
|
||||
/// and the device allows it, in system memory: Vulkan drivers fail an
|
||||
/// allocation rather than page like OpenGL ones, and wgpu's default error
|
||||
/// handler panics. `None` when neither has room.
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
|
||||
/// A buffer of `capacity` vertices in video memory or, when that is full,
|
||||
/// `spill` allows it and so does the device, in system memory: Vulkan
|
||||
/// drivers fail an allocation rather than page like OpenGL ones, and
|
||||
/// wgpu's default error handler panics. `None` when neither has room.
|
||||
fn create(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
capacity: u64,
|
||||
spill: bool,
|
||||
) -> Option<(wgpu::Buffer, bool)> {
|
||||
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
|
||||
#[cfg(test)]
|
||||
let local = (!self.video_memory_full)
|
||||
@@ -167,9 +219,10 @@ impl Arena {
|
||||
local.or_else(|| {
|
||||
// Mappable buffers are placed in host-visible memory, which the
|
||||
// allocator takes from system memory once video memory is full.
|
||||
if !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
if !spill
|
||||
|| !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -234,7 +287,7 @@ impl Arena {
|
||||
&& self.closing != Some(last as u32)
|
||||
{
|
||||
let current = page.capacity;
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
let grown = self.grown(current, len);
|
||||
if (grown - current >= len || grown == self.page_limit)
|
||||
&& self.grow(device, encoder, last, grown)
|
||||
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
|
||||
@@ -250,9 +303,9 @@ impl Arena {
|
||||
.max(len)
|
||||
.min(self.page_limit);
|
||||
// A page just large enough when memory is short.
|
||||
let ((buffer, host), capacity) = match self.create(device, capacity) {
|
||||
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
|
||||
Some(buffer) => (buffer, capacity),
|
||||
None if capacity > len => (self.create(device, len)?, len),
|
||||
None if capacity > len => (self.create(device, len, true)?, len),
|
||||
None => return None,
|
||||
};
|
||||
let mut free = FreeList::new(capacity);
|
||||
@@ -392,8 +445,17 @@ impl Arena {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The capacity the newest page grows to for a range of `len`: double, but
|
||||
/// by at most `PAGE_BYTES` past that size, so a large page is never left
|
||||
/// half empty (nor held twice during the copy) by hundreds of MiB.
|
||||
fn grown(&self, current: u64, len: u64) -> u64 {
|
||||
let step = current.min(PAGE_BYTES / self.stride);
|
||||
(current + step).max(current + len).min(self.page_limit)
|
||||
}
|
||||
/// Moves a page into a larger buffer; false (the page unchanged) when
|
||||
/// there is no memory for it.
|
||||
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
|
||||
/// memory only: moving all of it to system memory would slow every frame
|
||||
/// that draws it, so a new page takes the range instead (and may spill).
|
||||
fn grow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -402,7 +464,8 @@ impl Arena {
|
||||
capacity: u64,
|
||||
) -> bool {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let Some((buffer, host)) = self.create(device, capacity) else {
|
||||
let spill = capacity * self.stride <= PAGE_BYTES;
|
||||
let Some((buffer, host)) = self.create(device, capacity, spill) else {
|
||||
return false;
|
||||
};
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
@@ -474,6 +537,26 @@ mod tests {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A device with the adapter's own limits, as the renderer opens it: page
|
||||
/// caps past the default 256 MiB buffer limit show there.
|
||||
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
|
||||
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
|
||||
/// the adapter's own let large pages past 256 MiB.
|
||||
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
|
||||
let devices = [device(), device_at_limits()];
|
||||
devices.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// `Arena::alloc`, with the copies of a grow submitted at once.
|
||||
fn alloc(
|
||||
arena: &mut Arena,
|
||||
@@ -588,6 +671,119 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer's half of that compaction (`move_off_page`) moves what each
|
||||
/// section holds in the section arena, data and all. Split, that is its
|
||||
/// solid quads alone, and nothing of a section without any, whose (0, 0)
|
||||
/// names no range of page 0; else solid and translucent quads together,
|
||||
/// `translucent_at` along.
|
||||
#[test]
|
||||
fn compaction_moves_what_sections_hold_in_the_section_arena() {
|
||||
use super::super::{SectionGpu, move_off_page};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
|
||||
page,
|
||||
start,
|
||||
translucent_at,
|
||||
solid,
|
||||
sides: crate::mesher::Sides::unsorted(solid),
|
||||
translucent,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
for split in [true, false] {
|
||||
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
|
||||
arena.page_limit = 4 * GRAIN;
|
||||
// Page 0: two sections' grains and two free ones; page 1: one taken.
|
||||
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
|
||||
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
|
||||
arena.release(0, rest, 2 * GRAIN);
|
||||
// Split, a grain of solid quads each (the first's translucent ones, and
|
||||
// all of a water section's, in the translucent arena); else a grain of
|
||||
// solid and translucent quads, and one of translucent quads alone.
|
||||
let mut sections = if split {
|
||||
vec![
|
||||
section((0, a), (0, 0), 64, 64),
|
||||
section((0, 0), (0, GRAIN), 0, 64),
|
||||
section((0, b), (0, 0), 64, 0),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
section((0, a), (0, a + 128), 32, 32),
|
||||
section((0, b), (0, b), 0, 64),
|
||||
]
|
||||
};
|
||||
// A range's first and last vertex hold its section's number, and the
|
||||
// first translucent one of a mixed range that plus 100.
|
||||
let write = |(page, at): (u32, u64), value: usize| {
|
||||
let bytes = (value as u32).to_le_bytes();
|
||||
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
|
||||
};
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
|
||||
write((page, start), i);
|
||||
write((page, start + len - 1), i);
|
||||
if !split && s.solid > 0 && s.translucent > 0 {
|
||||
write(s.translucent_at, 100 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let before: Vec<_> = sections
|
||||
.iter()
|
||||
.map(|s| (s.page, s.start, s.translucent_at))
|
||||
.collect();
|
||||
arena.close(0);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let meshes = sections.iter_mut();
|
||||
assert!(move_off_page(
|
||||
&mut arena,
|
||||
&mut encoder,
|
||||
None,
|
||||
meshes,
|
||||
0,
|
||||
split,
|
||||
u64::MAX
|
||||
));
|
||||
queue.submit([encoder.finish()]);
|
||||
let word =
|
||||
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
|
||||
assert_eq!(
|
||||
(s.page, s.start, s.translucent_at),
|
||||
before[i],
|
||||
"split {split}"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
assert_eq!(page, 1, "split {split}");
|
||||
assert_eq!(word((page, start)), i as u32);
|
||||
assert_eq!(word((page, start + len - 1)), i as u32);
|
||||
if !split {
|
||||
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
|
||||
if s.solid > 0 {
|
||||
assert_eq!(word(s.translucent_at), 100 + i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Page 0 empties without a range given twice, and page 1 took a grain
|
||||
// per section moved: one is left.
|
||||
arena.trim();
|
||||
assert!(!arena.has_page(0), "split {split}");
|
||||
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
|
||||
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -630,6 +826,96 @@ mod tests {
|
||||
assert!(arena.pages.is_empty());
|
||||
}
|
||||
|
||||
/// Every arena's pages stay within its cap and the device's limits: the
|
||||
/// whole-page storage bindings of flat draws and shader packs included.
|
||||
#[test]
|
||||
fn pages_stay_within_their_caps_and_the_device_limits() {
|
||||
let devices = devices();
|
||||
if devices.is_empty() {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
}
|
||||
let bytes = |a: &Arena| a.page_vertices() * a.stride;
|
||||
for (device, _) in &devices {
|
||||
let limits = device.limits();
|
||||
for storage in [false, true] {
|
||||
for arena in [
|
||||
Arena::new(device, 24, GRAIN, storage),
|
||||
Arena::indices(device, GRAIN, storage),
|
||||
] {
|
||||
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
|
||||
let binding = limits.max_storage_buffer_binding_size;
|
||||
assert!(!storage || bytes(&arena) <= binding);
|
||||
}
|
||||
}
|
||||
for large in [
|
||||
Arena::translucent(device, 24, GRAIN),
|
||||
Arena::translucent_indices(device, GRAIN),
|
||||
] {
|
||||
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
|
||||
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The translucent arena comes with the split layout alone, and only then
|
||||
/// are the index pages large. Otherwise, as while a shader pack's terrain
|
||||
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
|
||||
/// section arena, and flat draws (`flat`) bind the index pages as storage.
|
||||
#[test]
|
||||
fn translucent_arenas_follow_the_layout() {
|
||||
let devices = devices();
|
||||
if devices.is_empty() {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
}
|
||||
let mut exercised = false;
|
||||
for (device, _) in &devices {
|
||||
let limits = device.limits();
|
||||
let large = limits.max_buffer_size > PAGE_BYTES;
|
||||
exercised |= large;
|
||||
for flat in [false, true] {
|
||||
for split in [false, true] {
|
||||
let case = format!("large {large}, flat {flat}, split {split}");
|
||||
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
|
||||
assert_eq!(translucent.is_some(), split, "{case}");
|
||||
let bytes = indices.page_vertices() * 4;
|
||||
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
|
||||
assert_eq!(storage, flat && !split, "{case}");
|
||||
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
|
||||
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
|
||||
if let Some(t) = translucent {
|
||||
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !exercised {
|
||||
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
|
||||
#[test]
|
||||
fn large_pages_grow_by_bounded_steps() {
|
||||
let Some((device, _)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
// Exercise the policy past the test device's own buffer limit.
|
||||
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
|
||||
let step = PAGE_BYTES / 24;
|
||||
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
|
||||
assert_eq!(arena.grown(step, GRAIN), 2 * step);
|
||||
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
|
||||
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
|
||||
assert_eq!(
|
||||
arena.grown(arena.page_limit - GRAIN, GRAIN),
|
||||
arena.page_limit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -638,7 +924,9 @@ mod tests {
|
||||
};
|
||||
for mut arena in [
|
||||
Arena::new(&device, 28, GRAIN, false),
|
||||
Arena::translucent(&device, 24, GRAIN),
|
||||
Arena::indices(&device, GRAIN, false),
|
||||
Arena::translucent_indices(&device, GRAIN),
|
||||
] {
|
||||
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(arena.bytes(), arena.stride * GRAIN);
|
||||
|
||||
@@ -528,7 +528,11 @@ impl Cull {
|
||||
|
||||
/// Between the phases (`InShader`): draw the tested boxes in a pass of
|
||||
/// their own that reads phase 1's depth, bound by `bind_depth`.
|
||||
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
pub fn test_pass(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
|
||||
(&self.group, &self.depth_group)
|
||||
else {
|
||||
@@ -539,6 +543,7 @@ impl Cull {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
|
||||
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
|
||||
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
|
||||
@@ -258,13 +258,20 @@ impl EntityOutline {
|
||||
frame: &wgpu::TextureView,
|
||||
entity_group: &wgpu::BindGroup,
|
||||
indices: &wgpu::Buffer,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
|
||||
return;
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
{
|
||||
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
|
||||
let mut pass = begin(
|
||||
encoder,
|
||||
"Entity outline",
|
||||
&t.outline,
|
||||
clear,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
pass.set_pipeline(&self.silhouette);
|
||||
pass.set_bind_group(0, entity_group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -280,7 +287,7 @@ impl EntityOutline {
|
||||
("Entity outline blit", frame, wgpu::LoadOp::Load),
|
||||
];
|
||||
for (i, (label, view, load)) in steps.into_iter().enumerate() {
|
||||
let mut pass = begin(encoder, label, view, load);
|
||||
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
|
||||
pass.set_pipeline(&self.passes[i]);
|
||||
pass.set_bind_group(0, &t.groups[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
@@ -293,6 +300,7 @@ fn begin<'e>(
|
||||
label: &str,
|
||||
view: &wgpu::TextureView,
|
||||
load: wgpu::LoadOp<wgpu::Color>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -305,6 +313,7 @@ fn begin<'e>(
|
||||
})];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -62,7 +62,13 @@ pub struct Call {
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
|
||||
/// the quads with no cutout first, through a shader that never discards, then the rest.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: wgpu::RenderPipeline,
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
@@ -286,7 +292,7 @@ fn plan(
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
|
||||
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
|
||||
/// terrain pipeline as the per-section ones, without the origin buffer.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
@@ -297,6 +303,7 @@ impl Flat {
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
Option<wgpu::BlendState>,
|
||||
Option<wgpu::Face>,
|
||||
) -> wgpu::RenderPipeline,
|
||||
@@ -354,11 +361,35 @@ impl Flat {
|
||||
let storage_usage = wgpu::BufferUsages::STORAGE;
|
||||
let back = Some(wgpu::Face::Back);
|
||||
Self {
|
||||
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
|
||||
solid: pipeline(
|
||||
&layout,
|
||||
"Terrain solid (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_terrain_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_terrain_flat_cutout",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
// The mirror flips the winding: its solid terrain culls the front.
|
||||
solid_mirror: pipeline(
|
||||
&layout,
|
||||
"Terrain solid mirrored (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid_mirror",
|
||||
None,
|
||||
Some(wgpu::Face::Front),
|
||||
@@ -366,6 +397,7 @@ impl Flat {
|
||||
translucent: pipeline(
|
||||
&layout,
|
||||
"Terrain translucent (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
@@ -490,6 +522,7 @@ impl Flat {
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
@@ -668,7 +701,7 @@ impl Flat {
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: None,
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
@@ -700,13 +733,17 @@ impl Flat {
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
pub fn flatten_phase2(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: None,
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow and interpolation; the setting that
|
||||
//! runs them comes next.
|
||||
//! two real ones. So far this holds its optical flow, its interpolation and the motion
|
||||
//! vectors of what moves on its own; the setting that runs them comes next.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
#[allow(dead_code)]
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
@@ -10,8 +10,8 @@
|
||||
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
|
||||
//! frame can't see; blend the two frames along both fields; fill what is left from a
|
||||
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
|
||||
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
|
||||
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
|
||||
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
|
||||
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
|
||||
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
|
||||
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
|
||||
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
|
||||
/// the depth.
|
||||
pub reproject: Mat4,
|
||||
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
|
||||
/// (FSR's convention, in UV) where z is 1, used over the camera's.
|
||||
pub object_motion: &'a wgpu::TextureView,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
/// Vertical field of view, radians.
|
||||
@@ -442,6 +445,7 @@ impl Interpolation {
|
||||
&[
|
||||
(0, params()),
|
||||
(2, view(inputs.depth)),
|
||||
(34, view(inputs.object_motion)),
|
||||
(5, view(&r.dilated_depth)),
|
||||
(6, view(&r.dilated_mv)),
|
||||
(9, whole(&r.rec_prev_depth)),
|
||||
@@ -644,10 +648,12 @@ mod tests {
|
||||
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
|
||||
/// a camera that moves by `reproject` each frame, and returns the mean error of the
|
||||
/// last made frame's interior against `expected`, and the last two real frames'.
|
||||
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
|
||||
fn interpolate(
|
||||
seed: u32,
|
||||
shifts: &[[i32; 2]],
|
||||
reproject: Mat4,
|
||||
object: Option<[f32; 2]>,
|
||||
expected: [i32; 2],
|
||||
) -> Option<[f32; 3]> {
|
||||
let (device, queue) = of::device()?;
|
||||
@@ -682,6 +688,35 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
queue.submit([encoder.finish()]);
|
||||
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::super::motion::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
if let Some(mv) = object {
|
||||
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
|
||||
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
|
||||
queue.write_texture(
|
||||
object_motion.as_image_copy(),
|
||||
bytemuck::cast_slice(&texels),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 8),
|
||||
rows_per_image: None,
|
||||
},
|
||||
object_motion.size(),
|
||||
);
|
||||
}
|
||||
let object_motion = object_motion.create_view(&Default::default());
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
let mut interpolation = Interpolation::new(&device, size);
|
||||
let frames: Vec<_> = shifts
|
||||
@@ -731,6 +766,7 @@ mod tests {
|
||||
render_size: size,
|
||||
jitter: [0.0; 2],
|
||||
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
|
||||
object_motion: &object_motion,
|
||||
near: 0.05,
|
||||
far: 512.0,
|
||||
fov_y: 1.2,
|
||||
@@ -776,7 +812,8 @@ mod tests {
|
||||
fn moves_what_the_optical_flow_follows_halfway() {
|
||||
let mut shifts = vec![[0, 0]; 20];
|
||||
shifts.push([16, 8]);
|
||||
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -795,7 +832,26 @@ mod tests {
|
||||
// What is at a pixel now was 16 pixels to its right last frame.
|
||||
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
|
||||
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
made * 4.0 < previous.min(current),
|
||||
"{made} against {previous} and {current}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Something moving on its own with its own motion vectors (the camera still): the
|
||||
/// made frame is halfway, as with the camera's.
|
||||
#[test]
|
||||
fn moves_what_has_its_own_motion_vectors_halfway() {
|
||||
let size = [512.0, 288.0];
|
||||
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
|
||||
let mv = [-16.0 / size[0], -8.0 / size[1]];
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
|
||||
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
|
||||
// lists why):
|
||||
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
|
||||
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
|
||||
// or an entity's own where `object_motion` has one;
|
||||
// - the vector fields and reconstructed depths are storage buffers of atomics;
|
||||
// - both inpainting pyramids are a pass per level, and textures of their own;
|
||||
// - the interpolation writes one texture and the inpainting another (rgba16float can't
|
||||
@@ -85,6 +86,8 @@ struct Params {
|
||||
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(32) var interpolated: texture_2d<f32>;
|
||||
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
|
||||
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
|
||||
@group(0) @binding(34) var object_motion: texture_2d<f32>;
|
||||
|
||||
const EPSILON: f32 = 1e-03;
|
||||
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
|
||||
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
let object = textureLoad(object_motion, nearest_coord, 0);
|
||||
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
if object.z > 0.5 {
|
||||
mv = object.xy;
|
||||
}
|
||||
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
|
||||
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
|
||||
// ReconstructPrevDepth
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
|
||||
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
|
||||
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
|
||||
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
|
||||
//! own motion, limbs and spin included, drawn into a texture at the render size
|
||||
//! (`motion.wgsl`) that the interpolation reads before the camera's.
|
||||
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `motion.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
jittered: [[f32; 4]; 4],
|
||||
current: [[f32; 4]; 4],
|
||||
previous: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct MotionVertex {
|
||||
position: [f32; 3],
|
||||
previous: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
/// 1 for a translucent item, whose texture says where it shows.
|
||||
translucent: u32,
|
||||
}
|
||||
|
||||
pub struct ObjectMotion {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
atlas: wgpu::TextureView,
|
||||
sampler: wgpu::Sampler,
|
||||
params: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// Triangles of the entities matched this frame.
|
||||
staged: Vec<MotionVertex>,
|
||||
/// Last frame's vertices by entity, and its camera.
|
||||
tracked: HashMap<u64, Vec<Vertex>>,
|
||||
camera: DVec3,
|
||||
target: Option<([u32; 2], wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
impl ObjectMotion {
|
||||
/// `atlas`: the entities' texture atlas.
|
||||
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<MotionVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
atlas,
|
||||
sampler: device.create_sampler(&Default::default()),
|
||||
params: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
vertices: vertex_buffer(device, 1 << 12),
|
||||
staged: Vec::new(),
|
||||
tracked: HashMap::new(),
|
||||
camera: DVec3::ZERO,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget last frame's entities: the next frame has no motion of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.tracked.clear();
|
||||
}
|
||||
|
||||
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
|
||||
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
|
||||
/// keep them for the next frame.
|
||||
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
|
||||
self.staged.clear();
|
||||
let shift = (self.camera - camera).as_vec3();
|
||||
let mut tracked = HashMap::with_capacity(self.tracked.len());
|
||||
for (i, &(start, id)) in spans.iter().enumerate() {
|
||||
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
|
||||
let Some(now) = entities
|
||||
.get(start as usize..end)
|
||||
.filter(|v| id != 0 && !v.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(then) = self.tracked.remove(&id)
|
||||
&& then.len() == now.len()
|
||||
&& then
|
||||
.iter()
|
||||
.zip(now)
|
||||
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
|
||||
{
|
||||
let pair = |i: usize| MotionVertex {
|
||||
position: now[i].pos,
|
||||
previous: (Vec3::from(then[i].pos) + shift).into(),
|
||||
uv: now[i].uv,
|
||||
translucent: u32::from(matches!(
|
||||
now[i].layer,
|
||||
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
|
||||
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)),
|
||||
};
|
||||
for q in (0..now.len() / 4).map(|q| q * 4) {
|
||||
self.staged
|
||||
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
|
||||
}
|
||||
}
|
||||
tracked.insert(id, now.to_vec());
|
||||
}
|
||||
self.tracked = tracked;
|
||||
self.camera = camera;
|
||||
}
|
||||
|
||||
/// Draw the matched entities' motion vectors at `size` (the world's render size),
|
||||
/// where `depth` (the world's) shows them; the rest of the texture is 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
jittered: Mat4,
|
||||
current: Mat4,
|
||||
previous: Mat4,
|
||||
) -> wgpu::TextureView {
|
||||
if self.target.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.target = Some((size, texture.create_view(&Default::default())));
|
||||
}
|
||||
let target = self.target.as_ref().expect("made above").1.clone();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
|
||||
if bytes.len() as u64 > self.vertices.size() {
|
||||
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
queue.write_buffer(&self.vertices, 0, bytes);
|
||||
}
|
||||
let params = Params {
|
||||
jittered: jittered.to_cols_array_2d(),
|
||||
current: current.to_cols_array_2d(),
|
||||
previous: previous.to_cols_array_2d(),
|
||||
};
|
||||
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(¶ms));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: &self.pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&self.atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
if !self.staged.is_empty() {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
|
||||
pass.draw(0..self.staged.len() as u32, 0..1);
|
||||
}
|
||||
drop(pass);
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion vertices"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
|
||||
(0..4)
|
||||
.map(|i| Vertex {
|
||||
pos: [x + i as f32, 0.0, -5.0],
|
||||
uv: [uv, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
|
||||
/// space; a new one, one whose shape changed and untagged geometry get none.
|
||||
#[test]
|
||||
fn tracks_each_entity_by_id() {
|
||||
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
|
||||
return;
|
||||
};
|
||||
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
|
||||
let mut motion = ObjectMotion::new(&device, atlas);
|
||||
let mut entities = quad(0.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.5));
|
||||
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
|
||||
assert!(motion.staged.is_empty());
|
||||
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
|
||||
// another item; 9 is new; then untagged geometry.
|
||||
let mut entities = quad(-1.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.25));
|
||||
entities.extend(quad(20.0, 0.0));
|
||||
entities.extend(quad(30.0, 0.0));
|
||||
motion.track(
|
||||
&entities,
|
||||
&[(0, 7), (4, 8), (8, 9), (12, 0)],
|
||||
DVec3::new(2.0, 0.0, 0.0),
|
||||
);
|
||||
assert_eq!(motion.staged.len(), 6);
|
||||
let v = motion.staged[0];
|
||||
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
|
||||
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
|
||||
assert_eq!(motion.tracked.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
|
||||
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
|
||||
// the world shows it at keep its vector: where the depth is its own, or, for translucent
|
||||
// items (which don't write depth), where its texture shows and nothing is in front.
|
||||
|
||||
struct Params {
|
||||
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
|
||||
// and last frame's projection from this frame's camera.
|
||||
jittered: mat4x4<f32>,
|
||||
current: mat4x4<f32>,
|
||||
previous: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(3) var atlas_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) current: vec4<f32>,
|
||||
@location(1) previous: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// 1: a translucent item.
|
||||
@location(3) @interpolate(flat) translucent: u32,
|
||||
};
|
||||
|
||||
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
|
||||
var out: Out;
|
||||
out.uv = uv;
|
||||
out.translucent = translucent;
|
||||
out.clip = params.jittered * vec4(position, 1.0);
|
||||
out.current = params.current * vec4(position, 1.0);
|
||||
out.previous = params.previous * vec4(previous, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
|
||||
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
|
||||
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
|
||||
let tolerance = stored * 1e-3 + 1e-7;
|
||||
// Not what the world shows here: a transparent texel, or behind something.
|
||||
if in.translucent != 0u {
|
||||
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha < 0.1 || in.clip.z < stored - tolerance {
|
||||
discard;
|
||||
}
|
||||
} else if abs(in.clip.z - stored) > tolerance {
|
||||
discard;
|
||||
}
|
||||
if in.previous.w <= 0.0 {
|
||||
discard;
|
||||
}
|
||||
let now = in.current.xy / in.current.w;
|
||||
let then = in.previous.xy / in.previous.w;
|
||||
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
|
||||
}
|
||||
@@ -366,7 +366,11 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -387,6 +391,7 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -397,7 +402,11 @@ impl Hdr {
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -413,6 +422,7 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -434,6 +444,7 @@ impl Hdr {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::Texture,
|
||||
world: bool,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -443,7 +454,7 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -494,7 +505,7 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
@@ -528,6 +539,7 @@ impl Hdr {
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -541,6 +553,7 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Far meshes share the terrain arena and passes, with their actual bounds
|
||||
//! Far meshes share the terrain arenas and passes, with their actual bounds
|
||||
//! rather than the 16³ boxes used by the near-section residency walk.
|
||||
use super::{Frustum, Renderer, SectionGpu};
|
||||
use crate::{
|
||||
|
||||
@@ -102,16 +102,38 @@ fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32>
|
||||
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
|
||||
}
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
|
||||
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
|
||||
/ f32(textureDimensions(horizon_pbr_normals).x);
|
||||
let l = max(lod - log2(scale), 0.0);
|
||||
if map == 0u {
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
|
||||
}
|
||||
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
|
||||
fn horizon_block_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
|
||||
}
|
||||
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
|
||||
switch layer / horizon_terrain_SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
|
||||
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
|
||||
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
|
||||
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
|
||||
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
|
||||
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
|
||||
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
|
||||
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
|
||||
}
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
horizon_block_layer = in.layer & 0x7fffu;
|
||||
// The mip level of this point in the maps: their own texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_pbr_size();
|
||||
// The mip level of this point in the texture: its texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_size();
|
||||
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
|
||||
@@ -225,8 +225,9 @@ impl ShadowMap {
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
|
||||
}
|
||||
|
||||
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
|
||||
@@ -236,7 +237,7 @@ impl ShadowMap {
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
|
||||
}
|
||||
|
||||
fn depth_pass<'e>(
|
||||
@@ -244,9 +245,11 @@ impl ShadowMap {
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
load: wgpu::LoadOp<f32>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
@@ -266,6 +269,7 @@ impl ShadowMap {
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.as_image_copy(),
|
||||
@@ -274,6 +278,7 @@ impl ShadowMap {
|
||||
);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.color_view,
|
||||
resolve_target: None,
|
||||
@@ -298,8 +303,8 @@ impl ShadowMap {
|
||||
/// Clears the 1×1 map of a pack without `[shadow]` once.
|
||||
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if std::mem::take(&mut self.blank_uncleared) {
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear"));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,6 +620,7 @@ impl Oit {
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
@@ -645,7 +646,7 @@ impl Oit {
|
||||
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
|
||||
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
|
||||
if let Some((mask, _)) = water_mask {
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
|
||||
}
|
||||
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
|
||||
let [c0, c1] = &t.views.coefficients;
|
||||
@@ -680,6 +681,7 @@ impl Oit {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
@@ -692,6 +694,7 @@ impl Oit {
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
@@ -710,7 +713,7 @@ impl Oit {
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
@@ -720,16 +723,18 @@ impl Oit {
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
@@ -749,6 +754,7 @@ impl Oit {
|
||||
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
|
||||
/// the world's depth (after the cull) and draws into it, so the later
|
||||
/// stages, which only read it, keep each pixel's nearest cloud face.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -764,6 +770,7 @@ impl Oit {
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
@@ -774,6 +781,7 @@ impl Oit {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
@@ -798,6 +806,7 @@ impl Oit {
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
@@ -810,6 +819,7 @@ impl Oit {
|
||||
// The mask pipeline writes depth only but has a colour target.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT water mask"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
@@ -840,6 +850,7 @@ impl Oit {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
@@ -852,6 +863,7 @@ impl Oit {
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
|
||||
@@ -217,7 +217,12 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
kind(
|
||||
"additive entities",
|
||||
"lightning",
|
||||
Some(wgpu::Face::Back),
|
||||
PLAIN,
|
||||
),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
|
||||
@@ -3117,8 +3117,7 @@ impl PackPost {
|
||||
|
||||
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
|
||||
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
|
||||
self.shadow_targets.as_ref()?;
|
||||
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
|
||||
if !self.has_shadow() {
|
||||
return None;
|
||||
}
|
||||
let shadow = &self.uniforms.shadow;
|
||||
@@ -3139,6 +3138,12 @@ impl PackPost {
|
||||
self.terrain.is_some()
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
pub fn has_shadow(&self) -> bool {
|
||||
self.shadow_targets.is_some()
|
||||
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
|
||||
}
|
||||
|
||||
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
|
||||
/// What the world's background is when the pack draws the sky: colortex0's clear colour
|
||||
/// (`None`: the fog colour, its default).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
|
||||
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
|
||||
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
|
||||
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
|
||||
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
|
||||
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
|
||||
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
|
||||
//! write them), then read back when mapped. The next frame after that is timed again.
|
||||
@@ -139,6 +140,12 @@ impl PassTiming {
|
||||
Some(stamps(&self.queries, i))
|
||||
}
|
||||
|
||||
/// Timestamp writes for the compute pass `name`, in a timed frame.
|
||||
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
|
||||
let i = self.push(name)?;
|
||||
Some(compute_stamps(&self.queries, i))
|
||||
}
|
||||
|
||||
/// The index of the timed frame's next pass, `name`.
|
||||
fn push(&mut self, name: &str) -> Option<u32> {
|
||||
let i = self.names.len() as u32;
|
||||
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
|
||||
}
|
||||
}
|
||||
|
||||
/// The compute pass's twin of `stamps`.
|
||||
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
|
||||
wgpu::ComputePassTimestampWrites {
|
||||
query_set: queries,
|
||||
beginning_of_pass_write_index: Some(2 * i),
|
||||
end_of_pass_write_index: Some(2 * i + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
|
||||
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
|
||||
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
|
||||
|
||||
@@ -35,10 +35,35 @@ pub struct PackInputs {
|
||||
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
|
||||
pub held_items: [i32; 2],
|
||||
pub held_light: [i32; 2],
|
||||
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
|
||||
pub right_handed: bool,
|
||||
pub on_ground: bool,
|
||||
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
|
||||
/// logical height, sky light and ceiling.
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
pub logical_height: i32,
|
||||
pub has_skylight: bool,
|
||||
pub has_ceiling: bool,
|
||||
/// `isElytraFlying`: the player is fall flying.
|
||||
pub elytra_flying: bool,
|
||||
/// `cloudHeight`, see [`cloud_height`].
|
||||
pub cloud_height: f32,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
/// has alpha (`LevelExtractor`); a dimension without clouds has no height, which the uniform
|
||||
/// reports as NaN. NaN is not vanilla's own value: packs map it to "no clouds".
|
||||
pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
|
||||
if crate::clouds::has_color(cloud_color_alpha) {
|
||||
height
|
||||
} else {
|
||||
f32::NAN
|
||||
}
|
||||
}
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
@@ -173,6 +198,15 @@ pub const NAMES: &[&str] = &[
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
"heightLimit",
|
||||
"logicalHeightLimit",
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
|
||||
/// Values carried from frame to frame.
|
||||
@@ -380,6 +414,13 @@ impl FrameUniforms {
|
||||
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
|
||||
"playerLookVector" | "playerBodyVector" => v3(look),
|
||||
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
|
||||
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
|
||||
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
|
||||
"bedrockLevel" => Value::i(&[inputs.min_y]),
|
||||
"heightLimit" => Value::i(&[inputs.height]),
|
||||
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
|
||||
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
|
||||
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
@@ -439,6 +480,8 @@ impl FrameUniforms {
|
||||
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
|
||||
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
|
||||
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
|
||||
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
|
||||
"cloudHeight" => Value::f(&[inputs.cloud_height]),
|
||||
// renderStage is NONE outside gbuffers draws.
|
||||
_ => Value::i(&[0]),
|
||||
}
|
||||
@@ -533,6 +576,25 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_height_needs_a_cloud_color() {
|
||||
assert_eq!(cloud_height(0.8, 192.33), 192.33);
|
||||
assert_eq!(cloud_height(0.8, 120.0), 120.0);
|
||||
assert!(cloud_height(0.0, 192.33).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elytra_and_cloud_height_uniforms_are_written() {
|
||||
assert!(NAMES.ends_with(&["isElytraFlying", "cloudHeight"]));
|
||||
let mut out = vec![0u8; 16];
|
||||
Value::i(&[1]).write("bool", &mut out, 0);
|
||||
assert_eq!(i32::from_ne_bytes(out[0..4].try_into().unwrap()), 1);
|
||||
Value::f(&[192.33]).write("float", &mut out, 4);
|
||||
assert_eq!(f32::from_ne_bytes(out[4..8].try_into().unwrap()), 192.33);
|
||||
Value::f(&[f32::NAN]).write("float", &mut out, 8);
|
||||
assert!(f32::from_ne_bytes(out[8..12].try_into().unwrap()).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
|
||||
@@ -437,14 +437,15 @@ impl Post {
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(target) = &self.target else { return };
|
||||
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
@@ -460,6 +461,7 @@ impl Post {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -467,7 +469,7 @@ impl Post {
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i]);
|
||||
self.finish_with(encoder, screen, &taa.show[i], timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -476,6 +478,7 @@ impl Post {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: screen,
|
||||
@@ -488,6 +491,7 @@ impl Post {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Post")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -325,6 +325,19 @@ pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
|
||||
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
|
||||
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
|
||||
/// arena's translucent quads).
|
||||
pub fn offer_translucent_batching(on: bool) {
|
||||
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn translucent_batching_offered() -> bool {
|
||||
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -415,6 +428,11 @@ pub struct Graphics {
|
||||
pub shadow_cache: bool,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
|
||||
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
|
||||
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
|
||||
/// offered (`translucent_batching_offered`).
|
||||
pub translucent_batching: bool,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
/// ignored.
|
||||
@@ -454,6 +472,7 @@ impl Default for Graphics {
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
translucent_batching: false,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -544,6 +563,7 @@ impl Graphics {
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
@@ -560,7 +580,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -600,6 +620,7 @@ impl Graphics {
|
||||
bool_text(self.shadow_cache),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
)
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
@@ -747,10 +768,13 @@ mod tests {
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
translucent_batching: true,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is opt-in for now: only the file turns it on.
|
||||
assert!(!Graphics::default().translucent_batching);
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
|
||||
@@ -2017,9 +2017,11 @@ impl HudRenderer {
|
||||
batch,
|
||||
size,
|
||||
0..batch.vertices.len() as u32,
|
||||
None,
|
||||
);
|
||||
}
|
||||
/// Draw part of the prepared batch, preserving its blend/depth runs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_range(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -2028,6 +2030,7 @@ impl HudRenderer {
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
vertices: std::ops::Range<u32>,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
if vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
@@ -2058,6 +2061,7 @@ impl HudRenderer {
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
|
||||
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
|
||||
/// columns: a server refused or lost it) waits before it is asked again.
|
||||
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
|
||||
/// around (a level-22 tile is 67 million blocks wide: the whole world).
|
||||
pub(crate) const MAX_LEVEL: u8 = 22;
|
||||
@@ -494,7 +497,20 @@ struct Plan {
|
||||
/// selection is rebuilt, without sorting the whole set.
|
||||
by_level: [Vec<TileKey>; MAX_LEVEL as usize + 1],
|
||||
elevation: i32,
|
||||
camera: [i32; 2],
|
||||
/// The squared distance within which no coarser tile stands in for
|
||||
/// missing detail (`stands_in`): the seam with the near chunks waits
|
||||
/// for its own tiles instead of showing broad boxes beside them.
|
||||
near: i64,
|
||||
/// The squared distance within which planned tiles load before the
|
||||
/// coarse backdrop (`wanted_tiles`).
|
||||
first: i64,
|
||||
}
|
||||
/// Chunks past the render distance where only planned tiles are drawn (two
|
||||
/// past the last near chunk, `streaming::in_view_distance`), and where they
|
||||
/// load first.
|
||||
const SEAM_CHUNKS: i32 = 3;
|
||||
const FIRST_CHUNKS: i32 = 4;
|
||||
impl Plan {
|
||||
#[cfg(test)]
|
||||
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
|
||||
@@ -518,6 +534,9 @@ impl Plan {
|
||||
let size = 16 << level;
|
||||
let mut result = Self {
|
||||
elevation,
|
||||
camera,
|
||||
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
|
||||
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
|
||||
..Self::default()
|
||||
};
|
||||
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
|
||||
@@ -589,7 +608,7 @@ impl Plan {
|
||||
if complete {
|
||||
return true;
|
||||
}
|
||||
if ready.contains_key(&key) {
|
||||
if ready.contains_key(&key) && self.stands_in(key) {
|
||||
// Its mesh is clipped around every selected finer tile.
|
||||
out.push(key);
|
||||
return true;
|
||||
@@ -602,6 +621,26 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether a split tile may draw for its missing children: only away
|
||||
/// from the near chunks, where its cells are small on screen.
|
||||
fn stands_in(&self, key: TileKey) -> bool {
|
||||
key.reach(self.camera) >= self.near
|
||||
}
|
||||
/// The coarse tile sampled to cover `leaf` while it loads: two levels
|
||||
/// up (16 times fewer tiles, each as cheap as a leaf to sample), one
|
||||
/// where that would reach the seam, none beside it.
|
||||
fn backdrop(&self, leaf: TileKey) -> Option<TileKey> {
|
||||
let mut backdrop = None;
|
||||
let mut key = leaf;
|
||||
for _ in 0..2 {
|
||||
key = key.parent();
|
||||
if !self.nodes.contains(&key) || !self.stands_in(key) {
|
||||
break;
|
||||
}
|
||||
backdrop = Some(key);
|
||||
}
|
||||
backdrop
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
@@ -625,6 +664,47 @@ impl Plan {
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// The tiles to sample, `Wanted`'s order best first. First the planned
|
||||
/// tiles at the seam with the near chunks, nearest first: coarse tiles
|
||||
/// there cost a far tile's work for broad boxes that fine tiles replace
|
||||
/// moments later. Then a coarse backdrop (`Plan::backdrop`) out to the
|
||||
/// horizon, then every other planned tile nearest first, each replacing its
|
||||
/// part of the backdrop: the horizon fills in early and sharpens from the
|
||||
/// player outward, whichever way they look.
|
||||
fn wanted_tiles(
|
||||
plan: &Plan,
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
camera: [i32; 2],
|
||||
) -> impl Iterator<Item = Wanted> {
|
||||
let wanted = |class, key: TileKey| Wanted {
|
||||
class,
|
||||
distance: i64::from(key.distance(camera)),
|
||||
x: key.x,
|
||||
z: key.z,
|
||||
level: key.level,
|
||||
};
|
||||
let mut backdrop = FxHashSet::default();
|
||||
let mut tiles: Vec<_> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| !plan.split.contains(key) && !ready.contains_key(key))
|
||||
.map(|&key| {
|
||||
if let Some(coarse) = plan.backdrop(key)
|
||||
&& !ready.contains_key(&coarse)
|
||||
{
|
||||
backdrop.insert(coarse);
|
||||
}
|
||||
let first = key.reach(camera) < plan.first;
|
||||
wanted(if first { SEAM } else { REST }, key)
|
||||
})
|
||||
.collect();
|
||||
tiles.extend(backdrop.into_iter().map(|key| wanted(BACKDROP, key)));
|
||||
tiles.into_iter()
|
||||
}
|
||||
/// `Wanted::class`es, best first.
|
||||
const SEAM: u8 = 0;
|
||||
const BACKDROP: u8 = 1;
|
||||
const REST: u8 = 2;
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
|
||||
@@ -791,9 +871,11 @@ impl Ord for MeshQueued {
|
||||
.then(other.order.cmp(&self.order))
|
||||
}
|
||||
}
|
||||
/// A tile the sampler should produce next, nearest first.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
/// A tile the sampler should produce next: by `class`
|
||||
/// (`wanted_tiles`), then nearest first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Wanted {
|
||||
class: u8,
|
||||
distance: i64,
|
||||
x: i32,
|
||||
z: i32,
|
||||
@@ -843,6 +925,8 @@ pub struct Lod {
|
||||
near_loading: bool,
|
||||
/// When `request_samples` last started a job while held.
|
||||
held_sample_at: std::time::Instant,
|
||||
/// Tiles whose sampling failed, and when they may be asked again.
|
||||
retry: FxHashMap<TileKey, std::time::Instant>,
|
||||
/// Each tile's samples, shared with mesh jobs in flight.
|
||||
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
/// Fully covered subtrees, by tile.
|
||||
@@ -1179,6 +1263,7 @@ impl Lod {
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
retry: FxHashMap::default(),
|
||||
ready: FxHashMap::default(),
|
||||
complete: FxHashMap::default(),
|
||||
active: FxHashSet::default(),
|
||||
@@ -1411,14 +1496,15 @@ impl Lod {
|
||||
let present = self.ready.contains_key(&key);
|
||||
// Covered level-0 stand-ins complete their parent but are never drawn.
|
||||
let ready = present && !(key.level == 0 && self.covered.contains(&(key.x, key.z)));
|
||||
// Mirrors `Plan::select`: a ready split tile is a complete
|
||||
// stand-in even while its children are still incomplete, so no
|
||||
// second fallback is drawn above it.
|
||||
// Mirrors `Plan::select`: a ready split tile away from the seam is a
|
||||
// complete stand-in even while its children are still incomplete,
|
||||
// so no second fallback is drawn above it.
|
||||
let (complete, active) = if self.plan.split.contains(&key) {
|
||||
let children = key.children().into_iter().all(|child| {
|
||||
!self.plan.nodes.contains(&child)
|
||||
|| self.complete.get(&child).copied().unwrap_or(false)
|
||||
});
|
||||
let ready = ready && self.plan.stands_in(key);
|
||||
(children || ready, ready && !children)
|
||||
} else {
|
||||
(present, ready)
|
||||
@@ -1609,7 +1695,16 @@ impl Lod {
|
||||
}
|
||||
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
|
||||
self.pending.remove(&key);
|
||||
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
|
||||
if samples.len() != CELLS * CELLS {
|
||||
let now = std::time::Instant::now();
|
||||
if self.retry.len() > 4096 {
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
}
|
||||
self.retry.insert(key, now + RETRY);
|
||||
return;
|
||||
}
|
||||
self.retry.remove(&key);
|
||||
if self.plan.nodes.contains(&key) {
|
||||
let mut samples = samples;
|
||||
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
|
||||
for i in 0..256 {
|
||||
@@ -2161,8 +2256,16 @@ impl Lod {
|
||||
if free == 0 {
|
||||
return;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if self.held() {
|
||||
self.held_sample_at = std::time::Instant::now();
|
||||
self.held_sample_at = now;
|
||||
}
|
||||
// A tile whose sampling failed waits out `RETRY`; once it has, the
|
||||
// queue is rebuilt so the tile is asked again.
|
||||
let retrying = self.retry.len();
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// The queue is rebuilt when the view moves a chunk, not scanned and
|
||||
// sorted on every frame.
|
||||
@@ -2183,12 +2286,12 @@ impl Lod {
|
||||
z: wanted.z,
|
||||
level: wanted.level,
|
||||
};
|
||||
// Final tiles only: a coarse tile under the near area is work
|
||||
// that finer tiles replace moments later.
|
||||
// Final tiles and the backdrop only (`wanted_tiles`).
|
||||
if self.pending.contains(&key)
|
||||
|| self.ready.contains_key(&key)
|
||||
|| self.plan.split.contains(&key)
|
||||
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|
||||
|| !self.plan.nodes.contains(&key)
|
||||
|| self.retry.contains_key(&key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2203,17 +2306,19 @@ impl Lod {
|
||||
}
|
||||
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
|
||||
self.wanted.clear();
|
||||
for &key in &self.plan.nodes {
|
||||
if self.plan.split.contains(&key) || self.ready.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
self.wanted.push(std::cmp::Reverse(Wanted {
|
||||
distance: i64::from(key.distance(camera)),
|
||||
x: key.x,
|
||||
z: key.z,
|
||||
level: key.level,
|
||||
}));
|
||||
}
|
||||
// A failed tile waits out its retry delay (`retry`) before it is asked again.
|
||||
let retry = &self.retry;
|
||||
self.wanted.extend(
|
||||
wanted_tiles(&self.plan, &self.ready, camera)
|
||||
.filter(|w| {
|
||||
!retry.contains_key(&TileKey {
|
||||
x: w.x,
|
||||
z: w.z,
|
||||
level: w.level,
|
||||
})
|
||||
})
|
||||
.map(std::cmp::Reverse),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2607,14 +2712,14 @@ mod tests {
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
// split before, so never sampled. The ready root reaches the seam, so
|
||||
// it stands in for nothing: the column stays until its tile is in.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!selected.iter().any(|key| contains(key, column)));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
@@ -3258,6 +3363,153 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_cells_past_render_distance_12_start_one_block_wide() {
|
||||
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
|
||||
// keeps 1 to the near terrain's edge and 2 to 256.
|
||||
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
|
||||
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
|
||||
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
|
||||
let distance = key.distance([8, 8]);
|
||||
if distance < one {
|
||||
assert_eq!(key.size() / 16, 1, "{key:?} reduced {reduced}");
|
||||
} else if distance < two {
|
||||
assert!(key.size() / 16 <= 2, "{key:?} reduced {reduced}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Loads a render distance 12 view one sample job at a time, in
|
||||
/// `wanted_tiles`' order. Counts the jobs, and their density lattice
|
||||
/// points (each job's cost), before every far column within two chunks
|
||||
/// of the near terrain is drawn as finely as the plan wants it (one
|
||||
/// block wide in Full): until then nothing is drawn there. And before
|
||||
/// every far column is drawn, by its tile or a coarse stand-in: until
|
||||
/// then the far terrain ends short of the horizon. Checks that no coarse
|
||||
/// tile is ever drawn at the seam.
|
||||
fn jobs_until_fine_seam(camera: [i32; 2], reduced: bool) -> ([(usize, i32); 2], usize) {
|
||||
let (detail, distance) = (12, 128);
|
||||
let center = (camera[0].div_euclid(16), camera[1].div_euclid(16));
|
||||
let mut covered = FxHashSet::default();
|
||||
for z in center.1 - detail..=center.1 + detail {
|
||||
for x in center.0 - detail..=center.0 + detail {
|
||||
if crate::streaming::in_view_distance(center, detail, x, z) {
|
||||
covered.insert((x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
|
||||
let leaf = |x: i32, z: i32| {
|
||||
(0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: x.div_euclid(16 << level),
|
||||
z: z.div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| plan.nodes.contains(key) && !plan.split.contains(key))
|
||||
};
|
||||
let mut seam = Vec::new();
|
||||
for z in (camera[1] - 256..camera[1] + 256).step_by(2) {
|
||||
for x in (camera[0] - 256..camera[0] + 256).step_by(2) {
|
||||
let chunk = (x.div_euclid(16), z.div_euclid(16));
|
||||
let near = (-2..=2)
|
||||
.any(|dz| (-2..=2).any(|dx| covered.contains(&(chunk.0 + dx, chunk.1 + dz))));
|
||||
if !covered.contains(&chunk) && near {
|
||||
seam.push((x, z, leaf(x, z).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut horizon = Vec::new();
|
||||
for z in (camera[1] - 2048..camera[1] + 2048).step_by(64) {
|
||||
for x in (camera[0] - 2048..camera[0] + 2048).step_by(64) {
|
||||
if !covered.contains(&(x.div_euclid(16), z.div_euclid(16))) && leaf(x, z).is_some()
|
||||
{
|
||||
horizon.push((x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready: FxHashMap<TileKey, ()> = FxHashMap::default();
|
||||
for key in &plan.nodes {
|
||||
if key.level == 0 && covered.contains(&(key.x, key.z)) {
|
||||
ready.insert(*key, ());
|
||||
}
|
||||
}
|
||||
let leaves = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|k| !plan.split.contains(k))
|
||||
.count();
|
||||
let (mut cost, mut fine, mut reached) = (0, None, None);
|
||||
for jobs in 0.. {
|
||||
let mut selected = Vec::new();
|
||||
for &root in &plan.roots {
|
||||
plan.select(root, &ready, &mut selected);
|
||||
}
|
||||
let active: FxHashSet<_> = selected.into_iter().collect();
|
||||
let drawn = |x: i32, z: i32, levels: u8| {
|
||||
(0..=levels).any(|level| {
|
||||
active.contains(&TileKey {
|
||||
x: x.div_euclid(16 << level),
|
||||
z: z.div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
})
|
||||
};
|
||||
for &(x, z, leaf) in &seam {
|
||||
assert!(drawn(x, z, leaf.level) || !drawn(x, z, MAX_LEVEL));
|
||||
}
|
||||
if fine.is_none() && seam.iter().all(|&(x, z, leaf)| drawn(x, z, leaf.level)) {
|
||||
fine = Some((jobs, cost));
|
||||
}
|
||||
if reached.is_none() && horizon.iter().all(|&(x, z)| drawn(x, z, MAX_LEVEL)) {
|
||||
reached = Some((jobs, cost));
|
||||
}
|
||||
if let (Some(fine), Some(reached)) = (fine, reached) {
|
||||
return ([fine, reached], leaves);
|
||||
}
|
||||
let next = wanted_tiles(&plan, &ready, camera).min().unwrap();
|
||||
let next = TileKey {
|
||||
x: next.x,
|
||||
z: next.z,
|
||||
level: next.level,
|
||||
};
|
||||
// `lod_grid`'s density lattice: one point per max(stride, 4) blocks.
|
||||
let stride = next.size() / 16;
|
||||
let lattice = 16 * stride / stride.max(4) + 1;
|
||||
cost += lattice * lattice;
|
||||
ready.insert(next, ());
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
#[test]
|
||||
fn the_far_terrain_next_to_the_near_chunks_loads_first() {
|
||||
// Measured: 270 to 301 jobs, all of them fine tiles (7500 to 8500
|
||||
// lattice points). A coarse tile first costs up to 289 points; the
|
||||
// order that filled a ring ahead first and refined broad tiles every
|
||||
// other job took 54000 to 119000 for the seam in view alone.
|
||||
for camera in [[8, 8], [40, -9], [-300, 517]] {
|
||||
for reduced in [false, true] {
|
||||
let ([(jobs, cost), _], _) = jobs_until_fine_seam(camera, reduced);
|
||||
assert!(jobs < 320, "{jobs} jobs");
|
||||
assert!(cost < 9000, "{cost} lattice points");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_coarse_backdrop_reaches_the_horizon_early() {
|
||||
// Sampling only planned tiles, nearest first, reached the horizon
|
||||
// with the last of ~3000 jobs in Full (500000 lattice points), ~1050
|
||||
// in Reduced (200000): for minutes on a slow machine the far terrain
|
||||
// ended in a hard edge. The backdrop adds ~230 coarse tiles (~80 in
|
||||
// Reduced), about a tenth more work in all.
|
||||
for camera in [[8, 8], [40, -9], [-300, 517]] {
|
||||
for reduced in [false, true] {
|
||||
let ([_, (jobs, cost)], leaves) = jobs_until_fine_seam(camera, reduced);
|
||||
assert!(jobs < 1000 && jobs < leaves, "{jobs} jobs of {leaves}");
|
||||
assert!(cost < 100_000, "{cost} lattice points");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn extra_sampling_threads_only_on_large_machines() {
|
||||
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
|
||||
|
||||
@@ -160,7 +160,14 @@ impl Mesher {
|
||||
for slot in &mut faces {
|
||||
let Some(face) = matching.next() else { break };
|
||||
*slot = Some(Style {
|
||||
layer: face.layer as u16 | if leaves { OPAQUE_LAYER } else { 0 },
|
||||
// As the mesher marks them: opaque leaves and faces over opaque
|
||||
// texels skip the cutout and draw in the opaque pass.
|
||||
layer: face.layer as u16
|
||||
| if leaves || face.opaque {
|
||||
OPAQUE_LAYER
|
||||
} else {
|
||||
0
|
||||
},
|
||||
tint: if face.quad.tint_index >= 0 {
|
||||
tint
|
||||
} else {
|
||||
|
||||
@@ -59,6 +59,55 @@ pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// `--player-dir DIR` (#4952), set once at start: one player's own files, when several games
|
||||
/// play in one game folder at the same time (the launcher's `play --account`).
|
||||
static PLAYER: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes [`player_dir`] return `dir` for the rest of the run (`--player-dir`), creating it.
|
||||
/// What is a player's own goes there: `options.txt` and the settings files beside it,
|
||||
/// `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`, `hotbar.nbt` and
|
||||
/// `screenshots/`. What the game folder's players share stays in it: saves, resource and
|
||||
/// shader packs, mods and their config, caches.
|
||||
pub fn set_player_dir(dir: PathBuf) {
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _ = PLAYER.set(dir);
|
||||
}
|
||||
|
||||
/// The folder of this player's own files: `--player-dir`, else the game folder (the working
|
||||
/// directory, where the launcher runs the game).
|
||||
pub fn player_dir() -> PathBuf {
|
||||
PLAYER
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `--player-dir`, if given.
|
||||
pub fn player_dir_override() -> Option<&'static Path> {
|
||||
PLAYER.get().map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Where the files all players share live, for settings kept beside `options.txt` in
|
||||
/// `options_dir` (the enabled mods): that folder, unless `--player-dir` moved `options.txt`
|
||||
/// out of the game folder.
|
||||
pub fn shared_dir(options_dir: &Path) -> PathBuf {
|
||||
shared_dir_of(PLAYER.get().map(PathBuf::as_path), options_dir, || {
|
||||
std::env::current_dir().unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`shared_dir`] for a given `--player-dir` and game folder.
|
||||
fn shared_dir_of(
|
||||
player: Option<&Path>,
|
||||
options_dir: &Path,
|
||||
game_dir: impl FnOnce() -> PathBuf,
|
||||
) -> PathBuf {
|
||||
match player {
|
||||
Some(player) if player == options_dir => game_dir(),
|
||||
_ => options_dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
@@ -567,6 +616,20 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_player_dir_keeps_the_shared_files_in_the_game_folder() {
|
||||
let (game, player) = (Path::new("/games/shared"), Path::new("/games/players/alex"));
|
||||
// options.txt moved to the player's folder: the enabled mods stay the game folder's.
|
||||
assert_eq!(shared_dir_of(Some(player), player, || game.into()), game);
|
||||
// No --player-dir, or a splitscreen profile's own folder: beside options.txt as before.
|
||||
assert_eq!(shared_dir_of(None, game, || unreachable!()), game);
|
||||
let profile = Path::new("/games/shared/splitscreen/profiles/P2");
|
||||
assert_eq!(
|
||||
shared_dir_of(Some(player), profile, || unreachable!()),
|
||||
profile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
|
||||
let (mut floods, start) = (Floods::default(), Instant::now());
|
||||
|
||||
@@ -172,6 +172,7 @@ impl MenuBlur {
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
encoder.copy_texture_to_texture(
|
||||
@@ -224,6 +225,7 @@ impl MenuBlur {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -282,6 +282,7 @@ impl SectionMesh {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.lights.clear();
|
||||
self.sprite_layers.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
@@ -292,6 +293,7 @@ impl SectionMesh {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
|
||||
@@ -1264,6 +1264,8 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -1526,6 +1528,8 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// Other local players are joined: leave before turning splitscreen off.
|
||||
pub split_joined: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1571,6 +1575,7 @@ impl Default for OptionsUi {
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1634,6 +1639,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -2166,6 +2172,7 @@ impl OptionsUi {
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles",
|
||||
Page::SplitSetup => "horizon.pages.split_setup",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -2255,6 +2262,14 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => vec![Row::Buttons(
|
||||
if self.split_guest || self.split_joined {
|
||||
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
|
||||
} else {
|
||||
self.extra_button(s, crate::extras::Item::Splitscreen)
|
||||
},
|
||||
None,
|
||||
)],
|
||||
Page::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -2321,6 +2336,17 @@ impl OptionsUi {
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
@@ -6788,6 +6814,7 @@ mod tests {
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
Page::SplitSetup,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1024,6 +1024,7 @@ impl PostEffects {
|
||||
size: [u32; 2],
|
||||
depth: Option<&wgpu::TextureView>,
|
||||
req: &Request,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.use_root(req.root);
|
||||
let mut applied = Vec::new();
|
||||
@@ -1099,6 +1100,7 @@ impl PostEffects {
|
||||
(&work.1, size),
|
||||
0.0,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.staging = Some(staging);
|
||||
for id in &applied {
|
||||
@@ -1113,6 +1115,7 @@ impl PostEffects {
|
||||
depth,
|
||||
req.menu_blur_radius,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.chains.insert(id.clone(), Some(chain));
|
||||
}
|
||||
@@ -1126,6 +1129,7 @@ impl PostEffects {
|
||||
(target, size),
|
||||
0.0,
|
||||
format,
|
||||
timing,
|
||||
);
|
||||
self.blit = Some(blit);
|
||||
self.work = Some(work);
|
||||
@@ -1144,6 +1148,7 @@ impl PostEffects {
|
||||
main_depth: Option<&wgpu::TextureView>,
|
||||
menu_radius: f32,
|
||||
format: wgpu::TextureFormat,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
for pass in &mut chain.passes {
|
||||
if let Some(translated) = &mut pass.translated {
|
||||
@@ -1203,6 +1208,7 @@ impl PostEffects {
|
||||
{
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect depth clear"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
@@ -1221,6 +1227,7 @@ impl PostEffects {
|
||||
let c = t.clear.map(f64::from);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect target clear"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &chain.textures[i - 1].1,
|
||||
resolve_target: None,
|
||||
@@ -1274,6 +1281,7 @@ impl PostEffects {
|
||||
&inputs,
|
||||
(view(pass.output), sizes[pass.output]),
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1287,6 +1295,7 @@ impl PostEffects {
|
||||
(view(pass.output), sizes[pass.output]),
|
||||
menu_radius,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1312,7 @@ impl PostEffects {
|
||||
inputs: &[(&wgpu::TextureView, [u32; 2])],
|
||||
output: (&wgpu::TextureView, [u32; 2]),
|
||||
format: wgpu::TextureFormat,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(pipeline) = translated.pipelines.get(&format) else {
|
||||
return;
|
||||
@@ -1353,6 +1363,7 @@ impl PostEffects {
|
||||
});
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: output.0,
|
||||
resolve_target: None,
|
||||
@@ -1381,6 +1392,7 @@ impl PostEffects {
|
||||
output: (&wgpu::TextureView, [u32; 2]),
|
||||
menu_radius: f32,
|
||||
format: wgpu::TextureFormat,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
// SamplerInfo: OutSize, then each input's size.
|
||||
let info = [
|
||||
@@ -1441,6 +1453,7 @@ impl PostEffects {
|
||||
});
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: output.0,
|
||||
resolve_target: None,
|
||||
@@ -1715,6 +1728,7 @@ mod tests {
|
||||
menu_blur_radius: 5.0,
|
||||
globals: Globals::default(),
|
||||
},
|
||||
None,
|
||||
);
|
||||
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
|
||||
queue.submit([encoder.finish()]);
|
||||
@@ -1845,6 +1859,7 @@ mod tests {
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
},
|
||||
None,
|
||||
));
|
||||
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
|
||||
queue.submit([encoder.finish()]);
|
||||
|
||||
@@ -341,9 +341,10 @@ impl Settings {
|
||||
.parent()
|
||||
.map(crate::extras::Extras::load)
|
||||
.unwrap_or_default();
|
||||
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
|
||||
let mods = path
|
||||
.parent()
|
||||
.map(crate::mod_list::ModList::load)
|
||||
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
|
||||
.unwrap_or_default();
|
||||
let settings = match fs::read(path) {
|
||||
Ok(bytes) => Self {
|
||||
@@ -650,7 +651,7 @@ impl Settings {
|
||||
self.horizon.save(dir);
|
||||
self.controller.save(dir);
|
||||
self.extras.save(dir);
|
||||
self.mods.save(dir);
|
||||
self.mods.save(&crate::logging::shared_dir(dir));
|
||||
}
|
||||
}
|
||||
/// Volume of a sound source (master excluded).
|
||||
|
||||
@@ -25,6 +25,9 @@ pub const UI: usize = 10;
|
||||
/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static.
|
||||
pub const STATIC_CHANNELS: usize = 247;
|
||||
pub const STREAMING_CHANNELS: usize = 8;
|
||||
/// Static channels only sounds played to the listener (no position) may take
|
||||
/// while the `uiSoundReserve` extra is on.
|
||||
pub const HEARD_RESERVE: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PlayResult {
|
||||
@@ -792,6 +795,9 @@ pub struct SoundEngine {
|
||||
pub source_volumes: [f32; 11],
|
||||
/// A `SoundEventListener` is registered.
|
||||
pub listening: bool,
|
||||
/// Static channels positional sounds leave free for the ones played to the
|
||||
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
|
||||
pub listener_reserve: usize,
|
||||
pub notifications: Vec<Notification>,
|
||||
}
|
||||
fn handle_channel(h: &Handle) -> Option<ChannelId> {
|
||||
@@ -819,6 +825,7 @@ impl SoundEngine {
|
||||
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
|
||||
source_volumes: [1.0; 11],
|
||||
listening: false,
|
||||
listener_reserve: 0,
|
||||
notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1100,7 +1107,15 @@ impl SoundEngine {
|
||||
let looping = instance.looping && instance.delay <= 0;
|
||||
let streaming = sound.stream;
|
||||
let pool = usize::from(streaming);
|
||||
let channel = if self.used[pool] >= self.limits[pool] {
|
||||
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
|
||||
// them (a crater's worth of blocks breaking) can't stop the ones played to the listener.
|
||||
let positional = !instance.relative && instance.attenuation != Attenuation::None;
|
||||
let reserved = if positional && !streaming {
|
||||
self.listener_reserve
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let channel = if self.used[pool] + reserved >= self.limits[pool] {
|
||||
None
|
||||
} else {
|
||||
backend.create(streaming)
|
||||
@@ -1549,4 +1564,47 @@ mod tests {
|
||||
assert_eq!(s.volume, 0.0);
|
||||
assert!(!s.stopped);
|
||||
}
|
||||
#[test]
|
||||
fn world_sounds_leave_channels_for_the_listener_only_with_the_extra() {
|
||||
use test_backend::Null;
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"block.stone.break": {"sounds": ["dig/stone1"]},
|
||||
"ui.button.click": {"sounds": ["random/click"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let random = || LegacyRandom::new(0);
|
||||
let block = |i: usize| {
|
||||
let at = [i as f64, 64.0, 0.0];
|
||||
let source = crate::settings::sound_source("minecraft:block.stone.break");
|
||||
SoundInstance::at(
|
||||
"minecraft:block.stone.break",
|
||||
source,
|
||||
1.0,
|
||||
1.0,
|
||||
random(),
|
||||
at,
|
||||
)
|
||||
};
|
||||
let click = || SoundInstance::for_ui("minecraft:ui.button.click", 1.0, 0.25, random());
|
||||
for reserve in [0, HEARD_RESERVE] {
|
||||
let mut engine = SoundEngine::new(registry.clone());
|
||||
engine.listener_reserve = reserve;
|
||||
let mut backend = Null::default();
|
||||
// Positional sounds take the static channels the reserve leaves; then their plays fail.
|
||||
let started = (0..STATIC_CHANNELS)
|
||||
.filter(|&i| {
|
||||
engine.play_new(block(i), false, &mut backend).1 == PlayResult::Started
|
||||
})
|
||||
.count();
|
||||
assert_eq!(started, STATIC_CHANNELS - reserve);
|
||||
let heard = engine.play_new(click(), false, &mut backend).1;
|
||||
assert_eq!(
|
||||
heard == PlayResult::Started,
|
||||
reserve > 0,
|
||||
"reserve {reserve}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,8 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
let mut columns = Vec::with_capacity(side * side);
|
||||
for dz in 0..side {
|
||||
@@ -2371,6 +2373,7 @@ mod tests {
|
||||
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
|
||||
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
|
||||
mesh.block_entities.push(([1, 2, 3], 7));
|
||||
mesh.lights.push(([1, 2, 3], 7));
|
||||
mesh.visibility = 0;
|
||||
mesh.sides = crate::mesher::Sides::unsorted(16);
|
||||
let capacities = mesh.capacity_bytes();
|
||||
@@ -2387,6 +2390,7 @@ mod tests {
|
||||
assert!(reused.solid.is_empty());
|
||||
assert!(reused.translucent.is_empty());
|
||||
assert!(reused.block_entities.is_empty());
|
||||
assert!(reused.lights.is_empty());
|
||||
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
|
||||
assert_eq!(reused.sides, crate::mesher::Sides::default());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
|
||||
@@ -92,19 +92,29 @@ fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
}
|
||||
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
|
||||
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
|
||||
// `tiled`: a distant tile's face takes its sprite again per block, so its gradients span many
|
||||
// sprites while `uv` stays inside one (see `tile_uv` below).
|
||||
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
|
||||
let texel_coords = uv / pixel;
|
||||
let center = round(texel_coords) - 0.5;
|
||||
var offset = texel_coords - center;
|
||||
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
|
||||
offset = clamp(offset, vec2(0.0), vec2(1.0));
|
||||
return sprite_grad((center + offset) * pixel, layer, du, dv);
|
||||
let at = (center + offset) * pixel;
|
||||
if tiled {
|
||||
// An anisotropic sampler lays its taps along those gradients, past the sprite's edge,
|
||||
// where the clamped sampler reads that edge texel again: long streaks on the far tiles.
|
||||
// Asking for the level the gradients give takes the one tap an anisotropy of 1 would.
|
||||
let level = max(length(du), length(dv)) / min(pixel.x, pixel.y);
|
||||
return sprite_level(at, layer, max(0.0, log2(level)));
|
||||
}
|
||||
return sprite_grad(at, layer, du, dv);
|
||||
}
|
||||
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
|
||||
// shrink below two screen pixels, over sample_nearest.
|
||||
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
|
||||
let max_texel = max(texel_screen.x, texel_screen.y);
|
||||
let min_pixel = min(pixel.x, pixel.y);
|
||||
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
|
||||
@@ -124,7 +134,7 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
|
||||
sum_high += sprite_level(p, layer, low + 1.0);
|
||||
}
|
||||
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
|
||||
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
|
||||
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
|
||||
}
|
||||
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
|
||||
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
|
||||
@@ -137,12 +147,15 @@ fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
let du = dpdx(tiled);
|
||||
let dv = dpdy(tiled);
|
||||
let uv = tile_uv(tiled);
|
||||
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
|
||||
// block's (whose UVs stay inside its sprite).
|
||||
let tiled_face = any(tiled - uv >= vec2(0.5));
|
||||
let pixel = vec2(1.0 / globals.filtering.y);
|
||||
let texel_screen = sqrt(du * du + dv * dv);
|
||||
if u32(globals.filtering.x) == 1u {
|
||||
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
|
||||
return sample_rgss(uv, layer, pixel, du, dv, texel_screen, tiled_face);
|
||||
}
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
|
||||
}
|
||||
|
||||
struct TerrainOut {
|
||||
@@ -295,6 +308,56 @@ fn fade_code(w: f32) -> u32 {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// Solid terrain draws in two passes from the same draws: first the quads with no
|
||||
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
|
||||
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
|
||||
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
|
||||
// all the terrain it drew. A quad the pass leaves out is decided before any of the
|
||||
// vertex's work and lies outside the clip volume: it makes no pixel.
|
||||
fn opaque_quad(layer: u32, w: f32) -> bool {
|
||||
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
|
||||
}
|
||||
fn left_out() -> TerrainOut {
|
||||
var out: TerrainOut;
|
||||
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_terrain_opaque(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
@vertex fn vs_terrain_cutout(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
@vertex fn vs_terrain_flat_opaque(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
@vertex fn vs_terrain_flat_cutout(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
@@ -344,6 +407,13 @@ fn brightness(c: vec3<f32>) -> f32 {
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
return solid_color(in);
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
return solid_finish(in, texel, color);
|
||||
}
|
||||
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
|
||||
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
|
||||
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@@ -360,6 +430,10 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
// terrain.fsh: the fade-in mixes toward the fog colour.
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
return solid_finish(in, texel, color);
|
||||
}
|
||||
// The rest of a solid pixel's colour once it is kept: emission and fog.
|
||||
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
|
||||
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
|
||||
// brightest. Only pixels as bright as their sprite's threshold may: its
|
||||
|
||||
@@ -6,8 +6,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Horizon native development client"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
|
||||
[dependencies]
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
@@ -103,20 +107,23 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
[[bin]]
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
@@ -41,6 +43,7 @@
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
|
||||
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
@@ -48,6 +51,7 @@
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.split_setup": "Splitscreen...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Profile the two mandatory passes over gameplay-facts.json independently.
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//!
|
||||
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
|
||||
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
@@ -133,7 +134,7 @@ fn main() -> Result<()> {
|
||||
.collect();
|
||||
let descriptor = BlockState {
|
||||
name: name.clone(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
};
|
||||
let Some(id) = registry.id(&descriptor) else {
|
||||
states.insert(key.clone(), Value::Null);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the old two-parse loading path with catalog construction from shared facts.
|
||||
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{Result, models::StateModels, sprites::Sprites};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! generated around them blend with them (`Blender.of`).
|
||||
//!
|
||||
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
|
||||
//! their dependencies. How a probe runs is in dispatch.rs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[macro_use]
|
||||
mod dispatch;
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! and the process RSS (what the allocator holds on to). On macOS it ends
|
||||
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
|
||||
//! counts freed pages the allocator marked reusable.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{Chunk, block_strategy},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run each mode in a separate process to capture CPU usage and peak RSS.
|
||||
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
|
||||
//! actual client startup performs other initialization between these phases.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts};
|
||||
use horizon_world::level::Behaviours;
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
|
||||
//! and writes it), loads again (reads it) and compares the sprite arrays.
|
||||
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::assets::{Assets, RenderAssets};
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
|
||||
@@ -478,7 +478,7 @@ pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
|
||||
inside
|
||||
}
|
||||
/// `Projectile.lerpRotation`.
|
||||
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
pub fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
while new - old < -180.0 {
|
||||
old -= 360.0;
|
||||
}
|
||||
|
||||
@@ -445,6 +445,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
@@ -6,7 +6,19 @@ use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
/// Revisions this server implements. Older ones are not claimed: only the revision actually
|
||||
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
|
||||
const SUPPORTED: &[&str] = &["2025-06-18"];
|
||||
|
||||
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
|
||||
fn negotiate_version(params: &Value) -> &'static str {
|
||||
let wanted = params.get("protocolVersion").and_then(Value::as_str);
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| Some(*v) == wanted)
|
||||
.unwrap_or(SUPPORTED[0])
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
@@ -134,7 +146,7 @@ impl Session {
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"protocolVersion": negotiate_version(params),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn version_negotiation() {
|
||||
let v = |p: Value| negotiate_version(&p);
|
||||
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
|
||||
assert_eq!(v(json!({})), "2025-06-18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
@@ -234,9 +256,9 @@ mod tests {
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert_eq!(init["protocolVersion"], "2025-06-18");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
|
||||
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
|
||||
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
|
||||
pub fn collected_components(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
collected
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
for (component, value) in collected_components(saved) {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Horizon native client: resources, world streaming, rendering and the
|
||||
//! bindings between the validated simulation pieces and the game loop.
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate self as horizon_client;
|
||||
pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light,
|
||||
lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen,
|
||||
packs, par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers,
|
||||
screen_draw, screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list,
|
||||
signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
pub mod command_storage;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod content_identity;
|
||||
pub mod control;
|
||||
pub mod crafting;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_entries;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hunger;
|
||||
pub mod inv_sort;
|
||||
pub mod item_activation;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod win_screen;
|
||||
pub mod world_spawn;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
pub use horizon_client_core::bossbar;
|
||||
pub use horizon_client_core::component;
|
||||
pub use horizon_client_core::fastutil_order;
|
||||
pub use horizon_client_core::gamerules;
|
||||
pub use horizon_client_core::lang;
|
||||
pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::flowers;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -1356,10 +1356,11 @@ impl Recipes {
|
||||
None => sources.push((name, bytes)),
|
||||
}
|
||||
}
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
let mut v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
@@ -1373,6 +1374,14 @@ impl Recipes {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
// The data's recipe filters, over the final set.
|
||||
let full_id = match name.contains(':') {
|
||||
true => name.clone(),
|
||||
false => format!("minecraft:{name}"),
|
||||
};
|
||||
if !filters.apply(&full_id, &mut v) {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
@@ -2163,6 +2172,44 @@ mod tests {
|
||||
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
/// A pack's recipe filter removes a recipe and swaps a mod recipe's input
|
||||
/// in the loaded tables, after the packs are merged.
|
||||
#[test]
|
||||
fn recipe_filters_remove_and_swap() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-filters-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
let crusher = |input: &str| {
|
||||
format!(
|
||||
r#"{{"type":"mymod:crusher","input":"{input}","result":{{"id":"mymod:dust"}}}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/iron.json"),
|
||||
crusher("minecraft:iron_ingot"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/gold.json"),
|
||||
crusher("minecraft:gold_ingot"),
|
||||
)
|
||||
.unwrap();
|
||||
let filter = br##"{"filters":[
|
||||
{"id":"minecraft:gold","remove":true},
|
||||
{"type":"mymod:*","replace_input":{"from":"minecraft:iron_ingot","to":"#c:ingots/iron"}}
|
||||
]}"##;
|
||||
let packs = [("recipe_filter/pack:f".to_owned(), vec![filter.to_vec()])];
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
|
||||
let crushed = &recipes.custom["mymod:crusher"];
|
||||
assert_eq!(
|
||||
crushed.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["minecraft:iron"]
|
||||
);
|
||||
assert_eq!(crushed[0].json["input"], "#c:ingots/iron");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
|
||||
@@ -86,15 +86,17 @@ impl Displayer {
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
labelled_pass_lines("Shader pack GPU", passes)
|
||||
}
|
||||
|
||||
/// `label` and the passes' total, then the costliest passes by GPU time.
|
||||
pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut lines = vec![format!("{label}: {total:.2} ms in {} passes", passes.len())];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging};
|
||||
|
||||
/// Start the game. The executable supplies the allocator and retains the mod API marker.
|
||||
pub fn main() {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
.clone()
|
||||
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
|
||||
// --home before the log opens in it.
|
||||
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
|
||||
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
|
||||
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
|
||||
let folder = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
|
||||
.nth(1)
|
||||
.and_then(|dir| std::path::absolute(dir).ok())
|
||||
.unwrap_or_else(|| ".".into());
|
||||
(folder, "server", "Server thread")
|
||||
} else {
|
||||
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
|
||||
// its log and crash reports in its own; crash telemetry stays with the home's consent.
|
||||
if let Some(dir) = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--player-dir")
|
||||
.nth(1)
|
||||
{
|
||||
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
thread,
|
||||
);
|
||||
// The log's first line names the build, so a bug report that carries the log says which one it was.
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
let usage = error.is::<game::UsageError>();
|
||||
log::error!("Horizon: {error}");
|
||||
logging::flush();
|
||||
// The report's path is the last line on stderr (crash::SAVED_LINE).
|
||||
if !help && !usage {
|
||||
crash::report_error(&home, side, &*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(if usage {
|
||||
game::exit_code::USAGE
|
||||
} else {
|
||||
game::exit_code::ERROR
|
||||
});
|
||||
}
|
||||
logging::flush();
|
||||
}
|
||||
|
||||
/// The JVM raises its open-file limit to the hard limit as it starts
|
||||
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
|
||||
/// sockets and region files as the host allows, not the soft default (often
|
||||
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
|
||||
/// JVM does there.
|
||||
fn raise_open_file_limit() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
|
||||
let limit = getrlimit(Resource::Nofile);
|
||||
let hard = if cfg!(target_os = "macos") {
|
||||
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
|
||||
} else {
|
||||
limit.maximum
|
||||
};
|
||||
let raised = match (limit.current, hard) {
|
||||
(Some(soft), Some(hard)) => hard > soft,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if raised {
|
||||
let to = Rlimit {
|
||||
current: hard,
|
||||
maximum: limit.maximum,
|
||||
};
|
||||
match setrlimit(Resource::Nofile, to) {
|
||||
Ok(()) => log::info!(
|
||||
"Open file limit raised from {:?} to {hard:?}",
|
||||
limit.current
|
||||
),
|
||||
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,7 @@ mod lan_camera;
|
||||
mod lan_chat;
|
||||
mod lan_crafting;
|
||||
mod lan_entities;
|
||||
mod lan_far;
|
||||
mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
@@ -187,6 +188,7 @@ mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod narrator;
|
||||
mod native_biome_colors;
|
||||
mod native_block_entity_draw;
|
||||
mod native_components;
|
||||
mod native_criteria;
|
||||
@@ -196,6 +198,7 @@ mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_net;
|
||||
@@ -481,7 +484,11 @@ impl Options {
|
||||
self.player_name.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().map_or_else(
|
||||
|_| horizon_client::offline_name::generate(0),
|
||||
|dir| horizon_client::offline_name::load_or_create(&dir),
|
||||
|_| {
|
||||
horizon_client::offline_name::load_or_create(
|
||||
&horizon_client::logging::player_dir(),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -607,7 +614,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -693,6 +700,8 @@ impl Options {
|
||||
"--game-dir" | "--gameDir" => o.game_dir = Some(value.into()),
|
||||
// main has made it the game home already (logging::set_game_home).
|
||||
"--home" => {}
|
||||
// ...and this player's own folder (logging::set_player_dir).
|
||||
"--player-dir" => {}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
@@ -765,6 +774,9 @@ enum Screen {
|
||||
Win,
|
||||
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
|
||||
DebugOptions,
|
||||
/// A screen of a native mod's own (`native_mod_screen_open`): it draws and
|
||||
/// takes its input through the screen hooks.
|
||||
Mod,
|
||||
}
|
||||
impl Screen {
|
||||
/// The id native mods see for this screen (`NativeScreenView.id`, the
|
||||
@@ -791,6 +803,7 @@ impl Screen {
|
||||
Screen::DialogWarning => "dialog_warning",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug_options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -872,6 +885,13 @@ struct GameLevel<'w> {
|
||||
dropped: &'w [ItemEntity],
|
||||
orbs: &'w [ExperienceOrb],
|
||||
projectiles: &'w [horizon_client::projectiles::Projectile],
|
||||
/// The tridents, firework rockets and fishing hooks here. None ignores
|
||||
/// block triggers (`Entity.isIgnoringBlockTriggers`), so each counts on a
|
||||
/// plate or tripwire (`getEntitiesOfClass`). The flag says whether it also
|
||||
/// reaches `entityInside` through this list (`applyEffectsFromBlocks`):
|
||||
/// not a rocket boosting a player or a hook on an entity, whose ticks
|
||||
/// skip that step, nor a trident, which `tick_blocks` sweeps itself.
|
||||
trigger_projectiles: Vec<(Aabb, bool)>,
|
||||
biomes: &'w Biomes,
|
||||
seed: i64,
|
||||
behaviours: &'w Behaviours,
|
||||
@@ -1549,6 +1569,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
let others = || {
|
||||
self.other_trigger_boxes()
|
||||
.chain(self.trigger_projectiles.iter().map(|&(bounds, _)| bounds))
|
||||
.filter(|b| area.intersects(*b))
|
||||
.count()
|
||||
};
|
||||
@@ -1649,6 +1670,8 @@ struct FallingBlock {
|
||||
age: i32,
|
||||
/// `Entity.isNoGravity`.
|
||||
no_gravity: bool,
|
||||
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
|
||||
portal: horizon_client::nether_portal::PortalState,
|
||||
/// `FallingBlockEntity.dropItem`.
|
||||
drop_item: bool,
|
||||
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
|
||||
@@ -1739,8 +1762,8 @@ struct WorldRest {
|
||||
/// recipes, loot tables and tags from the world's pack stack, over the
|
||||
/// resource root's own. Worlds without packs use `Resources.rest` directly.
|
||||
struct WorldTables {
|
||||
/// The pack files the tables were built from, for the advancement tree
|
||||
/// reload and the loot data.
|
||||
/// The pack files the tables were built from (a native mod's among
|
||||
/// them), for the advancement tree reload and the loot data.
|
||||
files: Vec<(String, Vec<Vec<u8>>)>,
|
||||
items: Items,
|
||||
recipes: Recipes,
|
||||
@@ -2217,6 +2240,8 @@ struct Game<'a> {
|
||||
/// Minecraft.pause: a single-player screen that pauses the game is open.
|
||||
pause: bool,
|
||||
debug: bool,
|
||||
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
|
||||
gpu_passes: bool,
|
||||
screen: Screen,
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
@@ -2233,6 +2258,8 @@ struct Game<'a> {
|
||||
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
|
||||
/// while `pack_ids` is set (game/shader_packs.rs).
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
@@ -2621,6 +2648,8 @@ struct Game<'a> {
|
||||
native_lifecycle: native_lifecycle::NativeLifecycle,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
native_net: native_net::NativeNet,
|
||||
/// The mod whose own screen is open (`Screen::Mod`).
|
||||
native_mod_screen: Option<String>,
|
||||
/// 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.
|
||||
@@ -2813,6 +2842,8 @@ struct Game<'a> {
|
||||
border: horizon_client::worldborder::WorldBorder,
|
||||
/// `WorldBorderRenderer` and its render state.
|
||||
border_renderer: horizon_client::border_render::BorderRenderer,
|
||||
/// The camera's 16-block section when the wall was last invalidated.
|
||||
border_section: Option<[i32; 3]>,
|
||||
border_state: horizon_client::border_render::BorderRenderState,
|
||||
/// `Hud.tabList`: the player list's header, footer and heart states.
|
||||
tab_list: horizon_client::tab_list::TabList,
|
||||
@@ -3435,6 +3466,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
@@ -3446,6 +3480,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.addBreakingParticles: one crumb on the face being mined.
|
||||
fn crack_particle(&mut self, pos: [i32; 3], face: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
@@ -3569,6 +3606,10 @@ impl Game<'_> {
|
||||
// Block.popResource: Mth.nextDouble offsets from the level random; the
|
||||
// motion is `new ItemEntity(..)`'s own, from the item's random.
|
||||
let p = spill::pop_position(&mut self.world.random, pos);
|
||||
// Block.popResource draws its offsets before the private overload checks the gamerule.
|
||||
if !self.ui.rules.bool("block_drops") {
|
||||
return;
|
||||
}
|
||||
let v = dispenser::new_item_motion();
|
||||
self.spawn_item(stack, p, v, 10);
|
||||
}
|
||||
@@ -3896,6 +3937,18 @@ impl Game<'_> {
|
||||
.map(|g| g.position)
|
||||
}))
|
||||
.collect();
|
||||
let mut trigger_projectiles: Vec<(Aabb, bool)> = self
|
||||
.tridents
|
||||
.iter()
|
||||
.map(|t| (horizon_client::arrows::arrow_bounds(t.position), false))
|
||||
.chain(self.rockets.iter().map(|r| (r.bounds(), !r.attached)))
|
||||
.chain(
|
||||
self.hook
|
||||
.iter()
|
||||
.map(|h| (h.bounds(), h.applies_block_effects())),
|
||||
)
|
||||
.collect();
|
||||
self.lan_hook_triggers(&mut trigger_projectiles);
|
||||
let blockers = Blockers {
|
||||
mobs: &self.mobs,
|
||||
armor_stands: &self.armor_stands,
|
||||
@@ -3913,6 +3966,7 @@ impl Game<'_> {
|
||||
dropped: &self.dropped,
|
||||
orbs: &self.orbs,
|
||||
projectiles: &self.creatures.projectiles,
|
||||
trigger_projectiles,
|
||||
world: &mut self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
@@ -4284,7 +4338,16 @@ impl Game<'_> {
|
||||
}
|
||||
// The mobs and the other entities (items, orbs, projectiles...) too.
|
||||
let mut touched: Vec<BlockPos> = Vec::new();
|
||||
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
|
||||
let projectiles = level
|
||||
.trigger_projectiles
|
||||
.iter()
|
||||
.filter(|(_, applies)| *applies)
|
||||
.map(|&(bounds, _)| bounds);
|
||||
for bounds in level
|
||||
.mob_trigger_boxes()
|
||||
.chain(level.other_trigger_boxes())
|
||||
.chain(projectiles)
|
||||
{
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
|
||||
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
|
||||
@@ -4726,6 +4789,7 @@ impl Game<'_> {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 0,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -4765,7 +4829,7 @@ impl Game<'_> {
|
||||
}
|
||||
PlayerEffect::InteractLoot(_) => {}
|
||||
},
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80),
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
@@ -5071,7 +5135,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// One `FallingBlockEntity.tick` move of `f`.
|
||||
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
if !f.no_gravity {
|
||||
@@ -5117,30 +5181,11 @@ impl Game<'_> {
|
||||
f.position.z + movement.z,
|
||||
);
|
||||
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
|
||||
// FallingBlockEntity.tick: off the ground, it gives up after 600
|
||||
// ticks (100 outside the world) and drops itself as an item.
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if next.y < f.position.y {
|
||||
f.fall_distance += f.position.y - next.y;
|
||||
}
|
||||
let from = f.position;
|
||||
f.position = next;
|
||||
if on_ground {
|
||||
let pos = [
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
];
|
||||
return Ok(FallStep::Landed(pos));
|
||||
}
|
||||
// Entity.move stops it on the axes it collided on.
|
||||
if movement.x != wanted.x {
|
||||
f.velocity.x = 0.0;
|
||||
@@ -5151,6 +5196,10 @@ impl Game<'_> {
|
||||
if movement.z != wanted.z {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
if on_ground {
|
||||
self.falling_block_impact(f);
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
|
||||
// drags it. Entity.move records the move unless it was blocked short
|
||||
@@ -5182,12 +5231,44 @@ impl Game<'_> {
|
||||
if stuck {
|
||||
f.stuck = horizon_client::falling_block::web_multiplier();
|
||||
}
|
||||
// FallingBlockEntity.tick handles its portal after the move's block
|
||||
// effects, before placing/dropping on the floor under an End portal.
|
||||
let teleported = self.falling_through_portal(f);
|
||||
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
|
||||
// either way through the End deliberately keeps the source's final
|
||||
// placement/drop branch, even though the source entity was removed.
|
||||
if let Some(target) = teleported
|
||||
&& self.dim != Dim::End
|
||||
&& target != Dim::End
|
||||
{
|
||||
return Ok(FallStep::Gone);
|
||||
}
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if on_ground {
|
||||
return Ok(FallStep::Landed([
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
]));
|
||||
}
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x * 0.98,
|
||||
f.velocity.y * 0.98,
|
||||
f.velocity.z * 0.98,
|
||||
);
|
||||
Ok(FallStep::Falling)
|
||||
Ok(if teleported.is_some() {
|
||||
FallStep::Gone
|
||||
} else {
|
||||
FallStep::Falling
|
||||
})
|
||||
}
|
||||
/// `Entity.spawnAtLocation`: an item entity where the block was.
|
||||
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
|
||||
@@ -5339,6 +5420,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
}
|
||||
if creative && storage::is_shulker_box(&facts.name) {
|
||||
self.drop_creative_shulker(pos);
|
||||
}
|
||||
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
|
||||
// with its bees and honey level.
|
||||
if creative
|
||||
@@ -7707,7 +7791,12 @@ impl Game<'_> {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
let max_damage = info.map_or(0, |info| info.max_damage as i32);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
@@ -8086,6 +8175,7 @@ impl Game<'_> {
|
||||
Screen::Dead => self.draw_death_screen(&mut batch),
|
||||
Screen::Stats => self.draw_stats(&mut batch),
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::Mod => {}
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
@@ -8125,11 +8215,12 @@ impl Game<'_> {
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
|
||||
/// screen, once a frame; the Win and Options screens call this on their
|
||||
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
|
||||
/// playing with no screen. Client only, read-only: what a mod draws is
|
||||
/// its own.
|
||||
/// playing with no screen, nor while the server pack prompt hides the
|
||||
/// open screen (`hud_layers` draws `Playing` then). Client only,
|
||||
/// read-only: what a mod draws is its own.
|
||||
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing {
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
@@ -10079,6 +10170,11 @@ impl Game<'_> {
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
camera_rotation(self.camera_type, self.yaw, self.pitch)
|
||||
}
|
||||
/// The entity whose geometry starts at vertex `start` (0: none), for frame
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
pub(super) fn mark_motion(&self, start: usize, id: u64) {
|
||||
self.entity_motion.borrow_mut().push((start as u32, id));
|
||||
}
|
||||
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
|
||||
fn entity_geometry(
|
||||
&mut self,
|
||||
@@ -10087,6 +10183,7 @@ impl Game<'_> {
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
self.drain_transient_block_queue();
|
||||
self.prepare_entity_culling(camera);
|
||||
@@ -10191,6 +10288,7 @@ impl Game<'_> {
|
||||
// `ItemEntityRenderer`.
|
||||
self.mark_entity(out.len(), "minecraft:item");
|
||||
self.dropped_item_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
@@ -10203,6 +10301,7 @@ impl Game<'_> {
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
@@ -11362,6 +11461,9 @@ impl Game<'_> {
|
||||
&self.options.resources,
|
||||
&pack_files(&self.world_tables),
|
||||
);
|
||||
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
|
||||
// its chunks and players are.
|
||||
self.native_server_data_loaded(false);
|
||||
self.bosses = Default::default();
|
||||
reset_world_progress(&mut self.adv, &mut self.stats);
|
||||
self.prefetch_advancement_tree();
|
||||
@@ -13228,6 +13330,8 @@ impl Game<'_> {
|
||||
self.renderer.as_ref(),
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
}
|
||||
fn apply_settings(&mut self) {
|
||||
@@ -14309,6 +14413,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
@@ -14702,7 +14808,9 @@ impl Game<'_> {
|
||||
self.bolt_instances(camera)
|
||||
};
|
||||
let mut additive = Vec::new();
|
||||
if !title && self.dim == Dim::End {
|
||||
// The dragon is drawn in every dimension, as any entity: a dragon
|
||||
// summoned outside the End (the server's AddEntity) is shown too.
|
||||
if !title {
|
||||
let mut dragon = Vec::new();
|
||||
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
@@ -14763,13 +14871,14 @@ impl Game<'_> {
|
||||
let pack_inputs = if title {
|
||||
Default::default()
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32)
|
||||
self.shader_pack_inputs(camera, alpha as f32, &sky)
|
||||
};
|
||||
// Map textures and font glyphs changed while building this frame's
|
||||
// geometry (game/maps.rs, world text) upload with this batch; draining
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let frame = Frame {
|
||||
@@ -14853,6 +14962,7 @@ impl Game<'_> {
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
entity_motion: &entity_motion,
|
||||
entity_spans: &entity_spans,
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
@@ -14923,7 +15033,7 @@ impl Game<'_> {
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug);
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
@@ -14931,6 +15041,7 @@ impl Game<'_> {
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
self.see_through_vertices = see_through;
|
||||
@@ -15058,6 +15169,13 @@ impl Game<'_> {
|
||||
distance,
|
||||
&mut self.border_state,
|
||||
);
|
||||
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
|
||||
// enters another `SectionPos` (it only holds the stretch in range).
|
||||
let section = camera.map(|c| (c.floor() as i32) >> 4);
|
||||
if self.border_section != Some(section) {
|
||||
self.border_renderer.invalidate();
|
||||
self.border_section = Some(section);
|
||||
}
|
||||
// Camera.depthFar, as the renderer's far plane.
|
||||
let depth_far =
|
||||
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
|
||||
@@ -15137,6 +15255,7 @@ impl Game<'_> {
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -15353,11 +15472,25 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them.
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them, the
|
||||
/// distance no farther than a server sampling far terrain for us allows.
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
(g.lod_distance, reduced)
|
||||
let distance = match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
};
|
||||
(distance, reduced)
|
||||
}
|
||||
/// The far terrain another machine's server samples for us
|
||||
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
|
||||
/// its own server thread, which hands its generator over directly.
|
||||
fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
|
||||
if self.integrated.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.remote.as_ref()?.far_terrain
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
@@ -15378,6 +15511,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn clear_lod(&mut self) {
|
||||
// The samplers waiting on the server stop first: dropping the far
|
||||
// terrain joins them.
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.cancel_far_terrain();
|
||||
}
|
||||
self.lod = None;
|
||||
self.lod_source_attempted = false;
|
||||
self.lod_observe.clear();
|
||||
@@ -15392,7 +15530,9 @@ impl Game<'_> {
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
&& (self.remote.is_none() || self.integrated.is_some());
|
||||
&& (self.remote.is_none()
|
||||
|| self.integrated.is_some()
|
||||
|| self.server_far_terrain().is_some());
|
||||
if !enabled {
|
||||
if self.lod.is_some() || self.lod_source_attempted {
|
||||
self.clear_lod();
|
||||
@@ -15404,9 +15544,16 @@ impl Game<'_> {
|
||||
}
|
||||
self.lod_source_attempted = true;
|
||||
let dim = self.dim;
|
||||
let source = match &self.integrated {
|
||||
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
None => Some(self.streaming.lod_source()),
|
||||
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
|
||||
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
(None, Some(offer), Some(remote)) => {
|
||||
let states = self.res.facts().states.len() as u32;
|
||||
let biomes = self.res.biomes().index.clone();
|
||||
let source: Arc<dyn horizon_client::streaming::LodSource> =
|
||||
remote.session.far_terrain(offer, biomes, states);
|
||||
Some(source)
|
||||
}
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
self.lod = Some(horizon_client::lod::Lod::new(
|
||||
@@ -15420,6 +15567,16 @@ impl Game<'_> {
|
||||
if let Err(e) = self.poll_remote() {
|
||||
log::warn!("Horizon: {e}");
|
||||
}
|
||||
// The renderer laid translucent quads out again (a shader pack's flat terrain came
|
||||
// or went) and dropped every section and far tile: meshed again, as for a new one.
|
||||
if self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.is_some_and(Renderer::take_dropped_sections)
|
||||
{
|
||||
self.clear_lod();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.prepare_lod();
|
||||
if self.lod.is_some() {
|
||||
for &((x, _, z), _, _) in &self.world.light_edits {
|
||||
@@ -15738,6 +15895,30 @@ impl Game<'_> {
|
||||
self.physical_buttons.remove(button);
|
||||
}
|
||||
}
|
||||
let mouse = if !self.pad_synthetic_input()
|
||||
&& let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
{
|
||||
Some((
|
||||
glfw_button(*button),
|
||||
*state == ElementState::Pressed,
|
||||
glfw_modifiers(self.modifiers),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_button_pre(button, pressed, modifiers, &mut canceled);
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.handle_window_event_inner(event_loop, event);
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
self.native_mouse_button_post(button, pressed, modifiers);
|
||||
}
|
||||
}
|
||||
fn handle_window_event_inner(&mut self, event_loop: &dyn EventLoopCtl, mut event: WindowEvent) {
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
self.physical_buttons.clear();
|
||||
@@ -17263,6 +17444,7 @@ impl<'a> Game<'a> {
|
||||
was_playing: false,
|
||||
pause: false,
|
||||
debug: false,
|
||||
gpu_passes: false,
|
||||
screen: Screen::Playing,
|
||||
window: None,
|
||||
renderer: None,
|
||||
@@ -17272,6 +17454,7 @@ impl<'a> Game<'a> {
|
||||
see_through_vertices: Vec::new(),
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
@@ -17516,6 +17699,7 @@ impl<'a> Game<'a> {
|
||||
native_shaders: Default::default(),
|
||||
native_lifecycle: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_mod_screen: None,
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
@@ -17603,6 +17787,7 @@ impl<'a> Game<'a> {
|
||||
brushing: Default::default(),
|
||||
border: Default::default(),
|
||||
border_renderer: Default::default(),
|
||||
border_section: None,
|
||||
border_state: Default::default(),
|
||||
tab_list: Default::default(),
|
||||
subtitles: Default::default(),
|
||||
@@ -17925,11 +18110,16 @@ impl Game<'_> {
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
fn build_world_tables(&self) -> Option<WorldTables> {
|
||||
let files = functions::content_overlay(
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
self.native_data_pack_files(&mut contributed);
|
||||
let files = functions::overlay_files(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
contributed,
|
||||
);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
@@ -17991,6 +18181,7 @@ impl Game<'_> {
|
||||
&overlay,
|
||||
));
|
||||
self.adv.reset_tree();
|
||||
self.native_server_data_loaded(true);
|
||||
}
|
||||
|
||||
fn join_world_rest(&mut self) -> Result<()> {
|
||||
@@ -18547,7 +18738,10 @@ fn load_world_rest(
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Arc::new(v)))
|
||||
.collect();
|
||||
let mob_data = horizon_client::mobs::MobData::load(root, facts);
|
||||
let mut mob_data = horizon_client::mobs::MobData::load(root, facts);
|
||||
mob_data.cem = std::sync::RwLock::new(Arc::new(
|
||||
horizon_client::mobs::cem::CemModels::load(root, &mob_types),
|
||||
));
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
Ok((mob_types, mob_data))
|
||||
};
|
||||
@@ -18657,6 +18851,8 @@ fn build_renderer(
|
||||
previous: Option<&Renderer>,
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
shader_pack: bool,
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
@@ -18691,6 +18887,8 @@ fn build_renderer(
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
shader_pack,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -18752,6 +18950,9 @@ fn loading_renderer(
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
@@ -18820,6 +19021,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
@@ -18873,7 +19075,7 @@ fn start_game<'a>(
|
||||
let handoff = prepared.is_some();
|
||||
let mut settings = match settings {
|
||||
Some(settings) => settings,
|
||||
None => Settings::load(&std::env::current_dir()?.join("options.txt")),
|
||||
None => Settings::load(&Game::options_path()),
|
||||
};
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
@@ -19293,6 +19495,8 @@ impl Loading {
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
@@ -19536,7 +19740,7 @@ pub fn run() -> Result<()> {
|
||||
// the error.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
log::error!("Horizon: {error}");
|
||||
let settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
let settings = Settings::load(&Game::options_path());
|
||||
return no_resources::show(&vanilla_root, &settings);
|
||||
}
|
||||
return Err(error.into());
|
||||
@@ -19547,13 +19751,14 @@ pub fn run() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
horizon_client::heartbeat::start();
|
||||
if let Err(e) = horizon_client::multiplayer::add_official_server(&std::env::current_dir()?)
|
||||
if let Err(e) =
|
||||
horizon_client::multiplayer::add_official_server(&horizon_client::logging::player_dir())
|
||||
{
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
let early_settings = Settings::load(&Game::options_path());
|
||||
// Minecraft's constructor opens the narrator; a server has none.
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
@@ -20108,6 +20313,7 @@ mod tests {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 5,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
|
||||
hud::HudBatch,
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
@@ -326,13 +326,19 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
// The world's packs' advancements go over the game's own, and a
|
||||
// native mod's contributed files are already among them: the tables
|
||||
// were built for this open (`build_world_tables`), so the hook is
|
||||
// asked once, not again here.
|
||||
let files = match &self.world_tables {
|
||||
Some(tables) => tables.files.clone(),
|
||||
None => super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
),
|
||||
};
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
@@ -1821,11 +1827,7 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
|
||||
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
|
||||
};
|
||||
let (key, color) = toast_label(frame);
|
||||
// AdvancementToast.extractRenderState: a title of one line sits under
|
||||
// the frame's label; a longer one is wrapped at 125, the label fading
|
||||
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
|
||||
@@ -1862,6 +1864,15 @@ impl Game<'_> {
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
/// The toast's frame label and its colour (`AdvancementToast`: -30465, pink,
|
||||
/// for a challenge, else -256, yellow), packed as `hud::rgba` for the batch.
|
||||
fn toast_label(frame: Frame) -> (&'static str, u32) {
|
||||
match frame {
|
||||
Frame::Task => ("advancements.toast.task", rgba(255, 255, 0, 255)),
|
||||
Frame::Goal => ("advancements.toast.goal", rgba(255, 255, 0, 255)),
|
||||
Frame::Challenge => ("advancements.toast.challenge", rgba(255, 136, 255, 255)),
|
||||
}
|
||||
}
|
||||
/// `Advancement.name`: the bracketed title in the frame's colour, with the
|
||||
/// description on hover; the id when there is no display.
|
||||
pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
@@ -2059,6 +2070,18 @@ pub(super) fn text_of(v: &Value) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toast_labels_are_yellow_or_pink() {
|
||||
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
|
||||
for frame in [Frame::Task, Frame::Goal] {
|
||||
assert_eq!(toast_label(frame).1.to_le_bytes(), [255, 255, 0, 255]);
|
||||
}
|
||||
assert_eq!(
|
||||
toast_label(Frame::Challenge).1.to_le_bytes(),
|
||||
[255, 136, 255, 255]
|
||||
);
|
||||
}
|
||||
|
||||
fn recipes(n: usize) -> Toast {
|
||||
Toast::recipe(
|
||||
(0..n)
|
||||
|
||||
@@ -424,6 +424,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -498,7 +499,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
@@ -528,8 +529,16 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Splitscreen's profile page, a joining player's (`split_profiles`).
|
||||
fn split_profiles_open(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
}
|
||||
fn title_value(&self) -> Value {
|
||||
use horizon_client::title::Mode;
|
||||
if self.split_profiles_open() {
|
||||
return self.ui.options.split_profiles.state();
|
||||
}
|
||||
let Some(title) = &self.title else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -727,7 +736,7 @@ impl Game<'_> {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1591,6 +1600,22 @@ impl Game<'_> {
|
||||
self.synth_click(event_loop, x, y, button)?;
|
||||
ok(json!({ "ok": true, "screen": self.screen_label() }))
|
||||
}
|
||||
// Splitscreen's profile page, over a joining player's title screen.
|
||||
"type" if self.split_profiles_open() => {
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
let page = &mut self.ui.options.split_profiles;
|
||||
if text == "\\b" {
|
||||
page.key(winit::keyboard::KeyCode::Backspace, None);
|
||||
} else {
|
||||
page.type_text(text);
|
||||
}
|
||||
ok(json!({ "ok": true, "menu": self.title_value() }))
|
||||
}
|
||||
"type" if self.title.is_some() => {
|
||||
// type [--field name|seed] [--replace] TEXT on the title screens.
|
||||
let mut text = line
|
||||
|
||||
@@ -303,6 +303,7 @@ impl Game<'_> {
|
||||
.get("minecraft:conduit_effect_block")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut shulker_pushes = Vec::new();
|
||||
for &(pos, ticker) in &positions {
|
||||
match ticker {
|
||||
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
|
||||
@@ -315,17 +316,19 @@ impl Game<'_> {
|
||||
.tick(pos, &players, &mut anims.book_random);
|
||||
}
|
||||
Ticker::Shulker { facing } => {
|
||||
// A window that simulates the world ticks its boxes on the
|
||||
// server pass (`tick_block_entities`, which also applies
|
||||
// `moveCollidedEntities`); a server's client only animates
|
||||
// what it is sent. Either way the collision queries read
|
||||
// the lids from `World::shulkers` (`sync_shulkers`).
|
||||
// `ShulkerBoxBlockEntity.tick` runs on both sides: the
|
||||
// client moves its own player, including with a menu open.
|
||||
// The server ticks its boxes in `tick_block_entities`.
|
||||
if self.remote.is_some() {
|
||||
self.be_anims
|
||||
let tick = self
|
||||
.be_anims
|
||||
.shulkers
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, facing);
|
||||
if let Some(push) = tick.push {
|
||||
shulker_pushes.push((pos, facing, push));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ticker::Skull { powered } => {
|
||||
@@ -432,6 +435,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
if self.remote.is_some() {
|
||||
self.sync_shulkers();
|
||||
self.push_shulker_entities(shulker_pushes);
|
||||
}
|
||||
}
|
||||
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
|
||||
/// tick blocks, with or without a renderer. The conduits are looked for
|
||||
|
||||
@@ -152,6 +152,31 @@ impl Game<'_> {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
|
||||
/// one item at the block center, with collectComponents and the default pickup delay.
|
||||
/// This direct spawn intentionally does not use the block_drops gamerule.
|
||||
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
|
||||
if items.iter().flatten().any(|s| s.count > 0));
|
||||
if !filled {
|
||||
return;
|
||||
}
|
||||
let Some(saved) = self.saved_block_entity(key) else {
|
||||
return;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
|
||||
let patch = block_entity_item::collected_components(&saved);
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
|
||||
self.spawn_item(
|
||||
stack,
|
||||
pos.map(|v| f64::from(v) + 0.5),
|
||||
super::dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
|
||||
/// `block_state` component (a hive's honey level, a statue's pose) set on
|
||||
/// the block just placed at `pos`. Returns the state it has now.
|
||||
|
||||
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
|
||||
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
Ok(BlockState { name, properties })
|
||||
Ok(BlockState {
|
||||
name,
|
||||
properties: properties.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -1119,7 +1119,7 @@ impl Game<'_> {
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
fluid = Some((fluid_hit_point(p, y, h), t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
@@ -1454,10 +1454,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
|
||||
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
|
||||
/// (the same fluid above it) is a full cube, so the hit is the ray's own
|
||||
/// point and a boat placed under water stays at the eye; a partial cell is
|
||||
/// clipped to its surface.
|
||||
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
|
||||
if h >= 1.0 {
|
||||
p
|
||||
} else {
|
||||
[p[0], f64::from(y) + f64::from(h), p[2]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
|
||||
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
|
||||
/// cell is clipped to its surface.
|
||||
#[test]
|
||||
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
|
||||
let eye = [0.5, 3.62, 0.5];
|
||||
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
|
||||
assert_eq!(
|
||||
fluid_hit_point(eye, 3, 8.0 / 9.0),
|
||||
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
|
||||
);
|
||||
}
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -1421,9 +1421,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
|
||||
pub(super) fn take_screenshot(&mut self) {
|
||||
let dir = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("screenshots");
|
||||
let dir = horizon_client::logging::player_dir().join("screenshots");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ pub(super) struct CombatTracker {
|
||||
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
|
||||
/// cause).
|
||||
pub(super) projectile_direct: Option<Text>,
|
||||
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
|
||||
/// its entity id and display name, the blast's causing entity.
|
||||
pub(super) blast_owner: Option<(u32, Text)>,
|
||||
/// The guest behind `blast_owner`, when that player is a LAN guest; the
|
||||
/// host's own blast leaves it None.
|
||||
pub(super) blast_guest: Option<u32>,
|
||||
pub(super) projectile_attacker: Option<(u32, Text)>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
//! waits until the terrain and entities around the player are loaded;
|
||||
//! `portal` sends the player through the nether portal it stands in.
|
||||
//! `break` destroys a block as the server player; `portal` sends the player
|
||||
//! through the nether portal it stands in; `falling` counts physical falling
|
||||
//! blocks by dimension, block and gravity.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, text::Text};
|
||||
use serde_json::{Value, json};
|
||||
@@ -244,7 +246,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"break" => {
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| g.break_block(pos))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
"falling" => {
|
||||
// Physical falling blocks are not command-selector targets
|
||||
// yet. Inspect the actual level rather than that unrelated gap.
|
||||
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
|
||||
let block = op["block"].as_str().unwrap_or("minecraft:sand");
|
||||
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
|
||||
let back = self.dim;
|
||||
self.enter_level(None, super::Dim::from_id(dimension))?;
|
||||
let facts = self.res.facts();
|
||||
let matches = |f: &super::FallingBlock| {
|
||||
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
|
||||
};
|
||||
let count = self.falling.iter().filter(|f| matches(f)).count();
|
||||
if op["discard"].as_bool() == Some(true) {
|
||||
self.falling.retain(|f| !matches(f));
|
||||
}
|
||||
self.enter_level(None, back)?;
|
||||
let record = json!({
|
||||
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
|
||||
"messages": [],
|
||||
"results": [[true, count]],
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2459,6 +2459,11 @@ impl Game<'_> {
|
||||
ctx.has("keep"),
|
||||
);
|
||||
let mut count = 0;
|
||||
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
|
||||
// of states, and matching a tag is a name lookup.
|
||||
let mut matched: std::collections::HashMap<BlockStateId, bool> =
|
||||
std::collections::HashMap::new();
|
||||
let plain = !(destroy || hollow || outline);
|
||||
for y in min[1]..=max[1] {
|
||||
for z in min[2]..=max[2] {
|
||||
for x in min[0]..=max[0] {
|
||||
@@ -2466,11 +2471,23 @@ impl Game<'_> {
|
||||
if keep && current != air {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter
|
||||
&& !self.block_matches(f, current)
|
||||
{
|
||||
// Already the block it would become: nothing to place (place_state would do nothing either).
|
||||
if plain && current == state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
let ok = match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let edge = x == min[0]
|
||||
|| x == max[0]
|
||||
|| y == min[1]
|
||||
|
||||
@@ -109,10 +109,28 @@ impl Game<'_> {
|
||||
let _ = flight.reply.send(summary.to_string());
|
||||
}
|
||||
}
|
||||
/// The frame summary, with Horizon's own passes' median GPU times while they are timed.
|
||||
fn summary_with_passes(&self) -> Value {
|
||||
let mut summary = self.frame_stats.summary();
|
||||
let passes = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| r.own_pass_times())
|
||||
.unwrap_or_default();
|
||||
if !passes.is_empty() {
|
||||
summary["gpu_passes"] = Value::Object(
|
||||
passes
|
||||
.into_iter()
|
||||
.map(|(name, ms)| (name, json!(ms)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
summary
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = reply.send(self.frame_stats.summary().to_string());
|
||||
let _ = reply.send(self.summary_with_passes().to_string());
|
||||
} else {
|
||||
self.pending_bench = Some((deadline, reply));
|
||||
}
|
||||
@@ -312,6 +330,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"gpu-passes" => {
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => self.gpu_passes = true,
|
||||
Some("off") => self.gpu_passes = false,
|
||||
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
@@ -820,7 +847,7 @@ impl Game<'_> {
|
||||
})))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.frame_stats.summary();
|
||||
let summary = self.summary_with_passes();
|
||||
if words.get(1) == Some(&"reset") {
|
||||
self.frame_stats.reset();
|
||||
}
|
||||
@@ -1032,6 +1059,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,7 +1172,7 @@ impl Game<'_> {
|
||||
"distance": self.settings.horizon.lod_distance,
|
||||
"effective_distance": self.lod_distance(),
|
||||
"terrain_distance": self.terrain_distance(),
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else { "local" },
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
|
||||
@@ -2382,6 +2382,16 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
MobEvent::ParticleColored {
|
||||
at,
|
||||
color: [r, g, b],
|
||||
} => {
|
||||
let options = horizon_client::particles::ParticleOptions::colored(
|
||||
horizon_client::particles::ParticleType::EntityEffect,
|
||||
[r, g, b, 1.0],
|
||||
);
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer { mob } => {
|
||||
let previous = self
|
||||
@@ -3788,7 +3798,7 @@ impl Game<'_> {
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
self.spawn_primed_tnt(pos, false);
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
@@ -5901,6 +5911,7 @@ impl Game<'_> {
|
||||
self.rendered_mobs.set(0);
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
@@ -5974,6 +5985,25 @@ impl Game<'_> {
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
if !is_player
|
||||
&& !layers_only
|
||||
&& self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
{
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
@@ -6075,6 +6105,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
|
||||
@@ -162,6 +162,13 @@ impl Game<'_> {
|
||||
if self.debug && !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// Horizon's own passes, in a group of their own.
|
||||
let own_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
overlay::labelled_pass_lines("Horizon GPU", &r.own_pass_times())
|
||||
});
|
||||
if self.debug && !own_lines.is_empty() {
|
||||
d.group("Horizon passes", own_lines);
|
||||
}
|
||||
}
|
||||
"tps" => self.debug_tps_line(d),
|
||||
// DebugEntryChunkGeneration, where the world is generated
|
||||
|
||||
@@ -877,6 +877,9 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
lod_distance: horizon_client::lod_net::distance_property(
|
||||
props.raw.get(horizon_client::lod_net::PROPERTY),
|
||||
),
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::{ServerState, log};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
@@ -48,6 +49,27 @@ impl Game<'_> {
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
"serversettings/difficulty/set" => {
|
||||
let Some(value) = body["value"].as_str() else {
|
||||
return json!({ "error": "difficulty must be a string" });
|
||||
};
|
||||
let Some(index) = DIFFICULTIES.iter().position(|name| *name == value) else {
|
||||
return json!({ "error": format!("unknown difficulty {value:?}") });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update difficulty from '{}' to '{}'",
|
||||
DIFFICULTIES[self.level.difficulty_id() as usize],
|
||||
value
|
||||
));
|
||||
// DedicatedServer.setDifficulty stores the property and then forces the world
|
||||
// difficulty, including hardcore's always-hard rule and the player broadcast.
|
||||
self.store_property("difficulty", value.to_owned());
|
||||
self.set_difficulty_forced(if self.level.hardcore { 3 } else { index as u8 });
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
"allowlist/add" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Add player '{}' to allowlist", shown(&user)));
|
||||
|
||||
@@ -1377,7 +1377,7 @@ impl Game<'_> {
|
||||
);
|
||||
} else if &*name == "minecraft:tnt" {
|
||||
if self.ui.rules.bool("tnt_explodes") {
|
||||
self.spawn_primed_tnt(target_pos, false);
|
||||
self.spawn_primed_tnt(target_pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
|
||||
self.process_level_events()?;
|
||||
} else {
|
||||
@@ -1441,7 +1441,7 @@ impl Game<'_> {
|
||||
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
|
||||
let target = Self::pos_of(front);
|
||||
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
|
||||
self.add_primed_tnt(target, rot, 80);
|
||||
self.add_primed_tnt(target, rot, 80, None, None);
|
||||
self.audio.play(
|
||||
"minecraft:entity.tnt.primed",
|
||||
Some([
|
||||
|
||||
@@ -438,8 +438,10 @@ impl Dragon {
|
||||
fn sample(&self, delay: usize) -> (f64, f32) {
|
||||
self.history[(self.head + 64 - (delay & 63)) & 63]
|
||||
}
|
||||
/// DragonFlightHistory.get(delay, partial).
|
||||
/// `DragonFlightHistory.get(delay, partial)`, with
|
||||
/// `EnderDragonRenderer.extractRenderState`'s frozen history on death.
|
||||
fn sample_lerp(&self, delay: usize, partial: f32) -> (f64, f32) {
|
||||
let partial = if self.alive() { partial } else { 0.0 };
|
||||
let a = self.sample(delay);
|
||||
let b = self.sample(delay + 1);
|
||||
let yaw = b.1 + wrap(a.1 - b.1) * partial;
|
||||
@@ -3874,7 +3876,7 @@ fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out:
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
@@ -3911,6 +3913,55 @@ fn push_seen_with_hurt(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dying_dragons_keep_their_flight_pose_between_frames() {
|
||||
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
|
||||
dragon.history.fill((70.0, 170.0));
|
||||
dragon.position.y = 90.0;
|
||||
dragon.yaw = -170.0;
|
||||
dragon.record();
|
||||
assert_eq!(dragon.sample_lerp(0, 0.5), (80.0, 180.0));
|
||||
dragon.health = 0.0;
|
||||
for partial in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert_eq!(dragon.sample_lerp(0, partial), (70.0, 170.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Driven by `tools/test_dragon_render_parity.py`; compares the actual
|
||||
/// ray vertices with EnderDragonRenderer's captured VertexConsumer output.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dragon_render_parity_fixture() {
|
||||
let path = std::env::var("HORIZON_DRAGON_RENDER_OUTPUT").expect("harness output path");
|
||||
let mut cases = Vec::new();
|
||||
for t in [0.0, 0.05, 0.25, 0.5, 0.8, 0.9, 0.95, 1.0] {
|
||||
let mut vertices = Vec::new();
|
||||
dragon_rays(
|
||||
glam::Mat4::IDENTITY,
|
||||
t,
|
||||
horizon_client::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
// The fourth quad vertex only pads a triangle for our index buffer.
|
||||
let triangles: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|q| &q[..3])
|
||||
.map(|v| serde_json::json!([v.pos[0], v.pos[1], v.pos[2], v.color]))
|
||||
.collect();
|
||||
cases.push(triangles);
|
||||
}
|
||||
std::fs::write(path, serde_json::to_vec(&cases).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn death_rays_sample_inside_the_white_sprite() {
|
||||
// A four-texel white sprite surrounded by transparent atlas pixels.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, VoxelShape},
|
||||
@@ -175,12 +175,17 @@ pub(super) fn client_float() -> f32 {
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
|
||||
// blast before any mob does, which makes the damage type
|
||||
// `minecraft:player_explosion` (`server_explode` asks this).
|
||||
self.combat.blast_owner.clone().or_else(|| {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `ServerExplosion.getSeenPercent` for an entity box.
|
||||
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
|
||||
@@ -976,7 +981,19 @@ impl Game<'_> {
|
||||
let r = &mut self.world.random;
|
||||
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
|
||||
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
|
||||
self.add_primed_tnt(BlockPos::new(pos[0], pos[1], pos[2]), rot, fuse);
|
||||
// TntBlock.wasExploded hands the new TNT the blast's indirect
|
||||
// source, so a chain reaction keeps the player who started it.
|
||||
let (owner, guest) = match &self.combat.blast_owner {
|
||||
Some(_) => (Some(Who::Player), self.combat.blast_guest),
|
||||
None => (None, None),
|
||||
};
|
||||
self.add_primed_tnt(
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
rot,
|
||||
fuse,
|
||||
owner,
|
||||
guest,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user