Merge main into #6650

server_e2e: dialog_close now uses main's frame proxy (renamed PacketProxy,
with close_container through its send_packet) instead of a second proxy
class; the server runs with the proxy for book_menu or dialog_close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:46:47 +01:00
commit 2d257a0bdd
990 files changed
+100179 -12703

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
animation) must be reached from `game/remote*.rs`.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
+5
View File
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+22 -1
View File
@@ -15,6 +15,8 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -26,6 +28,7 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
@@ -174,7 +177,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
@@ -240,6 +243,18 @@ jobs:
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
if [[ "$RUNNER_OS" == "Windows" ]]; then
setup="$RUNNER_TEMP/setup-smoke"
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
installed="$setup/Horizon Launcher/horizon-launcher.exe"
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
"$installed" uninstall --quiet
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
rm -rf "$setup"
fi
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -275,6 +290,12 @@ jobs:
fi
fi
ls -l out
- name: Setup exe (Windows)
# The launcher's binary under another name: started with no arguments it shows the per-user
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
+15 -15
View File
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
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.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- 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
@@ -143,7 +147,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
@@ -174,28 +178,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
## Single player runs on a server thread
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
Open `[server-thread]` issues list the gaps left.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
Generated
+15
View File
@@ -1399,6 +1399,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1461,6 +1462,7 @@ dependencies = [
"horizon-threads",
"horizon-world",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1542,6 +1544,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1574,6 +1583,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -1646,6 +1656,7 @@ dependencies = [
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
@@ -1733,6 +1744,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
@@ -5120,6 +5132,9 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
+2
View File
@@ -17,6 +17,8 @@ members = [
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+4 -13
View File
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+56
View File
@@ -91,3 +91,59 @@ licensed under the MIT license:
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## FusionExamples
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
texture templates (`templates/template <layout>.png`) of
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
licensed under the MIT license:
> Copyright (c) 2024 Martijn van den Brand
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## BlueMap
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
`settings.json`, at commit
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
BlueMap is licensed under the MIT license:
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
> Copyright (c) contributors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in
> all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
+39
View File
@@ -130,6 +130,26 @@ impl ModelLibrary {
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
pub fn resolves(&self, name: &str) -> bool {
let Ok(mut name) = identifier(name) else {
return false;
};
let mut visited = BTreeSet::new();
loop {
let Some(model) = self.models.get(&name) else {
return false;
};
if !visited.insert(name.clone()) {
return false;
}
match &model.parent {
Some(parent) => name = parent.clone(),
None => return true,
}
}
}
/// `name`'s resolved model with a virtual child in front of it that has
/// only `textures`: the child's slots win, and `#references` anywhere in
/// the chain re-bind through them (so `builtin/generated`'s
@@ -529,4 +549,23 @@ mod tests {
"minecraft:missingno"
);
}
#[test]
fn a_model_resolves_only_with_its_whole_parent_chain() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
("item/orphan", r#"{"parent":"item/gone"}"#),
("item/a", r#"{"parent":"item/b"}"#),
("item/b", r#"{"parent":"item/a"}"#),
]);
assert!(library.resolves("item/stick"));
assert!(library.resolves("minecraft:item/stick"));
assert!(!library.resolves("item/absent"));
assert!(!library.resolves("item/orphan"));
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
}
+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+92 -18
View File
@@ -239,8 +239,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +323,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +380,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +423,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+74 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
+76 -1
View File
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
Some("custom") => Some(crate::text::ClickEvent::Custom {
id: crate::identifier::parse_identifier(&text("id")?)?,
payload: e
.get("payload")
.filter(|value| !value.is_null())
.map(crate::scoreboard::json_to_tag),
}),
_ => None,
}
}
@@ -585,6 +592,63 @@ mod tests {
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
use crate::text::ClickEvent;
use horizon_world::nbt::Tag;
for (payload, expected) in [
(None, None),
(Some(serde_json::Value::Null), None),
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
(
Some(serde_json::json!({"shop": "potions", "amount": 3})),
Some(Tag::Compound(
[
("amount".into(), Tag::Int(3)),
("shop".into(), Tag::String("potions".into())),
]
.into_iter()
.collect(),
)),
),
] {
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
if let Some(payload) = payload {
event["payload"] = payload;
}
let component = serde_json::json!({
"text": "", "click_event": event,
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
});
let text = to_text(&component, &mut |_| Text::empty());
for (_, style) in text.chars() {
assert_eq!(
style.click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: expected.clone(),
})
);
}
let encoded = text.component();
let decoded = to_text(&encoded, &mut |_| Text::empty());
assert_eq!(decoded, text);
if expected.is_none() {
assert!(!encoded.to_string().contains("\"payload\""));
}
}
for id in ["Bad ID", "Uppercase:shop"] {
let text = to_text(
&serde_json::json!({
"text": "Shop", "click_event": {"action": "custom", "id": id}
}),
&mut |_| Text::empty(),
);
assert_eq!(text.spans[0].style.click, None);
}
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
@@ -624,4 +688,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+51 -12
View File
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
RULES.iter().find(|r| r.name == name)
available_rules(true).find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -160,7 +192,9 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name) else { return false };
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -176,8 +210,7 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
RULES
.iter()
self.available_rules()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -189,8 +222,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -212,11 +245,14 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
let mut rules = Self::default();
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let Some(data) = root.get("data") else {
return rules;
};
for r in RULES {
for r in rules.available_rules() {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -232,15 +268,18 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::default();
return Self::with_features(features);
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::default();
return Self::with_features(features);
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
+106 -2
View File
@@ -3,6 +3,7 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -34,6 +35,15 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -267,13 +319,19 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| key.to_owned())
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -350,6 +408,52 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+14 -2
View File
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
}
/// Click actions carried by locally constructed text components.
/// Click actions carried by text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
@@ -53,6 +53,11 @@ pub enum ClickEvent {
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
Custom {
id: String,
payload: Option<horizon_world::nbt::Tag>,
},
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -539,7 +544,7 @@ const STYLE_KEYS: [&str; 11] = [
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
/// `Style.Serializer`: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(c) = style.color {
@@ -581,6 +586,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::Custom { id, payload } => {
let mut event = json!({ "action": "custom", "id": id });
if let Some(payload) = payload {
event["payload"] = crate::scoreboard::tag_to_json(payload);
}
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -51,6 +51,10 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
+6
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -132,6 +134,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
+2
View File
@@ -7,6 +7,8 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
+9 -1
View File
@@ -105,6 +105,8 @@ pub struct RenderAssets {
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -224,7 +226,7 @@ impl RenderAssets {
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
let mut models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
@@ -244,6 +246,11 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
};
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
@@ -304,6 +311,7 @@ impl RenderAssets {
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
fusion,
})
}
}
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
+6 -1
View File
@@ -1206,8 +1206,13 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.engine.registry = registry;
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+74 -6
View File
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
}
}
@@ -564,6 +597,41 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
+18 -16
View File
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
&path,
)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -277,21 +278,22 @@ impl Biomes {
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
// from the prepared data file when there is one.
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
let mut files: Vec<_> = folder
.files
.iter()
.filter(|f| !f.name.contains('/'))
.collect();
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
&maps,
));
}
let index = list
.iter()
@@ -46,6 +46,7 @@ pub enum Ticking {
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
@@ -84,6 +85,18 @@ pub struct BlockEntities {
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
/// The positions of the block entities that draw a bounding box (structure and test
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
fn shows_box(entity: &BlockEntity) -> bool {
matches!(
entity,
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
)
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
@@ -220,11 +233,53 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The structure and test instance blocks (a frame draws their boxes), in no order.
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
self.boxes
.iter()
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
}
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
if entity.is_some_and(shows_box) {
self.boxes.insert(pos);
} else {
self.boxes.remove(&pos);
}
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
@@ -248,12 +303,28 @@ impl BlockEntities {
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_box {
self.boxes.insert(pos);
} else if old.as_ref().is_some_and(shows_box) {
self.boxes.remove(&pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
@@ -275,15 +346,20 @@ impl BlockEntities {
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if absent && shows_box(entity) {
self.boxes.insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
@@ -301,6 +377,7 @@ impl BlockEntities {
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -311,21 +388,30 @@ impl BlockEntities {
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let mods = &mut self.mods;
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
}
kept
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -342,7 +428,9 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
@@ -476,6 +564,52 @@ mod tests {
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
@@ -560,4 +694,43 @@ mod tests {
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
#[test]
fn boxes_list_exactly_the_structure_and_test_blocks() {
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
let listed = |b: &BlockEntities| {
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
v.sort();
v
};
let mut b = level();
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
b.insert(p, test_block());
b.insert(q, hopper());
assert_eq!(listed(&b), [p]);
// Replaced by another kind, and back.
b.insert(p, hopper());
assert_eq!(listed(&b), []);
b.insert(q, test_block());
b.get_or_insert_with(r, test_block);
assert_eq!(listed(&b), [q, r]);
// Taken out: not shown until put back; put back as another kind: gone.
let taken = b.take(&q).unwrap();
assert_eq!(listed(&b), [r]);
b.put(q, taken);
assert_eq!(listed(&b), [q, r]);
let taken = b.take(&r).unwrap();
drop(taken);
b.put(r, hopper());
assert_eq!(listed(&b), [q]);
b.remove(&q);
assert_eq!(listed(&b), []);
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
assert_eq!(listed(&b), [p]);
b.retain(|pos, _| *pos != p);
assert_eq!(listed(&b), []);
b.insert(r, test_block());
b.clear();
assert!(b.boxes.is_empty());
}
}
@@ -0,0 +1,630 @@
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
//! atlas has it.
//!
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
//! block models here, so their files are not read; copper golem statues have no CEM name.
use crate::block_entity_render::ModelPart;
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
/// names for its vanilla parts.
struct Target {
key: &'static str,
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
/// apart shares (a trapped chest's is the chest's).
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
}
const CHEST: &[(&str, &str)] = &[
("root", "root"),
("base", "bottom"),
("lid", "lid"),
("knob", "lock"),
];
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
const PIGLIN: &[(&str, &str)] = &[
("root", "root"),
("head", "head"),
("left_ear", "left_ear"),
("right_ear", "right_ear"),
];
const BELL: &[(&str, &str)] = &[
("root", "root"),
("body", "bell_body"),
("base", "bell_base"),
];
const BOOK: &[(&str, &str)] = &[
("root", "root"),
("cover_right", "right_lid"),
("cover_left", "left_lid"),
("pages_right", "right_pages"),
("pages_left", "left_pages"),
("flipping_page_right", "flip_page1"),
("flipping_page_left", "flip_page2"),
("book_spine", "seam"),
];
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
const POT_SIDES: &[(&str, &str)] = &[
("front", "front"),
("back", "back"),
("left", "left"),
("right", "right"),
];
const fn model(
key: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base: key,
names,
parts,
}
}
const fn copy(
key: &'static str,
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base,
names,
parts,
}
}
/// In order: a copy comes after its base.
const TARGETS: &[Target] = &[
model("chest", &["chest"], CHEST),
model("double_chest_left", &["chest_left"], CHEST),
model("double_chest_right", &["chest_right"], CHEST),
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
copy(
"trapped_chest_left",
"double_chest_left",
&["trapped_chest_left"],
CHEST,
),
copy(
"trapped_chest_right",
"double_chest_right",
&["trapped_chest_right"],
CHEST,
),
copy("ender_chest", "chest", &["ender_chest"], CHEST),
model("shulker_box", &["shulker_box"], SHULKER),
model("standing_banner", &["banner_base", "banner"], BANNER),
model("wall_banner", &["banner_base", "banner"], BANNER),
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
model("skeleton_skull", &["head_skeleton"], HEAD),
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
model("zombie_head", &["head_zombie"], HEAD),
model("creeper_head", &["head_creeper"], HEAD),
model("dragon_skull", &["head_dragon"], DRAGON),
model("piglin_head", &["head_piglin"], PIGLIN),
model("player_head", &["head_player"], HEAD),
model("bell", &["bell"], BELL),
model("book", &["enchanting_book", "book"], BOOK),
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
model("conduit_shell", &["conduit"], &[("base", "shell")]),
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
];
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
pub type Reshaped = (
HashMap<&'static str, ModelPart>,
HashMap<&'static str, String>,
);
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
/// a `.jem` there.
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
let (reshaped, unused) = reshape_all(root, vanilla)?;
for (path, part) in unused {
log::warn!("Horizon: custom entity model {path}: no part {part}");
}
Some(reshaped)
}
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
fn reshape_all(
root: &Path,
vanilla: &HashMap<&'static str, ModelPart>,
) -> Option<(Reshaped, Vec<(String, String)>)> {
let files = jem_files(root);
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
if !TARGETS.iter().any(named) {
return None;
}
let mut models = vanilla.clone();
let mut textures = HashMap::new();
let mut reshaped = HashSet::new();
// Each file parsed once; which of its entries found a part in some model.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
for t in TARGETS {
let Some(model) = vanilla.get(t.base) else {
continue;
};
let found = t
.names
.iter()
.flat_map(|name| files.get(*name).into_iter().flatten())
.map(String::as_str)
.find(|&path| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some(path) = found else {
// A copy of a reshaped base that has no file of its own stays vanilla.
if t.key != t.base && reshaped.contains(t.base) {
models.insert(t.key, model.clone());
}
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut out = model.clone();
let hits = apply(&mut out, jem, t.parts);
// A file shared by several models (the conduit's, the pot's) paints only the models
// it reshapes: vanilla geometry keeps its own texture's layout.
let reshapes = hits.iter().any(|hit| *hit);
let marks = used
.entry(path)
.or_insert_with(|| vec![false; jem.parts.len()]);
for (mark, hit) in marks.iter_mut().zip(hits) {
*mark |= hit;
}
models.insert(t.key, out);
reshaped.insert(t.key);
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
textures.insert(t.key, texture);
}
}
let mut unused = Vec::new();
for (path, marks) in &used {
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
unused.push(((*path).to_owned(), p.part.clone()));
}
}
Some(((models, textures), unused))
}
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
/// `cem#<n>`. Which entries found their part.
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
let mut count = 0;
let mut hits = Vec::with_capacity(jem.parts.len());
for p in &jem.parts {
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
let target = match mapped {
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
_ => p.part.as_str(),
};
let Some(node) = find_mut(model, target) else {
hits.push(false);
continue;
};
if !p.attach {
node.polygons.clear();
}
let (name, mut bone) = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
node.children.push((name, bone));
hits.push(true);
}
hits
}
/// The model itself for `root`, else its first part named `name`, depth first.
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
part.children.iter_mut().find_map(|(n, c)| {
if n.as_str() == name {
Some(c)
} else {
child(c, name)
}
})
}
if name == "root" {
return Some(model);
}
child(model, name)
}
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
/// change by name reaches it.
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
let out = ModelPart {
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
};
(name, out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::{collections::BTreeSet, fs};
/// A part with one face and `children`.
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[[0.0; 5]; 4]],
children: children
.into_iter()
.map(|(n, c)| (n.to_owned(), c))
.collect(),
}
}
pub(crate) fn chest() -> ModelPart {
let mut root = plane(vec![
("bottom", plane(vec![])),
("lid", plane(vec![])),
("lock", plane(vec![])),
]);
root.polygons.clear();
root
}
pub(crate) const BOX: &str =
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// A `.jem` replacing each of `parts` with one box.
fn jem(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
/// deep, so a part that took another entry's box shows.
fn jem_sized(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.enumerate()
.map(|(i, p)| {
let depth = i + 1;
format!(
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
)
})
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// How deep (z) the custom box under `model`'s part `name` is.
fn depth(model: &ModelPart, name: &str) -> f32 {
let part = model.child(name).unwrap();
let zs = part
.children
.iter()
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
hi - lo
}
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
/// child of a box's six faces.
fn replaced(model: &ModelPart, name: &str) -> bool {
let part = model.child(name).unwrap();
match part.children.as_slice() {
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
_ => false,
}
}
#[test]
fn every_optifine_part_name_lands_on_its_vanilla_part() {
// File, OptiFine's part, the model it reshapes, vanilla's part there.
let cases = [
("chest", "base", "chest", "bottom"),
("chest", "lid", "chest", "lid"),
("chest", "knob", "chest", "lock"),
("chest_left", "base", "double_chest_left", "bottom"),
("chest_left", "lid", "double_chest_left", "lid"),
("chest_left", "knob", "double_chest_left", "lock"),
("chest_right", "base", "double_chest_right", "bottom"),
("chest_right", "lid", "double_chest_right", "lid"),
("chest_right", "knob", "double_chest_right", "lock"),
("trapped_chest", "base", "trapped_chest", "bottom"),
("trapped_chest", "knob", "trapped_chest", "lock"),
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
(
"trapped_chest_right",
"base",
"trapped_chest_right",
"bottom",
),
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
("ender_chest", "base", "ender_chest", "bottom"),
("ender_chest", "knob", "ender_chest", "lock"),
("shulker_box", "base", "shulker_box", "base"),
("shulker_box", "lid", "shulker_box", "lid"),
("banner", "stand", "standing_banner", "pole"),
("banner", "top", "standing_banner", "bar"),
("banner", "top", "wall_banner", "bar"),
("banner", "slate", "standing_banner_flag", "flag"),
("banner", "slate", "wall_banner_flag", "flag"),
("head_skeleton", "head", "skeleton_skull", "head"),
(
"head_wither_skeleton",
"head",
"wither_skeleton_skull",
"head",
),
("head_zombie", "head", "zombie_head", "head"),
("head_creeper", "head", "creeper_head", "head"),
("head_dragon", "head", "dragon_skull", "head"),
("head_dragon", "jaw", "dragon_skull", "jaw"),
("head_piglin", "head", "piglin_head", "head"),
("head_piglin", "left_ear", "piglin_head", "left_ear"),
("head_piglin", "right_ear", "piglin_head", "right_ear"),
("head_player", "head", "player_head", "head"),
("bell", "body", "bell", "bell_body"),
("bell", "base", "bell", "bell_base"),
("enchanting_book", "cover_right", "book", "right_lid"),
("enchanting_book", "cover_left", "book", "left_lid"),
("enchanting_book", "pages_right", "book", "right_pages"),
("enchanting_book", "pages_left", "book", "left_pages"),
(
"enchanting_book",
"flipping_page_right",
"book",
"flip_page1",
),
(
"enchanting_book",
"flipping_page_left",
"book",
"flip_page2",
),
("enchanting_book", "book_spine", "book", "seam"),
("lectern_book", "cover_right", "lectern_book", "right_lid"),
("lectern_book", "pages_left", "lectern_book", "left_pages"),
(
"lectern_book",
"flipping_page_right",
"lectern_book",
"flip_page1",
),
("lectern_book", "book_spine", "lectern_book", "seam"),
("conduit", "base", "conduit_shell", "shell"),
("conduit", "cage", "conduit_cage", "shell"),
("conduit", "wind", "conduit_wind", "wind"),
("conduit", "eye", "conduit_eye", "eye"),
("decorated_pot", "neck", "decorated_pot_base", "neck"),
("decorated_pot", "top", "decorated_pot_base", "top"),
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
("decorated_pot", "front", "decorated_pot_sides", "front"),
("decorated_pot", "back", "decorated_pot_sides", "back"),
("decorated_pot", "left", "decorated_pot_sides", "left"),
("decorated_pot", "right", "decorated_pot_sides", "right"),
];
// One file per name with each of its parts, and a stub of each model holding its parts
// (chest models all of theirs, so a misplaced entry shows).
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (file, part, key, vanilla) in cases {
files.entry(file).or_default().insert(part);
trees.entry(key).or_default().insert(vanilla);
}
for key in ["chest", "double_chest_left", "double_chest_right"] {
trees
.entry(key)
.or_default()
.extend(["bottom", "lid", "lock"]);
}
let files_by_name = files.clone();
let files: Vec<_> = files
.iter()
.map(|(file, parts)| {
let parts: Vec<_> = parts.iter().copied().collect();
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
})
.collect();
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
let root = pack("parts", &files);
let vanilla: HashMap<&'static str, ModelPart> = trees
.iter()
.map(|(key, parts)| {
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
(*key, plane(parts))
})
.collect();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, _), unused) = result.unwrap();
for (file, part, key, vanilla) in cases {
assert!(
replaced(&models[key], vanilla),
"{file}.jem {part}: {key} {vanilla}"
);
// The box is this entry's own: swapped names (left and right, body and base) show.
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
assert_eq!(
depth(&models[key], vanilla),
own as f32,
"{file}.jem {part}: {key} {vanilla}"
);
}
assert_eq!(unused, Vec::new());
}
#[test]
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
let root = pack(
"chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
{{"part": "lid", {BOX}}},
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
{{"part": "bottom", {BOX}}},
{{"part": "tail", {BOX}}}]}}"#
),
)],
);
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
let chest = &models["chest"];
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
assert!(replaced(chest, "lid"));
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
assert!(replaced(chest, "bottom"));
// Attached: the lock keeps its face beside the scaled box.
let lock = chest.child("lock").unwrap();
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
// `tail` names no chest part: the one warning.
assert_eq!(
unused,
[(
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
"tail".to_owned()
)]
);
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
// only the chest takes the texture.
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
let lid = models[key].child("lid").unwrap();
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
}
assert_eq!(
textures,
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
);
}
#[test]
fn banners_split_one_file_and_books_fall_back() {
let root = pack(
"banner",
&[
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
],
);
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
let wall = plane(vec![("bar", plane(vec![]))]);
let flag = plane(vec![("flag", plane(vec![]))]);
let book = plane(vec![("left_lid", plane(vec![]))]);
let vanilla: HashMap<_, _> = [
("standing_banner", banner),
("wall_banner", wall),
("wall_banner_flag", flag),
("book", book),
]
.into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
assert!(replaced(&models["standing_banner"], "pole"));
assert!(replaced(&models["standing_banner"], "bar"));
assert!(replaced(&models["wall_banner"], "bar"));
assert!(replaced(&models["wall_banner_flag"], "flag"));
assert!(replaced(&models["book"], "left_lid"));
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
let lectern = models["lectern_book"].child("left_lid").unwrap();
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
assert!(textures.is_empty());
// Each entry found its part in one of the models: no warning.
assert_eq!(unused, Vec::new());
}
#[test]
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, _) = result.unwrap();
assert!(replaced(&models["book"], "left_lid"));
assert!(replaced(&models["lectern_book"], "left_lid"));
}
#[test]
fn a_shared_file_paints_only_the_models_it_reshapes() {
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
// vanilla geometry and so their own textures.
let file = format!(
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
);
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
let vanilla: HashMap<_, _> = [
"conduit_shell",
"conduit_cage",
"conduit_wind",
"conduit_eye",
]
.map(|key| {
let part = if key == "conduit_eye" { "eye" } else { "shell" };
(key, plane(vec![(part, plane(vec![]))]))
})
.into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, textures) = result.unwrap();
assert!(replaced(&models["conduit_eye"], "eye"));
let painted: BTreeSet<_> = textures.keys().copied().collect();
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
//! Finding a server's BlueMap without the player naming it
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
//! likely web addresses (`candidates`), taking only one whose `settings.json`
//! is BlueMap's web-app settings, and the answer is kept per server in
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
//! A `blueMap:` line the player wrote always wins.
use crate::bluemap::Settings;
use std::{
net::IpAddr,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime},
};
/// The answer kept per server, beside its BlueMap tiles.
pub const FILE: &str = "detected.txt";
/// A server found without a map is asked again after this long.
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
/// A map found is checked again (its address first) after this long.
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
/// Each probe gets this long, connecting included.
pub const TIMEOUT: Duration = Duration::from_secs(4);
/// Probes start this far apart at least.
pub const GAP: Duration = Duration::from_millis(500);
/// Where a level's BlueMap comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// The player's `blueMap:` line, or a map found before.
Url {
url: String,
maps: Vec<(String, String)>,
},
/// To be looked for at these addresses, in order.
Detect(Vec<String>),
}
/// What the server at `address` draws far terrain from, its BlueMap folder
/// being `dir`: `None` when the player named no map and either auto-detect
/// is off or the server was found without one recently.
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
if let Some(server) = settings.server(address) {
return Some(Source::Url {
url: server.url.clone(),
maps: server.maps.clone(),
});
}
if !settings.auto_detect {
return None;
}
let mut probes = Vec::new();
// Only an address this server's would be looked for at: a remembered
// one from elsewhere (a copied folder) names someone else's.
let ours = candidates(address);
match remembered(dir, now) {
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
Remembered::Found(url) => {
return Some(Source::Url {
url,
maps: Vec::new(),
});
}
Remembered::Nothing => return None,
Remembered::Stale(Some(url)) => probes.push(url),
Remembered::Stale(None) => {}
}
for candidate in ours {
if !probes.contains(&candidate) {
probes.push(candidate);
}
}
(!probes.is_empty()).then_some(Source::Detect(probes))
}
/// The first labels a game server's host name takes beside its site's.
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
/// Domains whose names belong to many customers (dynamic DNS, server
/// hosts): `map.` of them is someone else's.
const SHARED: &[&str] = &[
"aternos.me",
"ddns.net",
"duckdns.org",
"hopto.org",
"minehut.gg",
"no-ip.org",
"playit.gg",
"serveminecraft.net",
"zapto.org",
];
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
/// web server (port 8100), the server's host, and its `map.` subdomain, also
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
pub fn candidates(address: &str) -> Vec<String> {
let address = address.trim().to_ascii_lowercase();
let host = if let Some(rest) = address.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if address.matches(':').count() > 1 {
&address[..]
} else {
address.split(':').next().unwrap_or(&address)
};
let host = host.trim_end_matches('.');
if host.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(ip) = host.parse::<IpAddr>() {
// Certificates are for names: plain HTTP only.
let host = match ip {
IpAddr::V6(_) => format!("[{host}]"),
IpAddr::V4(_) => host.to_owned(),
};
out.push(format!("http://{host}:8100"));
out.push(format!("http://{host}"));
return out;
}
if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Vec::new();
}
out.push(format!("http://{host}:8100"));
let labels = host.split('.').count();
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("https://{map}"));
}
out.push(format!("https://{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("http://{map}"));
}
out.push(format!("http://{host}"));
// `play.example.net` → `map.example.net`, only for a game server's
// usual names: the parent of `name.hosting.example` is another party's.
if labels >= 3
&& let Some((first, parent)) = host.split_once('.')
&& GAME_LABELS.contains(&first)
&& !SHARED.contains(&parent)
&& let Some(map) = map(parent)
{
out.push(format!("https://{map}"));
out.push(format!("http://{map}"));
}
let mut seen = Vec::new();
out.retain(|url| {
let new = !seen.contains(url);
seen.push(url.clone());
new
});
out
}
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
/// and where their data lies, not any page a web server returns.
pub fn is_web_app_settings(json: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
return false;
};
let maps = v.get("maps").and_then(|m| m.as_array());
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
}
/// Whether a BlueMap web app answers at `base`.
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
use std::io::Read;
let Ok(response) = agent
.get(&format!("{base}/settings.json"))
.timeout(TIMEOUT)
.call()
else {
return false;
};
let mut body = Vec::new();
response
.into_reader()
.take(256 << 10)
.read_to_end(&mut body)
.is_ok()
&& is_web_app_settings(&body)
}
/// What an earlier look found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Remembered {
Found(String),
/// Nothing, recently.
Nothing,
/// Never looked, or long enough ago to look again (first at the map
/// found then, if any).
Stale(Option<String>),
}
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
return Remembered::Stale(None);
};
let mut words = text.split_whitespace();
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
return Remembered::Stale(None);
};
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
let Ok(age) = now.duration_since(at) else {
return Remembered::Stale(None);
};
match (kind, words.next()) {
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
_ => Remembered::Stale(None),
}
}
/// Keeps what a look found (`None`: no map).
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let text = match found {
Some(url) => format!("found {secs} {url}\n"),
None => format!("none {secs}\n"),
};
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(FILE), text);
}
/// The servers being looked at now, by BlueMap folder: one look a server at
/// a time, however many levels open meanwhile.
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// The right to look for `dir`'s server's map, until dropped.
pub struct Claim(PathBuf);
impl Claim {
/// `None` while another look at the same server runs.
pub fn take(dir: &Path) -> Option<Self> {
let mut looking = LOOKING.lock().unwrap();
if looking.iter().any(|d| d == dir) {
return None;
}
looking.push(dir.to_owned());
Some(Self(dir.to_owned()))
}
}
impl Drop for Claim {
fn drop(&mut self) {
LOOKING.lock().unwrap().retain(|d| *d != self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_cover_the_web_server_host_and_map_subdomains() {
assert_eq!(
candidates("Play.Example.net:25566"),
[
"http://play.example.net:8100",
"https://map.play.example.net",
"https://play.example.net",
"http://map.play.example.net",
"http://play.example.net",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("example.net"),
[
"http://example.net:8100",
"https://map.example.net",
"https://example.net",
"http://map.example.net",
"http://example.net",
]
);
// Already the map's host: no `map.map.`.
assert_eq!(
candidates("map.example.net"),
[
"http://map.example.net:8100",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("192.0.2.7:25565"),
["http://192.0.2.7:8100", "http://192.0.2.7"]
);
assert_eq!(
candidates("[2001:db8::7]:25565"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("2001:db8::7"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("localhost"),
[
"http://localhost:8100",
"https://localhost",
"http://localhost"
]
);
// A hosted name's parent is the host's, not the server's.
assert!(
!candidates("play.ddns.net")
.iter()
.any(|url| url.ends_with("//map.ddns.net"))
);
assert!(
!candidates("name.host.example")
.iter()
.any(|url| url.ends_with("//map.host.example"))
);
assert!(candidates("").is_empty());
assert!(candidates("evil.example.net/path?x").is_empty());
}
#[test]
fn only_blue_maps_settings_count() {
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
assert!(is_web_app_settings(web_app));
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
assert!(
!is_web_app_settings(br#"{"maps":["world"]}"#),
"no data root"
);
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
assert!(!is_web_app_settings(
br#"{"maps":[1],"mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"{"maps":"world","mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
));
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-bluemap-detect-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn answers_are_kept_and_looked_at_again_later() {
let dir = scratch("remember");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let hour = Duration::from_secs(3600);
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
remember(&dir, None, t0);
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
// Written ahead of the clock: not trusted.
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
remember(&dir, Some("https://map.example.net"), t0);
assert_eq!(
remembered(&dir, t0 + 2 * RETRY_NONE),
Remembered::Found("https://map.example.net".into())
);
assert_eq!(
remembered(&dir, t0 + RETRY_FOUND),
Remembered::Stale(Some("https://map.example.net".into()))
);
std::fs::write(dir.join(FILE), "garbage").unwrap();
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_players_map_wins_and_detection_waits_for_the_setting() {
let dir = scratch("source");
let now = SystemTime::now();
let mut settings = Settings::default();
let address = "play.example.net";
assert_eq!(
source(&settings, address, &dir, now),
None,
"off by default"
);
settings.auto_detect = true;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for");
};
assert_eq!(probes, candidates(address));
remember(&dir, None, now);
assert_eq!(
source(&settings, address, &dir, now),
None,
"none, recently"
);
remember(&dir, Some("https://map.example.net"), now);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "https://map.example.net".into(),
maps: Vec::new()
})
);
// A remembered map this server's addresses don't include (a copied
// folder's) is no map.
remember(&dir, Some("https://tracker.example"), now);
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for, the remembered one ignored");
};
assert_eq!(probes, candidates(address));
remember(&dir, Some("https://map.example.net"), now);
// Past a week the found map is tried first, then the rest.
let later = now + RETRY_FOUND;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
panic!("looked for again");
};
assert_eq!(probes[0], "https://map.example.net");
assert_eq!(probes.len(), candidates(address).len());
settings.servers.push(
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "http://example.net:8123".into(),
maps: vec![("minecraft:the_nether".into(), "n".into())]
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn one_look_a_server_at_a_time() {
let dir = scratch("claim");
let claim = Claim::take(&dir).unwrap();
assert!(Claim::take(&dir).is_none());
drop(claim);
assert!(Claim::take(&dir).is_some());
}
}
@@ -118,6 +118,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
positions: [
[0.0, 0.5, 0.0],
+24 -18
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,6 +158,28 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -172,23 +194,7 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
let mut sources: Vec<(String, Value)> = brewing_sources(root);
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// A server's own chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
+227 -17
View File
@@ -285,20 +285,122 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -347,14 +449,15 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -366,13 +469,47 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -564,19 +701,61 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -644,6 +823,10 @@ impl RolledLoot {
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Server-owned mod data attached to this chunk.
pub attachments: horizon_world::attachments::Attachments,
/// Attachment maps of the chunk's block entities, on a save snapshot.
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
@@ -670,6 +853,33 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
(|i| 0xA0 | (i / 256) as u8, "block"),
(|i| (i % 251) as u8, "full"),
(|_| 0x7C, "uniform"),
];
for (value, name) in cases {
let light = Light::packed(Box::new(std::array::from_fn(value)));
let form = match light {
Light::Sky(..) => "sky",
Light::Block(..) => "block",
Light::Full(_) => "full",
Light::Uniform(_) => "uniform",
};
assert_eq!(form, name);
for at in [0, 17, 1000, 4078] {
let mut run = [0u8; 18];
light.copy_run(at, &mut run);
let expected: Vec<u8> = (at..at + 18).map(value).collect();
assert_eq!(run.as_slice(), expected, "{name} at {at}");
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
}
}
}
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
+206 -19
View File
@@ -4,6 +4,7 @@
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use crate::cit_glint::{Global, StackGlint};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
@@ -16,7 +17,7 @@ use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
let props = read_properties(&root.join(&path))?;
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
rules
}
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
/// be read.
pub fn read_properties(path: &Path) -> Option<Properties> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
Some(Properties::parse_equals_first(&text))
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// The pack's `cit.properties`.
pub global: Global,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
@@ -56,6 +64,7 @@ pub struct CitRules {
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
}
#[derive(Debug, Default)]
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
}
/// `rules` in priority order, the winner first.
@@ -106,6 +118,19 @@ impl CitRules {
self.rules.is_empty()
}
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
/// match no stack, so vanilla's glint and the other rules of their layer stay.
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
self.any_item.retain(|&i| keep(i as usize));
let named = &mut self.by_item[Kind::Enchantment as usize];
for list in named.values_mut() {
list.retain(|&i| keep(i as usize));
}
named.retain(|_, list| !list.is_empty());
lock(&self.enchanted).clear();
lock(&self.glints).clear();
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
@@ -123,13 +148,14 @@ impl CitRules {
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
/// GUI), in priority order.
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
@@ -138,7 +164,7 @@ impl CitRules {
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
(1, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
@@ -147,24 +173,62 @@ impl CitRules {
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
/// pack changes no glint. `foil` is whether the stack draws a glint at all
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
pub fn glint(
&self,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
foil: bool,
) -> Option<Arc<StackGlint>> {
let no_rules =
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
// Before the cache, which `foil` is not part of.
if !foil || (no_rules && self.global.use_glint) {
return None;
}
let comps = encode_components(stack);
let key = cache_key(
(2, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
if let Some(hit) = lock(&self.glints).get(&key) {
return Some(hit.clone());
}
let matched = self.enchantments(stack, hand, max_damage);
let glint = Arc::new(StackGlint::new(
&self.rules,
&matched,
stack.crafting_enchantments(),
&self.global,
));
remember(&self.glints, key, glint.clone());
Some(glint)
}
/// Whether rule `i` matches the stack drawn in `hand`.
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
hand: Hand,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
if rule.hand != Hand::Any && rule.hand != hand {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
@@ -646,7 +710,7 @@ mod tests {
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
@@ -659,4 +723,127 @@ mod tests {
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
}
/// `stack` with its `enchantment_glint_override` set to `on`.
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
let flag = (
"minecraft:enchantment_glint_override".to_owned(),
Tag::Byte(on.into()),
);
stack.data_mut().other.push(flag);
stack
}
#[test]
fn a_stack_glint_reads_the_book_and_the_pack_file() {
let mut cit = rules(&[
("sharp", "type=enchantment\nenchantments=sharpness"),
(
"keep",
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
),
]);
// An enchanted book's glint follows its stored enchantments.
let mut book = new("enchanted_book");
book.data_mut().stored_enchantments = vec![
(Arc::from("minecraft:sharpness"), 3),
(Arc::from("minecraft:mending"), 1),
];
let book = glint(&cit, &book, true).unwrap();
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
assert_eq!(weights, [0.75, 0.25]);
assert!(book.vanilla);
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
assert_eq!(plain.layers.len(), 1);
assert!(!plain.vanilla);
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
let mut bare = rules(&[("stick", "items=stick")]);
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
bare.global.use_glint = false;
assert!(
!glint(&bare, &overridden(new("stick"), true), false)
.unwrap()
.vanilla
);
cit.global.use_glint = false;
assert!(
glint(&cit, &overridden(new("stick"), true), false)
.unwrap()
.is_empty()
);
}
#[test]
fn only_a_stack_with_a_glint_gets_layers() {
let cit = rules(&[("any", "type=enchantment")]);
assert!(glint(&cit, &new("stick"), false).is_none());
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
assert!(glint(&cit, &off, false).is_none());
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
assert_eq!(
weights,
[1.0],
"nothing to share: the rule at full strength"
);
// The same stack, asked again without its glint, is not answered from the cache.
assert!(
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
.is_none()
);
}
#[test]
fn a_rule_left_out_of_the_glints_matches_nothing() {
let mut cit = rules(&[
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
("shown", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |cit: &CitRules| -> Vec<String> {
let glint = glint(cit, &sword, false).unwrap();
let stems = glint
.layers
.iter()
.map(|l| stem(cit, Some(l.rule)).unwrap());
stems.map(str::to_owned).collect()
};
// The heavier rule wins the layer, until its texture is missing.
assert_eq!(layers(&cit), ["broken"]);
let broken = (0..cit.rules.len())
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
.unwrap();
cit.keep_glints(|i| i != broken);
assert_eq!(layers(&cit), ["shown"]);
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
// None left: vanilla's glint stays.
cit.keep_glints(|_| false);
assert!(glint(&cit, &sword, false).is_none());
}
#[test]
fn an_off_hand_layer_draws_only_in_the_off_hand() {
let cit = rules(&[
("left", "type=enchantment\nlayer=1\nhand=off"),
("both", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |hand| -> Vec<&str> {
let glint = cit.glint(&sword, hand, 0, true).unwrap();
glint
.layers
.iter()
.map(|l| stem(&cit, Some(l.rule)).unwrap())
.collect()
};
assert_eq!(layers(Hand::Off), ["both", "left"]);
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
assert_eq!(layers(Hand::Main), ["both"]);
}
}
@@ -0,0 +1,319 @@
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
//! names its textures after the armour texture files of before 1.21.2
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
use crate::cit_assets::{rule_sprites, sprite_id};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
/// overlay (`leather_overlay`) or the main texture.
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
// The equipment assets renamed from their old textures; the rest kept their names.
let old = match path {
"turtle_scute" => "turtle",
other => other,
};
let layer = if leggings { 2 } else { 1 };
let suffix = if overlay { "_overlay" } else { "" };
format!("{old}_layer_{layer}{suffix}")
}
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
/// else its `texture`.
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
rule.textures
.iter()
.find(|(name, _)| name == layer)
.map(|(_, path)| path.as_str())
.or(rule.texture.as_deref())
}
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
/// vanilla's sprites.
fn atlas_name(resource: &str) -> Option<String> {
let id = sprite_id(resource)?;
Some(match id.strip_prefix("minecraft:") {
Some(name) => name.to_owned(),
None => id,
})
}
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
atlas_name(armor_texture(
rule,
&armor_layer_name(asset, leggings, overlay),
)?)
}
/// The atlas sprite an elytra rule puts on the wings.
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
atlas_name(armor_texture(rule, "elytra")?)
}
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
/// a name the atlas lacks; nothing is allocated without a rule.
pub fn worn_layer_sprite(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
) -> Option<String> {
let leggings = kind == "humanoid_leggings";
let overlay = vanilla.ends_with("_overlay");
layer_sprite(rule?, asset, leggings, overlay)
}
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
/// cape, else the equipment asset's own wings.
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
elytra_sprite(rule?)
}
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
pub fn worn_layer<R>(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
worn_layer_sprite(rule, asset, kind, vanilla)
.and_then(|name| get(&name))
.or_else(|| get(vanilla))
}
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
/// them to the equipment asset's own wings.
pub fn wings<R>(
rule: Option<&Rule>,
cape: Option<&str>,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
wings_sprite(rule)
.and_then(|name| get(&name))
.or_else(|| cape.and_then(&get))
}
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
fn rule(path: &str, text: &str) -> Rule {
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
}
#[test]
fn layers_take_their_old_texture_names() {
assert_eq!(
armor_layer_name("minecraft:diamond", false, false),
"diamond_layer_1"
);
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
assert_eq!(
armor_layer_name("minecraft:leather", false, true),
"leather_layer_1_overlay"
);
assert_eq!(
armor_layer_name("minecraft:leather", true, true),
"leather_layer_2_overlay"
);
assert_eq!(
armor_layer_name("minecraft:gold", false, false),
"gold_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:chainmail", true, false),
"chainmail_layer_2"
);
assert_eq!(
armor_layer_name("minecraft:turtle_scute", false, false),
"turtle_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:netherite", true, false),
"netherite_layer_2"
);
}
#[test]
fn a_named_layer_wins_over_the_generic_texture() {
let r = rule(
"assets/minecraft/optifine/cit/armor/blue.properties",
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
texture.diamond_layer_2=blue_legs\n",
);
let dir = "assets/minecraft/optifine/cit/armor";
assert_eq!(
armor_texture(&r, "diamond_layer_2"),
Some(format!("{dir}/blue_legs.png").as_str())
);
assert_eq!(
armor_texture(&r, "diamond_layer_1"),
Some(format!("{dir}/generic.png").as_str())
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
);
}
#[test]
fn without_a_generic_texture_other_layers_stay_vanilla() {
let r = rule(
"assets/minecraft/optifine/cit/a.properties",
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
);
assert_eq!(
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/dyed")
);
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
}
#[test]
fn a_vanilla_texture_path_keeps_its_atlas_name() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
);
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
}
#[test]
fn worn_layers_pick_the_rules_texture_for_their_layer() {
let r = rule(
"assets/minecraft/optifine/cit/dye.properties",
"type=armor\nitems=leather_chestplate leather_leggings\n\
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
texture.leather_layer_2=legs\n",
);
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
let pick = |kind: &str, vanilla: &str| {
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
};
let body = "entity/equipment/humanoid/leather";
assert_eq!(pick("humanoid", body), cit("body"));
assert_eq!(
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
cit("body_dye")
);
assert_eq!(
pick(
"humanoid_leggings",
"entity/equipment/humanoid_leggings/leather"
),
cit("legs")
);
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
assert_eq!(pick("humanoid_leggings", legs_dye), None);
// No rule draws vanilla's.
assert_eq!(
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
None
);
}
#[test]
fn wings_take_the_rules_texture() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
);
assert_eq!(
wings_sprite(Some(&r)).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/wings")
);
assert_eq!(wings_sprite(None), None);
}
#[test]
fn only_armor_and_elytra_rules_reach_the_atlas() {
let rules = [
rule(
"assets/minecraft/optifine/cit/sword.properties",
"items=diamond_sword\ntexture=sword\n",
),
rule(
"assets/minecraft/optifine/cit/plate.properties",
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
texture.iron_layer_2=legs\n",
),
rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
),
rule(
"assets/minecraft/optifine/cit/glint.properties",
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
),
];
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
assert_eq!(
got,
[
"horizon_cit:minecraft/optifine/cit/legs",
"horizon_cit:minecraft/optifine/cit/plate",
"horizon_cit:minecraft/optifine/cit/wings",
]
);
}
/// An atlas lookup that knows `names`.
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
}
#[test]
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
let iron = rule(
"assets/minecraft/optifine/cit/iron.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
);
let plate = "horizon_cit:minecraft/optifine/cit/plate";
let vanilla = "entity/equipment/humanoid/iron";
let layer = |rule: Option<&Rule>, names: &[&str]| {
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
};
assert_eq!(
layer(Some(&iron), &[plate, vanilla]).as_deref(),
Some(plate)
);
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
let elytra = rule(
"assets/minecraft/optifine/cit/w.properties",
"type=elytra\ntexture=wings",
);
let w = "horizon_cit:minecraft/optifine/cit/wings";
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
assert_eq!(wings(None, None, atlas(&[w])), None);
}
}
+184 -6
View File
@@ -5,8 +5,11 @@
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
//! `models/`) keeps its vanilla id, so it is not packed twice.
use horizon_assets::model::{CuboidModel, TextureSlot};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
/// The namespace of CIT files outside `textures/` and `models/`.
pub const NAMESPACE: &str = "horizon_cit";
@@ -55,12 +58,11 @@ pub fn resource(id: &str, ext: &str) -> Option<String> {
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
/// a warning.
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
/// values. A path no id can name is skipped with a warning.
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
for rule in rules.iter().filter(|r| keep(r.kind)) {
let named = rule.textures.iter().map(|(_, path)| path);
for path in rule.texture.iter().chain(named) {
match sprite_id(path) {
@@ -77,6 +79,122 @@ pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
out
}
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
/// JSON models are [`model_sprites`].
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| kind == Kind::Item)
}
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
/// that would leave the root.
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
let mut rest = value
.strip_prefix("./")
.or_else(|| value.starts_with("../").then_some(value))?;
let mut dir = file.rsplit_once('/')?.0;
while let Some(up) = rest.strip_prefix("../") {
dir = dir.rsplit_once('/')?.0;
rest = up;
}
let rest = rest
.strip_suffix(ext)
.and_then(|r| r.strip_suffix('.'))
.unwrap_or(rest);
let path = format!("{dir}/{rest}.{ext}");
horizon_optifine::paths::valid(&path).then_some(path)
}
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
let id = if ext == "png" {
sprite_id(&path)
} else {
model_id(&path)
}?;
Some((id, path))
}
/// Parents followed from a rule's model before the rest are left to the library.
const MAX_PARENTS: usize = 16;
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
/// with the parents they reach by relative path. Their `./x` texture and parent values become
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
/// library already. A file that does not read or parse is left out, and a rule's layer that
/// names it (or a model whose parents reach it) keeps its vanilla model.
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let mut seen = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.models.iter().map(|(_, path)| path);
for path in rule.model.iter().chain(named) {
load_model(root, path, 0, &mut seen, &mut out);
}
}
out
}
fn load_model(
root: &Path,
resource: &str,
depth: usize,
seen: &mut BTreeSet<String>,
out: &mut BTreeMap<String, CuboidModel>,
) {
let Some(id) = model_id(resource) else {
log::warn!("custom item textures: {resource} is not a valid model id");
return;
};
let ours = id
.strip_prefix(NAMESPACE)
.is_some_and(|r| r.starts_with(':'));
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
return;
}
let model = fs::read_to_string(root.join(resource))
.map_err(|e| e.to_string())
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
let mut model = match model {
Ok(model) => model,
Err(e) => {
log::warn!("custom item textures: skipped model {resource}: {e}");
return;
}
};
for slot in model.textures.values_mut() {
if let TextureSlot::Material(m) = slot
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
{
m.sprite = id;
}
}
let parent = model
.parent
.as_deref()
.and_then(|p| rebase(resource, p, "json"));
if let Some((id, path)) = parent {
model.parent = Some(id);
load_model(root, &path, depth + 1, seen, out);
}
out.insert(id, model);
}
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
let prefix = format!("{NAMESPACE}:");
models
.values()
.flat_map(|m| m.textures.values())
.filter_map(|slot| match slot {
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -161,4 +279,64 @@ mod tests {
root.join("assets/minecraft/textures/item/stick.png")
);
}
#[test]
fn rule_models_load_with_their_relative_textures_and_parents() {
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(cit.join("swords")).unwrap();
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
write(
"swords/flame.json",
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
);
write(
"base.json",
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
);
let props =
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
root.join(p).is_file()
})
.unwrap();
let models = load_models(&root, &[rule]);
let _ = fs::remove_dir_all(&root);
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
assert_eq!(
models.keys().cloned().collect::<Vec<_>>(),
[cit("base"), cit("swords/flame")]
);
let flame = &models[&cit("swords/flame")];
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
assert_eq!(
models[&cit("base")].parent.as_deref(),
Some("minecraft:item/handheld")
);
assert_eq!(
model_sprites(&models).into_iter().collect::<Vec<_>>(),
[cit("glow"), cit("swords/flame")]
);
}
#[test]
fn relative_values_stay_in_the_root() {
let file = "assets/minecraft/optifine/cit/a/m.json";
let at = |v: &str| relative(file, v, "png");
assert_eq!(
at("./x").as_deref(),
Some("assets/minecraft/optifine/cit/a/x.png")
);
assert_eq!(
at("./../x.png").as_deref(),
Some("assets/minecraft/optifine/cit/x.png")
);
assert_eq!(
at("../../x").as_deref(),
Some("assets/minecraft/optifine/x.png")
);
assert_eq!(at("item/x"), None);
assert_eq!(at("./../../../../../x"), None);
assert_eq!(at("./a/../x"), None);
}
}
@@ -0,0 +1,386 @@
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
//!
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
//! cycle's fading follow it, except where noted.
pub use horizon_optifine::cit::{Global, Method};
use crate::cit::read_properties;
use horizon_optifine::cit::Rule;
use std::path::Path;
/// The pack's global `cit.properties` under the overlay resource root: the first of
/// `Global::PATHS` that exists, else the defaults.
pub fn load_global(root: &Path) -> Global {
Global::PATHS
.iter()
.find_map(|path| read_properties(&root.join(path)))
.map(|props| Global::parse(&props))
.unwrap_or_default()
}
/// One glint layer a stack draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlintLayer {
/// The rule, an index into `CitRules::rules`.
pub rule: usize,
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
pub weight: f32,
/// Its `duration`, for `cycle`.
duration: f32,
}
/// What a stack's glint is made of, worked out when the stack changes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackGlint {
/// Bottom first (`layer` ascending).
pub layers: Vec<GlintLayer>,
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
pub vanilla: bool,
/// `fade` when the layers take turns (`method=cycle`).
cycle_fade: Option<f32>,
}
impl StackGlint {
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
/// (`ItemStack::crafting_enchantments`).
pub fn new(
rules: &[Rule],
matched: &[usize],
enchantments: &[(impl AsRef<str>, u32)],
global: &Global,
) -> Self {
// The highest priority rule of each layer.
let mut layers: Vec<usize> = Vec::new();
for &i in matched {
let layer = rules[i].glint.layer;
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
layers.push(i);
}
}
layers.sort_by_key(|&i| rules[i].glint.layer);
let cycle = global.method == Method::Cycle;
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
// the bottom layers, though, and caps cycles as well).
if let Some(cap) = global.cap.filter(|_| !cycle) {
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
layers = layers.split_off(layers.len().saturating_sub(cap));
}
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
let vanilla = global.use_glint
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
// CIT Resewn: shares are against every enchantment of the stack, not only those the
// layers name.
let levels = enchantments.iter().map(|(_, level)| *level as f32);
let total = match global.method {
Method::Average => levels.sum::<f32>(),
Method::Layered => levels.fold(0.0, f32::max),
Method::None | Method::Cycle => 0.0,
};
let shared = matches!(global.method, Method::Average | Method::Layered);
let layers = layers
.into_iter()
.map(|i| {
let rule = &rules[i];
let named = &rule.enchantments;
let weight = if !shared || named.is_empty() {
1.0
} else {
// The highest level among the enchantments the rule names whose level also
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
// stack has, whatever its level).
let in_range = |level: u32| {
let level = i32::try_from(level).unwrap_or(i32::MAX);
rule.enchantment_levels
.as_ref()
.is_none_or(|r| r.contains(level))
};
let level = enchantments
.iter()
.filter(|(id, level)| {
let id: &str = id.as_ref();
named.iter().any(|n| n == id) && in_range(*level)
})
.map(|(_, level)| *level as f32)
.fold(0.0, f32::max);
if total > 0.0 { level / total } else { 0.0 }
};
GlintLayer {
rule: i,
weight,
duration: rule.glint.duration,
}
})
.collect();
Self {
layers,
vanilla,
cycle_fade: cycle.then_some(global.fade),
}
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
/// its turn.
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
let layer = self.layers[index];
match self.cycle_fade {
None => layer.weight,
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
}
}
}
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
/// fades into the next over `fade` seconds, the last into the first. With no time at all
/// (every `duration` and `fade` 0) the bottom layer shows alone.
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
let n = layers.len();
let fade = f64::from(fade);
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
if n == 1 || period <= 0.0 {
return if index == 0 { 1.0 } else { 0.0 };
}
let mut t = seconds.rem_euclid(period);
for (i, layer) in layers.iter().enumerate() {
let duration = f64::from(layer.duration);
if t < duration {
return if i == index { 1.0 } else { 0.0 };
}
t -= duration;
// Defensive: the last fade also takes anything rounding might leave of the period
// (none seen), ending fully on the first layer.
if t < fade || i + 1 == n {
let into = if fade > 0.0 {
(t / fade).min(1.0) as f32
} else {
1.0
};
return if i == index {
1.0 - into
} else if (i + 1) % n == index {
into
} else {
0.0
};
}
t -= fade;
}
// Not reached, as the last layer returns above; no layer rather than a panic.
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
const DIR: &str = "assets/minecraft/optifine/cit";
/// An enchantment rule named `name` with `text` after `type` and `texture`.
fn rule(name: &str, text: &str) -> Rule {
let text = format!("type=enchantment\ntexture=glint\n{text}");
let path = format!("{DIR}/{name}.properties");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
fn global(text: &str) -> Global {
Global::parse(&Properties::parse_equals_first(text))
}
fn rules_of(layers: &StackGlint) -> Vec<usize> {
layers.layers.iter().map(|l| l.rule).collect()
}
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
#[test]
fn one_rule_per_layer_bottom_first() {
let rules = [
rule("a", "layer=2"),
rule("b", "layer=0"),
rule("c", "layer=2"),
rule("d", "layer=-1"),
];
// Priority order a, b, c, d: c loses layer 2 to a.
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [3, 1, 0]);
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [2]);
}
#[test]
fn average_and_layered_shares() {
let rules = [
rule("sharp", "enchantments=sharpness smite"),
rule("unb", "layer=1\nenchantments=unbreaking"),
rule("any", "layer=2"),
rule("fire", "layer=3\nenchantments=fire_aspect"),
];
let weights = |method: &str| {
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
};
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
// A rule naming no enchantment is at full strength, one the stack lacks at none.
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
assert_eq!(weights("method=cycle"), [1.0; 4]);
assert_eq!(weights("method=none"), [1.0; 4]);
}
#[test]
fn the_level_is_one_the_rules_levels_allow() {
let rules = [rule(
"holy",
"enchantments=sharpness smite\nenchantmentLevels=1-2",
)];
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
}
#[test]
fn cap_keeps_the_topmost_layers() {
let rules = [
rule("a", "layer=0"),
rule("b", "layer=1"),
rule("c", "layer=2"),
];
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
assert_eq!(rules_of(&g), [1, 2]);
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
assert!(g.is_empty());
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
assert_eq!(rules_of(&g), [2]);
// Cycles show every layer in turn.
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
assert_eq!(rules_of(&g), [0, 1, 2]);
}
#[test]
fn vanilla_glint() {
let plain = rule("plain", "");
let keep = rule("keep", "layer=1\nuseGlint=true");
let rules = [plain, keep];
let vanilla = |matched: &[usize], text: &str| {
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
};
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
assert!(!vanilla(&[0], ""), "a rule replaces it");
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
assert!(!vanilla(&[], "useGlint=false"));
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
// Only the layers `cap` keeps count.
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
assert!(!g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
assert!(g.is_empty() && g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
assert!(!g.vanilla);
}
#[test]
fn cycles_take_turns_and_fade() {
let rules = [
rule("a", "duration=2"),
rule("b", "layer=1\nduration=1"),
rule("c", "layer=2\nduration=1"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.5"),
);
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
// One layer always shows.
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
assert_eq!(one.intensity(0, 0.7), 1.0);
}
#[test]
fn a_cycle_always_shows_a_whole_layer() {
let rules = [
rule("a", "duration=0.1"),
rule("b", "layer=1\nduration=0.7"),
rule("c", "layer=2\nduration=0.2"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.3"),
);
// Every millisecond of the first two seconds, and just under a period, the last fade's
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
// defensive.)
let period: f64 = [0.1f32, 0.7, 0.2]
.map(|d| f64::from(d) + f64::from(0.3f32))
.iter()
.sum();
let last = f64::from_bits(period.to_bits() - 1);
for t in (0..2000_i32)
.map(|i| f64::from(i) * 0.001)
.chain([last, period + last])
{
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
}
// No time at all: the bottom layer alone, never NaN.
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
for t in [0.0, 0.5, 7.25] {
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
}
}
#[test]
fn the_first_global_file_wins() {
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(load_global(&root), Global::default());
let write = |path: &str, text: &[u8]| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, text).unwrap();
};
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
write(
"assets/minecraft/optifine/cit/cit.properties",
b"# caf\xE9\nmethod=cycle",
);
assert_eq!(load_global(&root).method, Method::Cycle);
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
assert_eq!(load_global(&root).method, Method::Cycle);
write(
"assets/minecraft/optifine/cit.properties",
b"method=layered",
);
assert_eq!(load_global(&root).method, Method::Layered);
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
let g = load_global(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
}
}
+192 -80
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -150,9 +197,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +320,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +361,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+219 -8
View File
@@ -182,12 +182,24 @@ where
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = CollisionContext::position(bounds.min_position().y);
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// [`Self::resolve`] with the entity's captured collision context.
pub fn resolve_with_context(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
context: CollisionContext<'_>,
) -> Result<Vec3> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
context,
bounds,
movement,
max_step,
@@ -211,6 +223,25 @@ impl SceneCollisions<'_, '_> {
) -> Result<Vec3> {
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
}
/// [`Self::resolve`], also stopped by the world border's walls when one is
/// given with the moving entity's x and z: `Entity.collectColliders` adds
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
pub fn resolve_in_border(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
/// also affect contextual block shapes (for example powder snow).
@@ -296,6 +327,88 @@ where
)
.unwrap_or(false)
}
impl SceneCollisions<'_, '_> {
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
/// building one: no block that can collide in its inner cells, and no block that
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
/// the scan decides). The same cells, blocks and section flags the scan reads.
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
})
}
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
@@ -309,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
Ok(self.section_reaches_out(chunk, section_y))
}
fn is_suffocating(
&mut self,
@@ -617,6 +724,25 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
@@ -769,3 +895,88 @@ mod shulker_collision_tests {
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
#[cfg(test)]
mod section_tests {
use super::*;
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
/// is left to it. Needs the prepared resources.
#[test]
fn cells_without_collision_leave_the_movement_as_resolving_does() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let state = |name: &str| assets.registry.default_state(name).unwrap();
// Torches, and a fence at the bottom of a section above air.
for x in 17..30 {
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
}
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
let scene = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
assert!(scene.cells_without_collision(torches));
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
assert!(scene.cells_without_collision(over_floor));
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut skipped, mut resolved) = (0, 0);
for i in 0..4000 {
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
let d = Vec3::new(
(next() - 0.5) * reach,
(next() - 0.5) * reach,
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
let mut full = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
let moved = full.resolve(bb, d, 0.0, false).unwrap();
if fast {
skipped += 1;
assert_eq!(
moved.components().map(f64::to_bits),
d.components().map(f64::to_bits),
"{bb:?} by {d:?}"
);
} else {
resolved += 1;
}
}
assert!(
skipped > 500 && resolved > 500,
"{skipped} skipped, {resolved} resolved"
);
}
}
+195 -9
View File
@@ -39,6 +39,8 @@ pub enum Pick {
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
Nothing,
}
/// A tile drawn over a face, after its own (`overlay*` methods).
@@ -387,7 +389,8 @@ impl ConnectedTextures {
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
/// with `orient=texture`.
pub fn pick<'b>(
&self,
state: u32,
@@ -396,6 +399,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
texture: impl Fn() -> Option<Axes>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
@@ -427,9 +431,58 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) =
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
&& let Some(pick) = self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
};
if picked == layer {
return pick;
}
let own = self.list(&self.by_state, state as usize);
// Rules tried on the face's sprite already (those before this one) are not tried
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
j != i
&& !(j < i && first.contains(&j))
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
});
for (_, r) in again {
// Joins by tile still compare the face's own sprite with its neighbours'.
match self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
return pick;
}
}
@@ -447,6 +500,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
let rule = &r.rule;
@@ -487,7 +541,12 @@ impl ConnectedTextures {
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
// The quad's own texture directions, whatever face `symmetry` stands it for.
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
// the standard way.
(Orient::Texture, _) if rule.method != Method::Overlay => {
texture().unwrap_or(Axes::cube(face))
}
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
@@ -649,6 +708,10 @@ mod tests {
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
_ => None,
},
)
@@ -671,7 +734,7 @@ mod tests {
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -794,8 +857,26 @@ mod tests {
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
let lower = ctm.pick(
5,
3,
Face::North,
[x, 64, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
let upper = ctm.pick(
6,
3,
Face::North,
[x, 65, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
@@ -809,7 +890,16 @@ mod tests {
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at = |y, biome: &'static str| {
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
ctm.pick(
1,
3,
Face::Up,
[0, y, 0],
|_| 0,
|| Some(biome),
|| None,
|_| {},
)
};
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
@@ -844,7 +934,7 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
@@ -925,6 +1015,7 @@ mod tests {
[0, 64, 0],
at,
|| None,
|| None,
|o| out.push(o.layer),
);
(out, pick)
@@ -962,6 +1053,7 @@ mod tests {
[0, 64, 0],
|o| if o == [0, 0, -1] { 1 } else { 0 },
|| None,
|| None,
|o| {
tint = Some((o.tint, o.translucent));
},
@@ -999,6 +1091,7 @@ mod tests {
[0, 64, 0],
door_north,
|| None,
|| None,
|o| got.push(o),
);
assert_eq!(
@@ -1039,6 +1132,99 @@ mod tests {
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn a_picked_tile_can_be_picked_again() {
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
// names that tile, so it draws in b0's place.
let chain = |t: &str| {
world(&[
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
("t.properties", t),
])
};
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
// A chained rule keeping the tile (<default>) leaves b's.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// One that names another block doesn't chain on glass.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// An overlay tried on the face's own sprite and on the picked tile draws once.
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
// tried: it chains.
let ctm = world(&[
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
(
"c.properties",
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
),
]);
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
world(&[(
"glass.properties",
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
)])
};
// A mirrored north face (a vanilla pane's): its texture's right is east.
let mirrored = || {
Some(Axes {
right: [1, 0, 0],
up: [0, 1, 0],
})
};
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
let at = |ctm: &ConnectedTextures| {
ctm.pick(
1,
3,
Face::North,
[0, 64, 0],
east,
|| None,
mirrored,
|_| {},
)
};
// Glass to the east is to the texture's right: the row's left end.
assert_eq!(at(&rule("texture")), tile(1000));
// By the cube's directions it is to the left: the row's right end.
assert_eq!(at(&rule("none")), tile(1002));
// A plain overlay keeps the standard directions: glass north of a top face is above it
// (tile 15) whatever the quad's texture says.
let overlay = world(&[(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
)]);
let turned = || {
Some(Axes {
right: [0, 0, 1],
up: [1, 0, 0],
})
};
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
let mut got = Vec::new();
overlay.pick(
2,
7,
Face::Up,
[0, 64, 0],
north,
|| None,
turned,
|o| got.push(o.layer),
);
assert_eq!(got, [1015]);
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
@@ -389,6 +389,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -729,6 +732,20 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+5 -1
View File
@@ -121,7 +121,11 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs = Vec::new();
+43 -6
View File
@@ -31,6 +31,9 @@ struct Globals {
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -69,6 +72,8 @@ struct Out {
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -131,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
@@ -328,8 +345,16 @@ fn shadow_color(in: Out) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if in.tier == 0u || texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -348,7 +373,15 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 1.0);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -375,8 +408,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
let added = color.rgb * color.a;
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
// none (the End sky, the End flash) stays brightest.
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
let tier = select(in.tier, 4u, in.tier == 0u);
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
+62 -5
View File
@@ -120,10 +120,24 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: 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,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
/// on by default as vanilla); off, the wheel only scrolls the list.
pub wheel_cycles_settings: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -160,8 +174,13 @@ impl Default for Extras {
chat_indicator: true,
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
}
}
}
@@ -240,8 +259,13 @@ impl Extras {
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
_ => {}
}
}
@@ -249,7 +273,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:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\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:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -287,8 +311,13 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -402,6 +431,10 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -425,8 +458,11 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -451,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
]
}
impl Item {
@@ -469,10 +507,12 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -490,10 +530,12 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -522,8 +564,10 @@ impl Item {
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -535,8 +579,8 @@ impl Item {
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings => {
return None;
}
})
@@ -588,6 +632,12 @@ impl Item {
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -603,7 +653,8 @@ impl Item {
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
| Item::Splitscreen
| Item::WheelCyclesSettings => Some(Cost::None),
_ => None,
}
}
@@ -623,8 +674,10 @@ impl Item {
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
_ => return None,
})
}
@@ -655,8 +708,10 @@ impl Item {
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::SortKey => {}
}
}
@@ -680,7 +735,7 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -721,6 +776,8 @@ mod tests {
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+10
View File
@@ -947,6 +947,16 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
+687
View File
@@ -0,0 +1,687 @@
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
//!
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
//! turned about x, or turned with `uvlock`, join a quarter turn off.
//!
//! The format is `horizon_optifine::fusion`'s.
use crate::connected::{Overlay, Pick, TileLayer};
use crate::models::StateModels;
use crate::sprites::Sprites;
use horizon_optifine::cem::json::Json;
use horizon_optifine::ctm::connect::{self, Axes, Face};
use horizon_optifine::fusion::{
Connection, Layout, Tiles,
model::{Connections, ModelType},
predicate::Query,
texture::{Kind, Meta},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
layout: Layout,
tiles: Vec<Option<TileLayer>>,
}
/// A sheet and what it joins by, blocks as sorted state ids.
struct Rule {
sheet: usize,
connection: Connection<Vec<u32>>,
}
#[derive(Default)]
pub struct FusionTextures {
sheets: Vec<Sheet>,
/// By [`crate::models::BakedFace::fusion`].
rules: Vec<Rule>,
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
blocks: Vec<u32>,
}
/// A model's reference to a texture or another model (`block/glass`), namespaced.
fn namespaced(reference: &str) -> String {
horizon_optifine::matchers::namespaced(reference)
}
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{ns}/models/{path}.json")),
)
.ok()?;
Json::parse(&text).ok()
}
/// What a model's face draws and joins by: its sprite, its side in the model, and its
/// predicates (`None`: the texture's own).
type ModelFace = (String, Face, Option<Connection>);
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
let Some(model) = read_model(root, name) else {
return Vec::new();
};
if ModelType::of(&model) != Some(ModelType::Connecting) {
return Vec::new();
}
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
// levels is a loop.
let mut chain = vec![model];
while chain.len() < 16
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
&& let Some(parent) = read_model(root, &namespaced(parent))
{
chain.push(parent);
}
// Parents first, so a child's keys replace theirs.
let mut connections = Connections::Textures;
let mut textures: HashMap<&str, &str> = HashMap::new();
for model in chain.iter().rev() {
match Connections::of(model) {
Ok(own) => connections = own.inherit(&connections),
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
}
for (key, value) in model
.get("textures")
.and_then(Json::as_object)
.unwrap_or(&[])
{
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
if let Some(value) = value {
textures.insert(key.as_str(), value);
}
}
}
// A `#key` as vanilla resolves it.
let resolve = |reference: &str| {
let mut value = reference;
for _ in 0..16 {
match value.strip_prefix('#') {
Some(key) => value = textures.get(key)?,
None => return Some(namespaced(value)),
}
}
None
};
// The elements are the nearest model's that has some.
let elements = chain
.iter()
.find_map(|m| m.get("elements")?.as_array())
.unwrap_or(&[]);
let mut out: Vec<ModelFace> = Vec::new();
let faces = elements
.iter()
.filter_map(|e| e.get("faces")?.as_object())
.flatten();
for (side, face) in faces {
let side = match side.as_str() {
"down" => Face::Down,
"up" => Face::Up,
"north" => Face::North,
"south" => Face::South,
"west" => Face::West,
"east" => Face::East,
_ => continue,
};
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
continue;
};
let Some(sprite) = resolve(texture) else {
continue;
};
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
let key = face.get("connections").and_then(Json::as_str);
out.push((sprite, side, connections.for_face(key, texture).cloned()));
}
}
out
}
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
let mut n = face.normal();
for _ in 0..x % 4 {
// x=90 takes up to north.
n = [n[0], n[2], -n[1]];
}
for _ in 0..y % 4 {
// y=90 takes north to east.
n = [-n[2], n[1], n[0]];
}
Face::ALL
.into_iter()
.find(|f| f.normal() == n)
.expect("a quarter turn maps faces onto faces")
}
/// The connecting textures under `root`, by sprite name.
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let mut out = HashMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
out
}
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
fn sheet(
root: &Path,
sprites: &mut Sprites,
name: &str,
meta: Meta,
) -> Option<(Sheet, Connection)> {
let Kind::Connecting {
layout,
connections,
per_tile_animation,
..
} = meta.kind
else {
return None;
};
let tiles = sprites
.insert_tiles(root, name, layout.grid(), per_tile_animation)
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
.ok()?;
// A layer past the mesher's `u16` leaves the sheet to vanilla.
let tiles = tiles
.into_iter()
.map(|t| match t {
None => Some(None),
Some(l) => Some(Some(TileLayer::Layer {
layer: u16::try_from(l).ok()?,
translucent: sprites.translucent[l as usize],
opaque: sprites.opaque[l as usize],
})),
})
.collect::<Option<Vec<_>>>()?;
Some((Sheet { layout, tiles }, connections))
}
impl FusionTextures {
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
/// shows one gets its rule.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
catalog: &mut StateModels,
) -> Self {
let mut textures = connecting_textures(root);
if textures.is_empty() {
return Self::default();
}
// The layers models show the sheets as: only their models are read.
let shown: std::collections::HashSet<u32> = textures
.keys()
.filter_map(|name| sprites.layers.get(name).copied())
.collect();
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
let mut rules: Vec<(usize, Connection)> = Vec::new();
for (variant, model) in catalog.models_mut() {
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
continue;
}
let found = models
.entry(namespaced(&variant.model))
.or_insert_with_key(|name| connecting_model(root, name));
for face in &mut model.faces {
let sprite = &face.quad.material.sprite;
// The model's face on the side this one turned to, else any with its sprite.
let side = Face::ALL[face.quad.facing as usize];
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
let Some((_, _, connection)) = drawing
.clone()
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
.or_else(|| drawing.next())
else {
continue;
};
// The sheet, cut into tiles on first use.
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
let meta = textures.remove(sprite)?;
sheets.push(sheet(root, sprites, sprite, meta)?);
Some(sheets.len() - 1)
});
let Some(sheet) = sheet else {
continue;
};
let connection = connection
.clone()
.unwrap_or_else(|| sheets[sheet].1.clone());
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
Some(rule) => rule,
None => {
rules.push((sheet, connection));
rules.len() - 1
}
};
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
}
}
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
Self::build(sheets, rules, states)
}
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
fn build<'a>(
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
) -> Self {
let states: Vec<_> = states.collect();
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
let mut names = HashMap::new();
let blocks = (0..states.len() as u32)
.map(|id| match states[id as usize] {
Some((name, _)) => {
block_states.entry(name).or_default().push(id);
let next = names.len() as u32;
*names.entry(name).or_insert(next)
}
None => u32::MAX,
})
.collect();
// Block lists to the states they match.
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
let mut ids: Vec<u32> = list
.iter()
.flat_map(|m| {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
candidates.iter().copied().filter(|&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
})
})
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let rules = rules
.into_iter()
.map(|(sheet, connection)| Rule {
sheet,
connection: connection.map_blocks(&mut resolve),
})
.collect();
Self {
sheets,
rules,
blocks,
}
}
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
/// pieces, tinted as the quad, go to `overlay`.
#[allow(clippy::too_many_arguments)]
pub fn pick(
&self,
rule: NonZeroU16,
state: u32,
face: Face,
tint: i32,
neighbour: impl Fn([i32; 3]) -> u32,
covers: impl Fn(u32) -> bool,
mut overlay: impl FnMut(Overlay),
) -> Option<Pick> {
let rule = self.rules.get(usize::from(rule.get() - 1))?;
let normal = face.normal();
let offsets = connect::offsets(Axes::cube(face));
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
let mask = rule.connection.mask(|bit, query| {
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
match query {
Query::SameBlock => {
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
}
Query::SameState => neighbour([x, y, z]) == state,
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
Query::FaceVisible => !covers(in_front()),
}
});
let sheet = &self.sheets[rule.sheet];
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
// never need.
Some(match sheet.layout.tiles(mask) {
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
Tiles::Quarters(q) => match q.map(tile) {
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
_ => Pick::Nothing,
},
Tiles::Overlay(mask) => {
for t in Layout::overlay_tiles(mask) {
if let Some(TileLayer::Layer {
layer, translucent, ..
}) = tile(t)
{
overlay(Overlay {
layer,
translucent,
tint: (tint >= 0).then_some((tint, None)),
});
}
}
Pick::Nothing
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
let (w, h) = layout.grid();
Sheet {
layout,
tiles: (0..(w * h) as u16)
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
.collect(),
}
}
fn predicate(json: &str) -> Connection {
Connection::parse(&Json::parse(json).unwrap()).unwrap()
}
fn layer(layer: u16) -> TileLayer {
TileLayer::Layer {
layer,
translucent: false,
opaque: false,
}
}
/// The lookups over blocks named `names` (a state each).
fn world(
names: &[&str],
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
) -> FusionTextures {
let none = BlockProperties::default();
let states = names.iter().map(|n| Some((*n, &none)));
FusionTextures::build(sheets, rules, states)
}
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
/// `opaque`; with the overlays it hands out.
fn pick_in(
fusion: &FusionTextures,
rule: u16,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
opaque: &[u32],
) -> (Option<Pick>, Vec<u16>) {
// A face's rule counts from 1.
let rule = NonZeroU16::new(rule + 1).unwrap();
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut overlays = Vec::new();
let pick = fusion.pick(
rule,
state,
face,
-1,
at,
|s| opaque.contains(&s),
|o| overlays.push(o.layer),
);
(pick, overlays)
}
#[test]
fn glass_pieces_join_the_same_block() {
// Glass twice (two states of one block), then stone.
let fusion = world(
&[
"minecraft:air",
"minecraft:glass",
"minecraft:glass",
"minecraft:stone",
],
vec![sheet(Layout::Pieced, 100, &[])],
vec![(0, Connection::SameBlock)],
);
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
// Alone: every quarter the lone tile.
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
// A north face's right is west: glass there (in its other state) joins the right
// quarters, the horizontal piece. Stone doesn't.
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
assert_eq!(
at(&[([-1, 0, 0], 3)]),
Some(Pick::Quarters([layer(100); 4]))
);
// A rule that doesn't exist draws as vanilla.
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
}
#[test]
fn overlays_reach_toward_visible_sand() {
let sand = predicate(
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
);
let fusion = world(
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
vec![sheet(Layout::Overlay, 200, &[7])],
vec![(0, sand)],
);
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
// over a face that draws nothing itself.
let north = [([0, 0, -1], 2)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
(Some(Pick::Nothing), vec![213])
);
// Stone over that sand hides its top: no edge.
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
(Some(Pick::Nothing), vec![])
);
// Sand all round: the ring, (4, 1).
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
.filter(|(o, _)| *o != [0, 0, 0])
.collect();
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
// Its pieces take the quad's tint index.
let mut tints = Vec::new();
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
let rule = NonZeroU16::new(1).unwrap();
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
assert_eq!(tints, [Some((0, None))]);
}
#[test]
fn in_front_and_empty_tiles() {
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
// lone tile empty.
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
let fusion = world(
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
vec![sheet(Layout::Full, 300, &[0])],
vec![(0, front)],
);
// Sand on the block east of the top face: joined right, the full template's (1, 0).
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
Some(Pick::Tile(layer(301)))
);
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
Some(Pick::Nothing)
);
}
#[test]
fn models_name_the_predicates_their_faces_draw() {
let root =
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// A pane arm over a template whose `particle` names the pane sheet but draws no face
// with it (as Fusion Connected Glass's posts do): only the faces count.
fs::write(
models.join("template.json"),
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
)
.unwrap();
fs::write(
models.join("arm.json"),
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
"connections": {"edge": {"type": "true"}},
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
)
.unwrap();
fs::write(
models.join("plain.json"),
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
)
.unwrap();
let arm = connecting_model(&root, "demo:block/arm");
let plain = connecting_model(&root, "demo:block/plain");
fs::remove_dir_all(&root).unwrap();
// `edge` has the model's predicate; `pane` none, so the texture's own.
assert_eq!(
arm,
[
(
"demo:block/edge".to_owned(),
Face::Up,
Some(Connection::Const(true))
),
("demo:block/glass".to_owned(), Face::West, None),
]
);
// Not a Fusion model: vanilla.
assert!(plain.is_empty());
}
#[test]
fn faces_of_one_sprite_join_by_their_own_keys() {
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// The wiki's example: four keys on one texture, two of them joining by `blue`.
fs::write(
models.join("tiles.json"),
r##"{"loader": "fusion:model", "type": "connecting",
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
"connections": {"blue": {"type": "match_block", "block": "ice"},
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
"up": {"texture": "#ice", "connections": "#blue"},
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
)
.unwrap();
let faces = connecting_model(&root, "demo:block/tiles");
fs::remove_dir_all(&root).unwrap();
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
assert_eq!(joins(Face::South), Some(Connection::SameState));
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
// the east.
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
}
}
File diff suppressed because it is too large. Load diff
+215 -22
View File
@@ -4,7 +4,7 @@
//! 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;
use std::collections::{BTreeMap, VecDeque};
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -157,21 +157,24 @@ impl Arena {
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.
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages 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)))
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
} else {
(Self::indices(device, 1 << 16, flat), None)
}
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
}
fn with_usage(
device: &wgpu::Device,
@@ -545,14 +548,17 @@ impl Arena {
old.free.give(previous, capacity - previous);
true
}
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
/// the buffers of the pages it emptied, for a [`Graveyard`].
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
let mut released = Vec::new();
for page in &mut self.pages {
if let Some(p) = page
&& p.free.0.len() == 1
&& p.free.0.get(&0) == Some(&p.capacity)
if page
.as_ref()
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
&& let Some(p) = page.take()
{
*page = None;
released.push(p.buffer);
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
@@ -562,13 +568,67 @@ impl Arena {
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
}
released
}
pub fn clear(&mut self) {
self.pages.clear();
/// Drops every page; gives back their buffers, for a [`Graveyard`].
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
self.closing = None;
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
}
}
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
/// thread holds its last reference.
const GRAVE_FRAMES: usize = 4;
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
pub struct Graveyard<T> {
graves: VecDeque<Vec<T>>,
}
impl<T> Default for Graveyard<T> {
fn default() -> Self {
Self {
graves: VecDeque::new(),
}
}
}
impl<T> Graveyard<T> {
/// Released this frame.
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
if self.graves.is_empty() {
self.graves.push_back(Vec::new());
}
if let Some(grave) = self.graves.back_mut() {
grave.extend(items);
}
}
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
pub fn advance(&mut self) -> Vec<T> {
self.graves.push_back(Vec::new());
if self.graves.len() > GRAVE_FRAMES {
self.graves.pop_front().unwrap_or_default()
} else {
Vec::new()
}
}
}
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
/// unloaded chunks; should the thread not start, they drop here.
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
if items.is_empty() {
return;
}
let _ = std::thread::Builder::new()
.name("Buffer freeing".into())
.spawn(move || drop(items));
}
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
@@ -587,6 +647,24 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
mod tests {
use super::*;
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
#[test]
fn graveyard_frees_after_its_frames() {
let mut graves = Graveyard::default();
graves.add([1, 2]);
let mut freed = Vec::new();
for frame in 0..GRAVE_FRAMES + 2 {
if frame == 1 {
graves.add([3]);
}
freed.push(graves.advance());
}
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
expected[GRAVE_FRAMES - 1] = vec![1, 2];
expected[GRAVE_FRAMES] = vec![3];
assert_eq!(freed, expected);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
device_with(wgpu::Features::empty())
}
@@ -852,6 +930,121 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -953,9 +1146,9 @@ mod tests {
}
/// 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.
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -975,9 +1168,9 @@ mod tests {
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_eq!(storage, flat, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
@@ -0,0 +1,571 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
//! their quads all face an axis, and their shading is baked into the colour.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
impl SkinPart {
/// An item layer's part: its pose, and its quads' colours by axis.
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
let mut normal = [0.0; 9];
for (word, color) in normal.iter_mut().zip(colors) {
*word = f32::from_bits(color);
}
Self {
matrix: matrix.to_cols_array(),
normal,
}
}
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
pub item: u32,
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
pub cull: u32,
/// Always 0: the shader reads it as a zero it cannot fold away.
pub spare: u32,
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
) {
if frame.jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
let (color, normal) = if job.item != 0 {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else {
let w = if job.cull != 0 {
crate::mesher::SHADED_CULL_W
} else {
-127
};
(job.color, [packed[0], packed[1], packed[2], w])
};
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color,
light: job.light,
normal,
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint, and an item's: the shader writes what
/// `expand` does, to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
SkinPart::item(
child * turn,
[
0xff80_4020,
0x7fc0_0001,
0x0000_0001,
0xffff_ffff,
0x7f80_0001,
0x1234_5678,
],
),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
// The bat's encoding: the shader must write -126 like `expand`.
cull: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
SkinJob {
first: 0,
count: 24,
part: 4,
dst: 72,
rect: [0.5, 0.25, 0.5625, 0.3125],
light: 0xa0_30,
overlay: 2 << 16,
item: 1,
..Default::default()
},
],
vertices: 96,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let before = 5u32;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
}
}
@@ -0,0 +1,140 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
// it draws an item's quads (`SkinJob::item`), whether its quads cull back faces
// (`SkinJob::cull`), 1 spare (0: `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
// to it, so each operation below rounds on its own, in the CPU's order.
var<private> keep: u32;
fn r(v: f32) -> f32 {
return bitcast<f32>(bitcast<u32>(v) ^ keep);
}
fn r3(v: vec3<f32>) -> vec3<f32> {
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = r(u + scroll);
return r(s - r(trunc(s)));
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let item = jobs[j + 13u] != 0u;
let cull = jobs[j + 14u] != 0u;
keep = jobs[j + 15u];
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order, each operation rounded.
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
pos = r3(vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos);
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
var vertex_color = color;
var normal = pack(n);
if cull {
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if item {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
out[o + 5u] = overlay;
out[o + 6u] = vertex_color;
out[o + 7u] = light;
out[o + 8u] = normal;
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
+93 -12
View File
@@ -48,6 +48,10 @@ struct Plan {
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
/// Batching): its page plus this. The section arena's pages are their own keys, all far
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
struct PageTable {
/// Section id of every block of the page.
@@ -55,9 +59,19 @@ struct PageTable {
group: Option<wgpu::BindGroup>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::REPLACE,
};
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
/// The page's key (`TRANSLUCENT_KEYS`).
pub page: u32,
pub first: u32,
pub count: u32,
@@ -74,6 +88,7 @@ pub struct Flat {
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub translucent_mark: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
@@ -88,6 +103,9 @@ pub struct Flat {
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
translucent_blocks_per_page: u64,
/// Block tables by page key.
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
next_id: u32,
@@ -208,7 +226,14 @@ fn buffer(
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: usage | wgpu::BufferUsages::COPY_DST,
// Tests read the tables back.
usage: usage
| wgpu::BufferUsages::COPY_DST
| if cfg!(test) {
wgpu::BufferUsages::COPY_SRC
} else {
wgpu::BufferUsages::empty()
},
mapped_at_creation: false,
})
}
@@ -232,6 +257,7 @@ fn plan(
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
@@ -263,6 +289,7 @@ fn plan(
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
let page = key(page, sorted);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -418,6 +445,14 @@ impl Flat {
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
translucent_mark: pipeline(
&layout,
"Terrain translucent HDR mark (flat)",
"vs_terrain_flat",
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
),
flatten,
compute_layout,
flatten_seen,
@@ -433,6 +468,7 @@ impl Flat {
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
free_ids: Vec::new(),
next_id: 0,
@@ -461,8 +497,8 @@ impl Flat {
.max(1),
}
}
/// A section uploaded to `vertices` vertices from `start` of `page`: its
/// id, which its blocks now name (written through `uploads` in `encoder`).
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
/// its id, which its blocks now name (written through `uploads` in `encoder`).
pub fn add(
&mut self,
device: &wgpu::Device,
@@ -476,6 +512,28 @@ impl Flat {
self.next_id += 1;
self.next_id - 1
});
self.place(device, encoder, uploads, id, page, start, vertices);
id
}
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
/// a range that moved.
#[allow(clippy::too_many_arguments)]
pub fn place(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
page: u32,
start: u64,
vertices: u64,
) {
let blocks = if page >= TRANSLUCENT_KEYS {
self.translucent_blocks_per_page
} else {
self.blocks_per_page
};
let page = page as usize;
if self.pages.len() <= page {
self.pages.resize_with(page + 1, || None);
@@ -484,7 +542,7 @@ impl Flat {
blocks: buffer(
device,
"Section ids by block",
self.blocks_per_page * 4,
blocks * 4,
wgpu::BufferUsages::STORAGE,
),
group: None,
@@ -497,7 +555,19 @@ impl Flat {
first * 4,
bytemuck::cast_slice(&ids),
);
id
}
/// The block table of the page keyed `page`, for tests that read it back.
#[cfg(test)]
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("a page table")
.blocks
}
/// The most vertices a page of the translucent arena holds: its block tables' size.
/// Tables made before keep theirs, so this goes with `clear`.
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
@@ -507,7 +577,7 @@ impl Flat {
self.free_ids.clear();
self.next_id = 0;
}
/// Forgets the tables of pages the arena released.
/// Forgets the tables of pages the arenas released (`live`, by page key).
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
for (i, page) in self.pages.iter_mut().enumerate() {
if page.is_some() && !live(i as u32) {
@@ -538,6 +608,7 @@ impl Flat {
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
@@ -591,6 +662,11 @@ impl Flat {
shadow,
passes,
self.range_align,
// Translucent draws, and only they, have sorted indices.
|page, sorted| match sorted {
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
_ => page,
},
&mut self.calls,
&mut self.range_data,
&mut self.segments,
@@ -783,7 +859,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
@@ -793,7 +869,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -810,7 +886,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
@@ -824,7 +900,7 @@ impl Flat {
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -839,13 +915,13 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_vertex_buffer(0, vertices(page));
pass.set_bind_group(1, table, &[]);
}
}
@@ -894,6 +970,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -942,6 +1019,7 @@ mod tests {
[draws.len(); 2],
[3, 1],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -977,6 +1055,7 @@ mod tests {
[camera, camera + 2],
[camera, 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1005,6 +1084,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1122,6 +1202,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
align,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -149,6 +149,9 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -293,6 +296,7 @@ impl Hdr {
tiers: TIERS,
ui: 1.0,
masks: MASKS,
lighting: true,
calibration: None,
}
}
@@ -539,6 +543,11 @@ impl Hdr {
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
@@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -630,6 +645,7 @@ mod tests {
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
include_str!("stars.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
@@ -670,3 +686,18 @@ mod tests {
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
#[cfg(test)]
mod lighting_tests {
use super::scene_gain;
#[test]
fn the_lighting_scales_with_the_headroom() {
// Enough headroom: the full strength, whatever the paper white.
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
// Little headroom, and none.
assert_eq!(scene_gain(600.0, 400.0), 2.625);
assert_eq!(scene_gain(200.0, 480.0), 1.0);
}
}
+24 -4
View File
@@ -7,13 +7,19 @@ use crate::{
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
use std::time::{Duration, Instant};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
/// once every mesh is in) and the columns they leave out.
#[derive(Default)]
pub(super) struct Pending {
meshes: FxHashMap<TileKey, SectionMesh>,
/// The order they go up in: as handed over (`Lod::update`: finer tiles
/// first, so no tile clipped around a finer one is drawn before it).
order: VecDeque<TileKey>,
removed: FxHashSet<TileKey>,
cutout: Option<FxHashSet<(i32, i32)>>,
}
@@ -30,7 +36,9 @@ impl Pending {
) {
for (key, mesh) in meshes {
self.removed.remove(&key);
self.meshes.insert(key, mesh);
if self.meshes.insert(key, mesh).is_none() {
self.order.push_back(key);
}
}
for key in removed {
self.meshes.remove(&key);
@@ -330,7 +338,10 @@ impl Renderer {
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
// Keys meshed again keep their place; removed ones are skipped.
let next = std::iter::from_fn(|| pending.order.pop_front())
.find(|key| pending.meshes.contains_key(key));
let Some(key) = next else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
@@ -498,7 +509,16 @@ mod tests {
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
removed.sort_unstable();
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
// They go up in the order handed over, each once.
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
let order: Vec<_> = pending
.order
.iter()
.filter(|k| pending.meshes.contains_key(k))
.map(|k| k.x)
.collect();
assert_eq!(order, [1, 3]);
}
#[test]
@@ -39,6 +39,8 @@ struct HorizonMaterialOut {
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
// terrain.wgsl's `emission`, for HDR output's mark.
@location(11) @interpolate(flat) hdr_code: u32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.hdr_code = 15u - (flags & 15u);
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
let y = horizon_terrain_brightness(texel.rgb);
if whole || y >= t.r {
return horizon_terrain_hdr_mark(tier, y);
}
}
return 1.0;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@@ -21,6 +21,10 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -133,6 +137,8 @@ impl NativePack {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
light_colors: report.manifest.lights.clone(),
frame,
sky,
material,
@@ -6,7 +6,23 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
/// Install trait mod renderers in dispatch order, once per process.
pub fn install(
renderers: Box<[&'static dyn RenderHooks]>,
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
RENDERERS.set(renderers)
}
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
@@ -18,12 +34,16 @@ pub fn set_mods_built_in(on: bool) {
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
|| RENDERERS
.get()
.is_some_and(|renderers| !renderers.is_empty())
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
@@ -59,6 +79,7 @@ pub struct WorldDrawn<'a> {
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
@@ -70,13 +91,13 @@ pub struct RenderNeeds {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
}
/// What the mods need this frame ([`native_render_needs`]).
@@ -85,3 +106,34 @@ pub fn render_needs() -> RenderNeeds {
native_render_needs(&mut needs);
needs
}
#[cfg(test)]
mod tests {
use super::*;
struct Depth;
impl RenderHooks for Depth {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.world_depth = true;
}
}
struct Spans;
impl RenderHooks for Spans {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.entity_spans = event.needs.world_depth;
}
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
assert_eq!(
needs,
RenderNeeds {
world_depth: true,
entity_spans: true
}
);
}
}
@@ -0,0 +1,36 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
pub sprite: u32,
/// Layers per sprite array.
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
}
impl PackAtlas {
@@ -57,6 +59,7 @@ impl PackAtlas {
columns,
sprite,
split,
separate_ao: false,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
@@ -179,7 +182,12 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
"hz_v_color.a"
} else {
"1.0"
};
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
@@ -189,7 +197,7 @@ impl PackAtlas {
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -313,4 +321,50 @@ mod tests {
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let mut atlas = PackAtlas {
texture,
view,
columns: 1,
sprite: 16,
split: 1,
separate_ao: false,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
inputs_default
.body
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
);
atlas.separate_ao = true;
let inputs_separate = atlas.terrain_inputs(false);
assert!(
inputs_separate
.body
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
);
}
}
@@ -319,6 +319,7 @@ impl PackEntities {
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
@@ -330,7 +331,9 @@ impl PackEntities {
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|_| plain(run));
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
@@ -341,6 +344,9 @@ impl PackEntities {
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const PROGRAMS: usize = 4;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else if span.stage == render_stage("DESTROY") {
DESTROY
} else {
return None;
})
@@ -461,6 +470,16 @@ pub fn runs(
mod tests {
use super::*;
#[test]
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
let destroy = EntitySpan {
stage: render_stage("DESTROY"),
..Default::default()
};
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
}
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
@@ -483,15 +502,21 @@ mod tests {
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
@@ -523,3 +548,53 @@ mod tests {
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
for quad in vertices.as_chunks_mut::<4>().0 {
let origin = [
(quad[0].light & 65535) as f32 / 65535.0,
(quad[0].light >> 16) as f32 / 65535.0,
];
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
let min = [0, 1]
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
for v in quad {
v.uv =
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
v.layer = 0;
v.light = 0x0000_f0f0;
}
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
#[test]
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
.into_iter()
.map(|uv| Vertex {
uv,
layer: 11,
light: crate::breaking::stage_light([0.25, 0.5]),
..bytemuck::Zeroable::zeroed()
})
.collect();
project_cracks(
&mut vertices,
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
[4096, 9216],
);
assert!(
vertices
.iter()
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
);
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
@@ -273,16 +273,20 @@ const ZEROS: u64 = 4 << 20;
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
minimums: BTreeMap<u32, u64>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
minimums: BTreeMap<u32, u64>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
@@ -305,6 +309,7 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -330,7 +335,8 @@ impl Buffers {
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
@@ -368,6 +374,13 @@ impl Buffers {
}
}
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
@@ -458,3 +471,27 @@ impl Dump {
self.pending = Some((buffer, texel, dims, row));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
+171 -35
View File
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
enum Source {
Color(usize),
Depth,
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
Depth1,
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
Depth2,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
Normals,
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
Specular,
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
/// `.mcmeta` says.
Custom {
@@ -81,10 +87,18 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
@@ -97,9 +111,12 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
}
}
@@ -387,6 +404,8 @@ struct Targets {
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -408,6 +427,7 @@ pub(super) struct World<'a> {
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
pub gui_size: [u32; 2],
/// Set 1 of the clouds' draw: their offset and palette.
pub clouds: Option<&'a wgpu::BindGroupLayout>,
/// The game can hand its rain and snow to the pack.
@@ -458,6 +478,7 @@ struct ShadowCacheKey {
}
pub struct PackPost {
setup_pending: bool,
pub notes: Vec<String>,
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
@@ -492,6 +513,8 @@ pub struct PackPost {
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -510,6 +533,7 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
@@ -533,6 +557,8 @@ pub struct PackPost {
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
dummy_black: wgpu::TextureView,
dummy_normal: wgpu::TextureView,
dummy_3d: wgpu::TextureView,
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
@@ -647,11 +673,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
match name {
"depthtex0" | "gdepthtex" => Source::Depth,
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
"depthtex1" | "depthtex2" => Source::Depth1,
"depthtex1" => Source::Depth1,
"depthtex2" => Source::Depth2,
"noisetex" => Source::Noise,
"texture" | "gtexture" | "tex" => Source::Atlas,
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1073,7 +1101,7 @@ fn bindings(
Source::Atlas => geometry.is_none(),
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 => group == Group::Gbuffers,
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
_ => false,
};
@@ -1084,7 +1112,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
@@ -1341,11 +1369,16 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if let (Some(t), Some(b)) = (&mut state, p.blend)
&& format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
{
t.blend = Some(blend_state(b));
}
@@ -1433,7 +1466,15 @@ fn build_pass(
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
@@ -1645,6 +1686,8 @@ impl PackPost {
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
@@ -1728,7 +1771,7 @@ impl PackPost {
if gpu.storage_stages().is_empty() {
objects.clear();
}
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
let buffers = pack_images::Buffers::new(device, objects, minimums, gpu.storage_stages());
let storable = (0..BUFFERS)
.filter(|&i| gpu.storable(adapter, formats[i], features))
.fold(0u16, |m, i| m | 1 << i);
@@ -1893,8 +1936,15 @@ impl PackPost {
post.block,
post.entities_translucent,
post.particles,
post.damaged_block,
];
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
];
let late = [false, false, post.translucent_late, post.particles_late];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
let entity_vertices = (programs.iter().chain(&shadow_programs))
@@ -1910,8 +1960,21 @@ impl PackPost {
cull: false,
});
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
buffers: g.buffers,
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
} else {
build(p, Some(g))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
@@ -2045,13 +2108,20 @@ impl PackPost {
}
}
}
let depth1 = water.is_some()
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
let every_pass = || {
(passes
.iter()
@@ -2126,7 +2196,7 @@ impl PackPost {
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
@@ -2215,8 +2285,11 @@ impl PackPost {
});
}
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
setup_pending: true,
passes,
terrain,
water,
@@ -2233,6 +2306,7 @@ impl PackPost {
weather_generation: 0,
hand,
depth1,
depth2,
shadow_targets,
shadow_terrain,
shadow_water,
@@ -2243,6 +2317,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
@@ -2266,6 +2341,14 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
device,
queue,
"Shader pack normal stand-in",
1,
&flat_normal,
),
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
@@ -2471,6 +2554,27 @@ impl PackPost {
}
_ => &targets.depth,
},
Source::Depth2 => match &targets.depth2 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => match &targets.depth1 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => &targets.depth,
},
},
Source::Noise => &self.noise,
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
&self.dummy_3d
@@ -2478,6 +2582,8 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -2649,16 +2755,6 @@ impl PackPost {
return;
};
let translucent = list.translucent();
if list == TerrainList::Translucent
&& let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
@@ -2868,6 +2964,7 @@ impl PackPost {
queue: &wgpu::Queue,
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
) {
let shadow = self.shadow_casters();
@@ -2884,6 +2981,7 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -2934,6 +3032,22 @@ impl PackPost {
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
self.draw_entities(device, queue, encoder, size, false);
self.draw_clouds(device, queue, encoder, size);
if let Some(t) = &self.targets {
if let Some((copy, _)) = &t.depth1 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
if let Some((copy, _)) = &t.depth2 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
}
self.draw_hand(device, queue, encoder, size, frame);
}
@@ -3228,6 +3342,7 @@ impl PackPost {
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
self.setup_pending = true;
let sizes: [[u32; 2]; BUFFERS] =
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
@@ -3324,6 +3439,11 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let depth2 = self.depth2.then(|| {
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
let view = t.create_view(&Default::default());
(t, view)
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -3357,6 +3477,7 @@ impl PackPost {
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
depth2,
mips,
});
self.forget_groups();
@@ -3655,7 +3776,6 @@ impl PackPost {
self.gpu.clear,
&mut self.zeros,
);
self.run_shadow(device, queue, encoder, terrain, frame);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
@@ -3785,8 +3905,17 @@ impl PackPost {
.any(|(p, late)| p.is_some() && late)
});
let mut water_drawn = self.water.is_none() && self.weather.is_none() && !late_entities;
let mut shadow_drawn = false;
for pass in &mut passes {
if !terrain_drawn && pass.group != Group::Prepare {
if pass.group == Group::Setup && !self.setup_pending {
continue;
}
let early = matches!(pass.group, Group::Setup | Group::Begin);
if !early && !shadow_drawn {
self.run_shadow(device, queue, encoder, terrain, frame);
shadow_drawn = true;
}
if !terrain_drawn && !early && pass.group != Group::Prepare {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
@@ -3865,6 +3994,10 @@ impl PackPost {
self.mips_valid &= !pass.written;
}
self.passes = passes;
self.setup_pending = false;
if !shadow_drawn {
self.run_shadow(device, queue, encoder, terrain, frame);
}
if !terrain_drawn {
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
@@ -4040,7 +4173,10 @@ mod tests {
let main = |n| source(n, false, false, false);
assert!(main("gaux1") == Source::Color(4));
assert!(main("gdepthtex") == Source::Depth);
assert!(main("depthtex2") == Source::Depth1);
assert!(main("depthtex1") == Source::Depth1);
assert!(main("depthtex2") == Source::Depth2);
assert!(main("normals") == Source::Normals);
assert!(main("specular") == Source::Specular);
assert!(main("noisetex") == Source::Noise);
// Without the shadow pass, the shadow buffers are stand-ins.
assert!(main("shadowcolor0") == Source::Dummy);
@@ -35,7 +35,7 @@ pub(super) fn upload(
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
continue;
}
};
@@ -60,8 +60,8 @@ pub(super) fn upload(
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
"{}: `{location}` is not provided yet",
key(&t.stage, &t.name)
));
continue;
};
@@ -86,6 +86,8 @@ pub(super) fn upload(
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Setup => "setup",
Group::Begin => "begin",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
@@ -95,7 +97,8 @@ fn stage(group: Group) -> &'static str {
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
/// dimension, which every program reads.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
@@ -105,14 +108,27 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
@@ -316,6 +332,47 @@ mod tests {
);
}
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
lightmap(every, "colortex4", D::D2),
];
for group in [
Group::Gbuffers,
Group::Translucent,
Group::Shadow,
Group::Prepare,
Group::Deferred,
Group::ShadowComp,
] {
assert_eq!(
find(&textures, group, "sampler3D", "texClouds"),
Some(0),
"{group:?}"
);
}
// `final` reads the `composite` stage's textures too.
for group in [Group::Composite, Group::Final] {
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
}
assert_eq!(
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
None
);
// Only its own name: no other name of the same buffer.
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
assert_eq!(
find(&textures, Group::Final, "sampler2D", "colortex4"),
Some(2)
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
@@ -404,9 +404,9 @@ impl FrameUniforms {
frame.depth_far,
);
let camera = frame.camera.as_vec3();
let first_frame = self.previous.is_none();
let (previous_exact, previous_view, previous_projection) =
self.previous
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous.unwrap_or((frame.camera, view, projection));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
@@ -436,6 +436,10 @@ impl FrameUniforms {
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
@@ -849,3 +853,151 @@ mod tests {
);
}
}
#[cfg(test)]
mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
match u.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
}
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let mut u = FrameUniforms::default();
f.camera = DVec3::new(1024.25, -3.5, 11.75);
f.yaw = 21.0;
f.pitch = -16.0;
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
for (prior, current) in [
("gbufferPreviousProjection", "gbufferProjection"),
("gbufferPreviousModelView", "gbufferModelView"),
] {
assert_eq!(float(&u, prior), float(&u, current));
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
}
let camera = float(&u, "cameraPosition");
let view = float(&u, "gbufferModelView");
let projection = float(&u, "gbufferProjection");
f.camera = DVec3::new(-100.0, 20.0, 40.0);
f.yaw = 90.0;
f.pitch = 50.0;
f.fov_degrees = 100.0;
u.update(&f, &PackInputs::default(), [1000, 500]);
assert_eq!(float(&u, "previousCameraPosition"), camera);
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
assert_ne!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 0.75,
eye_light: [3, 14],
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert_eq!(float(&u, "wetness")[0], 0.75);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
_ => panic!("integer uniform expected"),
}
input.rain = 0.0;
input.eye_light = [0, 0];
u.update(&f, &input, [800, 450]);
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
}
@@ -5,7 +5,10 @@
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
streaming::in_view_distance,
};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -55,6 +58,22 @@ impl<T> Resident<T> {
pub fn len(&self) -> usize {
self.items.len()
}
/// Heap bytes of its own lists and maps, not counting what a `T` points
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
/// control byte.
pub fn heap_bytes(&self) -> usize {
use std::mem::size_of;
self.items.capacity() * size_of::<(SectionKey, T)>()
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
+ self.columns.capacity() * size_of::<Column>()
+ self
.columns
.iter()
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
.sum::<usize>()
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
}
pub fn contains(&self, key: SectionKey) -> bool {
self.slots.contains_key(&key)
}
@@ -125,6 +144,24 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
let c = self.column_index[&(key.0, key.2)] as usize;
@@ -496,7 +533,19 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(3) {
if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
} else {
r.insert(key, step);
@@ -0,0 +1,181 @@
//! Pipelines kept between the renderers built on one device. The start builds
//! three (the boot screen's, the title artwork's, the world's) and a resource
//! reload or a graphics option builds another; each compiled every shader
//! module and pipeline again, though most come out identical (the boot and
//! title renderers' entirely, the world's entity pipelines). On a driver that
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
//! of the main thread before the title showed.
//!
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
//! labels, sources, constants, formats and states, and the handles a descriptor
//! names (shader modules, layouts) by their ids, so a descriptor that names a
//! kept handle matches only the same handle. Kept objects stay alive, so their
//! ids are never reused while they are keys.
use std::{
collections::HashMap,
sync::{Mutex, PoisonError},
};
/// One map of kept objects with the generation each was last asked for in.
struct Kept<T> {
map: Mutex<HashMap<String, (T, u32)>>,
}
impl<T> Default for Kept<T> {
fn default() -> Self {
Self {
map: Mutex::new(HashMap::new()),
}
}
}
impl<T: Clone> Kept<T> {
/// The object kept under `key`, else `make`'s, kept. The lock is not
/// held while `make` compiles.
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
if let Some((object, used)) = self
.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_mut(&key)
{
*used = generation;
return object.clone();
}
let object = make();
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(key, (object.clone(), generation));
object
}
fn keep_only(&self, generation: u32) {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|_, (_, used)| *used == generation);
}
fn len(&self) -> usize {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
}
/// The shader modules, layouts and pipelines of the device's renderers.
#[derive(Default)]
pub struct Reuse {
generation: std::sync::atomic::AtomicU32,
shaders: Kept<wgpu::ShaderModule>,
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
pipeline_layouts: Kept<wgpu::PipelineLayout>,
render: Kept<wgpu::RenderPipeline>,
}
impl Reuse {
/// A renderer starts building: what it asks for is kept, the rest is let
/// go by [`Reuse::finish`].
pub fn begin(&self) -> u32 {
self.generation
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.wrapping_add(1)
}
/// The renderer of `generation` is built: objects only older renderers
/// used are dropped here (the renderers still alive hold their own).
pub fn finish(&self, generation: u32) {
self.shaders.keep_only(generation);
self.bind_group_layouts.keep_only(generation);
self.pipeline_layouts.keep_only(generation);
self.render.keep_only(generation);
}
/// How many objects are kept.
pub fn len(&self) -> usize {
self.shaders.len()
+ self.bind_group_layouts.len()
+ self.pipeline_layouts.len()
+ self.render.len()
}
fn current(&self) -> u32 {
self.generation.load(std::sync::atomic::Ordering::Relaxed)
}
/// `device`'s `create_*` calls through this cache.
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
On {
reuse: self,
device,
generation: self.current(),
}
}
}
/// A device whose shader modules, layouts and render pipelines come from a
/// [`Reuse`] when one with the same descriptor is kept.
pub struct On<'a> {
reuse: &'a Reuse,
device: &'a wgpu::Device,
generation: u32,
}
impl On<'_> {
pub fn create_shader_module(
&self,
descriptor: wgpu::ShaderModuleDescriptor<'_>,
) -> wgpu::ShaderModule {
let key = format!("{descriptor:?}");
self.reuse.shaders.get(key, self.generation, || {
self.device.create_shader_module(descriptor)
})
}
pub fn create_bind_group_layout(
&self,
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
) -> wgpu::BindGroupLayout {
let key = format!("{descriptor:?}");
self.reuse.bind_group_layouts.get(key, self.generation, || {
self.device.create_bind_group_layout(descriptor)
})
}
pub fn create_pipeline_layout(
&self,
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
) -> wgpu::PipelineLayout {
let key = format!("{descriptor:?}");
self.reuse.pipeline_layouts.get(key, self.generation, || {
self.device.create_pipeline_layout(descriptor)
})
}
pub fn create_render_pipeline(
&self,
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
) -> wgpu::RenderPipeline {
let key = format!("{descriptor:?}");
self.reuse.render.get(key, self.generation, || {
self.device.create_render_pipeline(descriptor)
})
}
}
#[cfg(test)]
mod tests {
use super::Kept;
/// A key asked for again gets the kept object without making one; what
/// the latest generation did not ask for is let go when it finishes.
#[test]
fn kept_objects_are_shared_until_a_build_stops_asking() {
let kept = Kept::<u32>::default();
let made = std::cell::Cell::new(0);
let make = |value| {
made.set(made.get() + 1);
value
};
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
kept.keep_only(1);
assert_eq!(kept.len(), 2);
// The next build asks for "a" only.
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
kept.keep_only(2);
assert_eq!(kept.len(), 1);
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
assert_eq!(made.get(), 3);
}
}
@@ -132,14 +132,15 @@ impl Stars {
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// RenderPipelines.STARS: the celestial blend (src alpha, one).
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -11,6 +11,10 @@ struct Stars {
return stars.clip_from_sky * vec4(position, 1.0);
}
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
@fragment fn fs_stars() -> @location(0) vec4<f32> {
return stars.color;
let b = stars.color.a;
return vec4(stars.color.rgb * b, 0.175);
}
+15 -1
View File
@@ -7,8 +7,9 @@
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
@@ -50,6 +51,9 @@ pub struct Queue {
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
/// Nanoseconds the window's thread waited for the submit thread since the last
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
waited: AtomicU64,
}
impl Queue {
@@ -60,6 +64,7 @@ impl Queue {
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
waited: AtomicU64::new(0),
};
if !threaded {
return this;
@@ -130,15 +135,24 @@ impl Queue {
return;
}
if let Some(l) = landing.as_mut() {
let started = Instant::now();
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
self.waited.fetch_add(nanos, Ordering::Relaxed);
}
self.pending.store(false, Ordering::Release);
}
/// How long the window's thread waited for the submit thread since the last call: in a
/// GPU-bound view, the previous frame's submission and present hold up every use of the
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
pub fn take_waited(&self) -> Duration {
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
@@ -10,6 +10,8 @@
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The least a world is scaled to when memory runs out.
const MIN_SCALE: f32 = 0.25;
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -322,12 +324,65 @@ impl Post {
self.target = None;
return None;
}
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
// The old target goes first: a slider drag would otherwise hold the old and the new
// targets together, and the sum is what runs a small GPU out of memory.
loop {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
{
break;
}
self.target = None;
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
// that failed is invalid, so its view fails validation too: catch that as well.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let target = self.build_target(device, size, history_size);
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
if error.is_none() {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(target);
break;
}
drop(target);
if self.scale <= MIN_SCALE {
log::warn!(
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = 1.0;
self.target = None;
return None;
}
let reduced = (self.scale * 0.75).max(MIN_SCALE);
log::warn!(
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
self.scale,
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = reduced;
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
return None;
}
}
self.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
/// is set.
fn build_target(
&self,
device: &wgpu::Device,
size: [u32; 2],
history_size: Option<[u32; 2]>,
) -> WorldTarget {
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -403,19 +458,14 @@ impl Post {
show,
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
WorldTarget {
size,
color,
depth,
group,
taa,
});
}
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
+118 -9
View File
@@ -19,20 +19,28 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -535,6 +543,9 @@ pub struct Graphics {
pub lod_distance: u32,
/// How finely far terrain is drawn; Full unless the player picks Reduced.
pub far_detail: FarDetail,
/// Far terrain from servers' BlueMap web maps; no server has one unless
/// the player names it (`blueMap:` lines) or turns on auto-detect.
pub blue_map: crate::bluemap::Settings,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
@@ -543,6 +554,20 @@ pub struct Graphics {
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Cache settled shulker-box models.
pub cache_shulker_boxes: bool,
/// Cache sign-text geometry.
pub cache_signs: bool,
/// Cache decorated pots outside their wobble animation.
pub cache_decorated_pots: bool,
/// Cache bells while they are not ringing.
pub cache_bells: bool,
/// Cache closed chests, including copper variants.
pub cache_chests: bool,
/// Cache banner poles while leaving their flags animated.
pub cache_banners: bool,
/// Cache copper-golem statue models.
pub cache_copper_golem_statues: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -582,8 +607,9 @@ pub struct Graphics {
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -618,9 +644,18 @@ impl Default for Graphics {
preset: Preset::Custom,
lod_distance: 0,
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
cache_signs: true,
cache_decorated_pots: false,
cache_bells: false,
cache_chests: false,
cache_banners: false,
cache_copper_golem_statues: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -661,6 +696,14 @@ impl Graphics {
let mut g: Self = fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
.unwrap_or_default();
g.blue_map.servers = match fs::read_to_string(dir.join(crate::bluemap::SERVERS_FILE)) {
Ok(text) => crate::bluemap::Settings::parse_servers(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
log::warn!("{}: {e}; no BlueMap is named", crate::bluemap::SERVERS_FILE);
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
g.save(dir);
@@ -689,9 +732,38 @@ impl Graphics {
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"blueMapRadius" => {
if let Some(radius) = crate::bluemap::Settings::parse_radius(value) {
g.blue_map.radius = radius;
}
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
g.cache_shulker_boxes = enabled;
g.cache_signs = enabled;
g.cache_decorated_pots = enabled;
g.cache_bells = enabled;
g.cache_chests = enabled;
g.cache_banners = enabled;
g.cache_copper_golem_statues = enabled;
}
"blockEntityCacheShulkerBoxes" => {
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
}
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
"blockEntityCacheDecoratedPots" => {
g.cache_decorated_pots = flag(g.cache_decorated_pots)
}
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
"blockEntityCacheCopperGolemStatues" => {
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
}
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -757,14 +829,21 @@ impl Graphics {
g
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
bool_text(self.cache_decorated_pots),
bool_text(self.cache_bells),
bool_text(self.cache_chests),
bool_text(self.cache_banners),
bool_text(self.cache_copper_golem_statues),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -801,7 +880,9 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
);
text += &self.blue_map.serialize();
text
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
@@ -830,6 +911,14 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -920,9 +1009,22 @@ mod tests {
preset: Preset::Balanced,
lod_distance: 256,
far_detail: FarDetail::Reduced,
blue_map: crate::bluemap::Settings {
radius: 40,
auto_detect: true,
// In their own file (`bluemap::SERVERS_FILE`).
servers: Vec::new(),
},
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
cache_signs: true,
cache_decorated_pots: true,
cache_bells: true,
cache_chests: true,
cache_banners: true,
cache_copper_golem_statues: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -969,6 +1071,13 @@ mod tests {
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
// A map named here (a shared instance's, a pack's) is no map.
assert!(
Graphics::parse("blueMap:play.example.net https://map.example.net\n")
.blue_map
.servers
.is_empty()
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
+280 -16
View File
@@ -28,9 +28,18 @@ pub enum H {
Preset,
LodDistance,
FarDetail,
BlueMapAutoDetect,
BlueMapRadius,
CaveCulling,
EntityCulling,
LeafCulling,
CacheShulkerBoxes,
CacheSigns,
CacheDecoratedPots,
CacheBells,
CacheChests,
CacheBanners,
CacheCopperGolemStatues,
ChunkUpdates,
UploadBudget,
Sky,
@@ -183,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
render,
lod,
far,
h(
H::BlueMapAutoDetect,
None,
"On servers, finds their BlueMap web map for far terrain. Next join.",
),
h(
H::BlueMapRadius,
None,
"How far around you BlueMap tiles are downloaded. Next join.",
),
v(
Opt::SimulationDistance,
Medium,
@@ -207,13 +226,49 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Hides entities behind opaque blocks.",
),
h(
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Block Entities"),
h(
H::CacheShulkerBoxes,
Saves,
"Reuses settled boxes; lid movement stays animated.",
),
h(
H::CacheSigns,
Saves,
"Reuses sign text until text, light, or font changes.",
),
h(
H::CacheDecoratedPots,
Saves,
"Reuses still pots; break wobble and sherd changes redraw.",
),
h(
H::CacheBells,
Saves,
"Reuses still bells; ringing stays animated.",
),
h(
H::CacheChests,
Saves,
"Reuses closed chests; moving lids stay animated.",
),
h(
H::CacheBanners,
Saves,
"Reuses poles; flags and pattern layers still update.",
),
h(
H::CacheCopperGolemStatues,
Saves,
"Reuses statue geometry until its block state changes.",
),
Row::Header("Quality"),
v(
Opt::SmoothLighting,
@@ -387,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
rows
}
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// up to the whole world.
@@ -470,6 +527,11 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::EntityCulling), _) => match s.horizon.entity_culling {
EntityCullingMode::Off => "No entity occlusion culling.",
EntityCullingMode::Synchronous => "CPU occlusion test runs this frame.",
EntityCullingMode::Asynchronous => "Occlusion checks run on a worker thread.",
},
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
@@ -548,9 +610,23 @@ impl H {
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
H::BlueMapRadius => labelled(
"BlueMap Radius",
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
H::CacheBells => labelled("Bells", on_off(g.cache_bells)),
H::CacheChests => labelled("Chests", on_off(g.cache_chests)),
H::CacheBanners => labelled("Banners", on_off(g.cache_banners)),
H::CacheCopperGolemStatues => {
labelled("Copper Golem Statues", on_off(g.cache_copper_golem_statues))
}
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -606,24 +682,21 @@ impl H {
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
n => labelled("Paper White", n.to_string()),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => labelled(
"Black Level",
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
@@ -689,6 +762,10 @@ impl H {
H::FarDetail => {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
H::BlueMapRadius => {
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
@@ -696,6 +773,15 @@ impl H {
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
H::CacheSigns => g.cache_signs = !g.cache_signs,
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
H::CacheBells => g.cache_bells = !g.cache_bells,
H::CacheChests => g.cache_chests = !g.cache_chests,
H::CacheBanners => g.cache_banners = !g.cache_banners,
H::CacheCopperGolemStatues => {
g.cache_copper_golem_statues = !g.cache_copper_golem_statues
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -792,7 +878,15 @@ impl Item {
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
Item::Horizon(H::CacheSigns) => g.cache_signs,
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
Item::Horizon(H::CacheBells) => g.cache_bells,
Item::Horizon(H::CacheChests) => g.cache_chests,
Item::Horizon(H::CacheBanners) => g.cache_banners,
Item::Horizon(H::CacheCopperGolemStatues) => g.cache_copper_golem_statues,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
@@ -856,6 +950,8 @@ impl Item {
self,
Item::Horizon(
H::Preset
| H::BlueMapAutoDetect
| H::BlueMapRadius
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
@@ -1293,6 +1389,11 @@ impl GraphicsUi {
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Action> {
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
if !s.extras.wheel_cycles_settings {
self.scroll(level, s, h, amount);
return Vec::new();
}
// Horizon improvement: focused slider/cycle intercepts wheel first.
if amount != 0.0
&& self.can_change_value
@@ -1821,6 +1922,31 @@ mod tests {
);
}
#[test]
fn blue_map_settings_are_advanced_rendering_options() {
let mut s = Settings::default();
let find = Item::Horizon(H::BlueMapAutoDetect);
let radius = Item::Horizon(H::BlueMapRadius);
let advanced: Vec<_> = rows(Level::Advanced)
.into_iter()
.filter_map(|row| match row {
Row::Setting(setting) => Some(setting.item),
Row::Header(_) => None,
})
.collect();
assert!(advanced.contains(&find) && advanced.contains(&radius));
assert_eq!(find.switch(&s), Some(false), "off by default");
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
find.cycle(&mut s, false, [854, 480]);
assert!(s.horizon.blue_map.auto_detect);
radius.cycle(&mut s, false, [854, 480]);
assert_eq!(s.horizon.blue_map.radius, 128);
radius.cycle(&mut s, true, [854, 480]);
radius.cycle(&mut s, true, [854, 480]);
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn presets_set_vanilla_and_horizon_options() {
let mut s = Settings::default();
@@ -1910,8 +2036,8 @@ mod tests {
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
// The culling group is followed by the separate block-entity cache
// group, then Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
@@ -1931,6 +2057,27 @@ mod tests {
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Block Entities"))
));
for (offset, cache) in [
H::CacheShulkerBoxes,
H::CacheSigns,
H::CacheDecoratedPots,
H::CacheBells,
H::CacheChests,
H::CacheBanners,
H::CacheCopperGolemStatues,
]
.into_iter()
.enumerate()
{
assert!(matches!(
rows.get(culling + 5 + offset),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(cache)
));
}
assert!(matches!(
rows.get(culling + 12),
Some(Row::Header("Quality"))
));
@@ -2162,6 +2309,15 @@ mod tests {
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
s.extras.wheel_cycles_settings = false;
let scroll_before = ui.scroll.amount;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
assert!(actions.is_empty());
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
assert!(ui.scroll.amount > scroll_before);
s.extras.wheel_cycles_settings = true;
ui.scroll.amount = scroll_before;
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
s.horizon.chunk_updates = 16;
@@ -2229,4 +2385,112 @@ mod tests {
}
}
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the three pages show, with each setting cycled
/// through its values (#1927).
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for _ in 0..8 {
for row in extra_rows(level, &s) {
match row {
Row::Header(name) => {
lang::get(name);
}
Row::Setting(row) => {
row.item.label(&s);
let text = description(row.item, &s, row.text);
lang::get(text);
lang::get(row.cost.label());
if let Some(why) = row.item.blocked(&s) {
lang::get(why);
}
}
}
}
for row in extra_rows(level, &s) {
if let Row::Setting(Setting {
item: Item::Horizon(h),
..
}) = row
{
h.cycle(&mut s, false);
}
}
}
}
}
for button in ["Advanced...", "Simple...", "Done"] {
lang::get(button);
}
});
// `labelled` also looks up values that are already words in the language ("An") or
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
// a name.
let missed: Vec<_> = missed
.into_iter()
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
.collect();
assert_eq!(missed, Vec::<String>::new());
}
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
#[test]
fn newer_graphics_settings_are_translated() {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
let item = |h| Item::Horizon(h);
(
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
"{code}"
);
}
}
}
+20 -3
View File
@@ -20,6 +20,8 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
&& (hand || (!covered && shows_world(c, w.rgb))) {
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
@@ -49,7 +49,7 @@ pub struct Calibration {
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 2],
auto: [Option<f32>; 3],
dragging: bool,
}
@@ -125,11 +125,12 @@ impl Calibration {
Self {
step: 0,
values: [
g.hdr_black_level as f32 / 100.0,
g.black_level_nits(),
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -146,15 +147,11 @@ impl Calibration {
fn label(&self) -> Text {
let v = self.values[self.step];
let step = self.current();
let nits = match step {
let value = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -284,11 +281,12 @@ impl Calibration {
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
let scale = [100.0, 1.0, 1.0];
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
Some(auto) if auto == self.values[i] => 0,
_ => self.values[i].round() as u32,
_ => (self.values[i] * scale[i]).round() as u32,
});
g.hdr_black_level = black;
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
+405 -38
View File
@@ -7,9 +7,11 @@ use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use wgpu::util::DeviceExt;
pub mod cit_glint;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
@@ -57,7 +59,18 @@ impl TextureFlags {
}
}
pub struct HudAtlas {
/// Identity of this packed atlas, used to invalidate cached model UV data
/// even when a resource reload places sprites at the same coordinates.
pub revision: u64,
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
/// compressed them (read them through `pixels()`).
pub pixels: Vec<u8>,
/// `pixels` compressed (lz4) on a thread of its own once the renderer
/// has uploaded them: only rebuilding a renderer reads them again.
packed: Option<Arc<OnceLock<Vec<u8>>>>,
/// The width each Standard Galactic letter uses in `font/ascii_sga`'s
/// cell, by ASCII code (the enchanting table's text), read when built.
pub galactic_widths: [u16; 128],
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
@@ -102,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
/// enough for a world map's 1024 blocks at a block a pixel.
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
pub const MOD_IMAGE_SIZE: u32 = 1024;
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
/// Vanilla registers a texture per skin. Here the slots are shared
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
@@ -119,7 +136,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
decode_bytes(&fs::read(path)?)
decode_bytes(&horizon_core::prepared_files::read(path)?)
}
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
@@ -171,14 +188,25 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut header = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
.then(|| (field(16), field(20)))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
// A prepared root's folder: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
out.extend(
files
.iter()
.filter(|file| file.ends_with(".png"))
.map(|file| dir.join(file)),
);
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
@@ -247,16 +275,23 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
/// They are item textures, so an animated strip keeps its first frame. None without
/// `optifine/cit` rules.
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
/// first frame; a still texture is packed whole. None without `optifine/cit` rules.
fn cit_sprites(
root: &Path,
packed: &[PackedImage],
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
let rules = crate::cit::load_rules(root);
let mut ids = crate::cit_assets::item_sprites(&rules);
if !rules.is_empty() {
let models = crate::cit_assets::load_models(root, &rules);
ids.extend(crate::cit_assets::model_sprites(&models));
ids.extend(crate::cit_armor::armor_sprites(&rules));
}
let ids: Vec<String> = ids
.into_iter()
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
@@ -345,7 +380,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
return None;
}
};
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
// armour layers are twice as wide as they are tall.
@@ -443,7 +479,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
first_frame(image, animation.as_ref())
@@ -475,7 +514,135 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
pub const MISSING_SPRITE: &str = "horizon/missing";
/// The used width of each Standard Galactic letter (A-Z, a-z) in the
/// `font/ascii_sga` sheet: one past its last column with a texel that isn't
/// transparent.
fn galactic_widths(
sprites: &HashMap<String, Rect>,
pixels: &[u8],
width: u32,
height: u32,
) -> [u16; 128] {
let mut widths = [0u16; 128];
let Some(sheet) = sprites.get("font/ascii_sga") else {
return widths;
};
let cell = sheet.w / 16;
let (px, py) = (
(sheet.u0 * width as f32) as u32,
(sheet.v0 * height as f32) as u32,
);
for code in (0x41..=0x5Au32).chain(0x61..=0x7A) {
let (cx, cy) = (px + code % 16 * cell, py + code / 16 * cell);
let alpha = |x: u32, y: u32| {
pixels
.get(((y * width + x) * 4 + 3) as usize)
.copied()
.unwrap_or(0)
};
widths[code as usize] = (0..cell)
.rev()
.find(|&dx| (0..cell).any(|dy| alpha(cx + dx, cy + dy) != 0))
.map_or(0, |dx| (dx + 1).min(u32::from(u16::MAX)) as u16);
}
widths
}
/// `TextureAtlas.dumpContents` (`F3 + S`, vanilla's "dynamic textures"): the atlas
/// written as `name.png` beside `name.txt`, every sprite in name order with its
/// rect in texels (`dumpSpriteNames`). Vanilla names each file after its atlas
/// with `/` and `:` as `_` (`Identifier.toDebugFileName`); Horizon packs the GUI,
/// font, particle, map and skin sprites into one atlas, so one file stands in for
/// the atlases vanilla dumps apart.
pub fn dump_atlas(
dir: &Path,
name: &str,
width: u32,
height: u32,
pixels: &[u8],
sprites: &HashMap<String, Rect>,
) -> std::io::Result<PathBuf> {
// A build whose pixels were freed (`fonts`) has the size and no texels:
// write nothing rather than a file every reader would reject.
if pixels.len() != (width as usize) * (height as usize) * 4 {
return Err(std::io::Error::other(format!(
"wrong data size, expected {} got {}",
(width as usize) * (height as usize) * 4,
pixels.len()
)));
}
fs::create_dir_all(dir)?;
let png_path = dir.join(format!("{name}.png"));
let mut encoder = png::Encoder::new(fs::File::create(&png_path)?, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
writer
.write_image_data(pixels)
.map_err(std::io::Error::other)?;
drop(writer);
let mut names = sprites.iter().collect::<Vec<_>>();
names.sort_by(|a, b| a.0.cmp(b.0));
let mut list = String::new();
for (sprite, rect) in names {
let (w, h) = rect.texels((width, height));
let x = (rect.u0 * width as f32).round() as u32;
let y = (rect.v0 * height as f32).round() as u32;
list.push_str(&format!("{sprite}\tx={x}\ty={y}\tw={w}\th={h}\n"));
}
fs::write(dir.join(format!("{name}.txt")), list)?;
Ok(png_path)
}
impl HudAtlas {
/// `TextureAtlas.dumpContents` for this atlas: `dir` is `screenshots/debug`,
/// the folder `TextureUtil.getDebugTexturePath` names.
pub fn dump_contents(&self, dir: &Path, name: &str) -> std::io::Result<PathBuf> {
// `pixels()`: the copy `pack_pixels` compressed once the GPU had the
// atlas, decompressed; vanilla reads the texture back from the GPU.
let pixels = self.pixels();
dump_atlas(dir, name, self.width, self.height, &pixels, &self.sprites)
}
fn next_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// The atlas's pixels: as built, or uncompressed again after
/// `pack_pixels` (a renderer being rebuilt).
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
let Some(packed) = self.packed.as_ref().filter(|_| self.pixels.is_empty()) else {
return std::borrow::Cow::Borrowed(&self.pixels);
};
let size = (self.width * self.height * 4) as usize;
std::borrow::Cow::Owned(
lz4_flex::decompress_size_prepended(packed.wait())
.ok()
.filter(|p| p.len() == size)
.unwrap_or_else(|| vec![0; size]),
)
}
/// Keep the pixels compressed from now on, once a renderer has them: the
/// window's GPU holds the atlas, and a copy as built only serves a
/// renderer made again (another window, a lost device, another graphics
/// API). They are compressed on a thread of their own; `pixels()` waits
/// for it.
pub fn pack_pixels(&mut self) {
if self.pixels.is_empty() || self.packed.is_some() {
return;
}
let raw = Arc::new(std::mem::take(&mut self.pixels));
let slot = Arc::new(OnceLock::new());
let (job_raw, job_slot) = (raw.clone(), slot.clone());
let spawned = std::thread::Builder::new()
.name("Atlas packing".into())
.spawn(move || {
let _ = job_slot.set(lz4_flex::compress_prepend_size(&job_raw));
});
if spawned.is_err() {
let _ = slot.set(lz4_flex::compress_prepend_size(&raw));
}
self.packed = Some(slot);
}
/// A sprite, or the missing one when the atlas has no such sprite (an
/// effect without its icon), as a stitched atlas answers.
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
@@ -518,13 +685,21 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
Self::build_over_with_image(root, defaults, false)
}
/// The atlas above, with a slot for a native mod's own image
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
/// draws one: the slot costs 4 MiB of pixels, so a game without native
/// mods holds none of them.
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
@@ -535,6 +710,15 @@ impl HudAtlas {
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
images.push((
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
@@ -577,7 +761,7 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
@@ -614,6 +798,7 @@ impl HudAtlas {
);
let white = sprites["horizon/white"];
Self {
revision: Self::next_revision(),
font: crate::font::Font::load(
Path::new("horizon-boot"),
&sprites,
@@ -622,7 +807,9 @@ impl HudAtlas {
(glyph_top, height - glyph_top),
white,
),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -664,25 +851,21 @@ impl HudAtlas {
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
/// A width where the plain shelves (each image after the last) leave too
/// much unused is tried again with each image in the first shelf with
/// room: a pack of thousands of mixed sizes (server packs with custom
/// items) fits where the shelves' ragged ends would not.
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
if wide_enough {
for first_fit in [false, true] {
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
if glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
}
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
@@ -695,9 +878,42 @@ impl HudAtlas {
width = (width * 2).min(max);
}
}
/// `layout`'s shelves at one width: the placements and the first row below
/// them. `first_fit` puts an image in the first shelf that is tall enough
/// and has room, instead of only the last one.
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
// Each shelf: its top row, its height (its first image's) and the next free column.
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
let mut placements = Vec::with_capacity(images.len());
for (_, w, h, _) in images {
let open = if first_fit {
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
} else {
shelves
.len()
.checked_sub(1)
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
};
let i = open.unwrap_or_else(|| {
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
shelves.push((top, *h, 0));
shelves.len() - 1
});
placements.push((shelves[i].2, shelves[i].0));
shelves[i].2 += w + 1;
}
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
(placements, glyph_region)
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(
root: &Path,
defaults: &Path,
folders: &[&str],
full: bool,
mod_image: bool,
) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
@@ -841,7 +1057,9 @@ impl HudAtlas {
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
@@ -926,7 +1144,7 @@ impl HudAtlas {
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
@@ -1032,7 +1250,10 @@ impl HudAtlas {
white,
);
Ok(Self {
revision: Self::next_revision(),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1155,6 +1376,9 @@ pub enum Blend {
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
/// projected from the screen (`HudBatch::end_portal` sets the textures).
EndPortal,
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
/// `GlintTextures::blends` says.
CitGlint(u8),
}
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
@@ -1889,7 +2113,11 @@ pub struct HudRenderer {
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// The pack's CIT enchantment glints, when it has any.
cit_glint: Option<cit_glint::CitGlintPass>,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
@@ -1918,6 +2146,12 @@ impl HudRenderer {
label: Some("HUD"),
..Default::default()
});
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("HUD linear"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
@@ -1968,6 +2202,12 @@ impl HudRenderer {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -1994,6 +2234,10 @@ impl HudRenderer {
binding: 4,
resource: wgpu::BindingResource::Sampler(glint_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&linear_sampler),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -2096,7 +2340,7 @@ impl HudRenderer {
operation: wgpu::BlendOperation::Add,
},
},
"fs_main",
"fs_vignette",
false,
);
// RenderPipelines.END_PORTAL: opaque.
@@ -2122,7 +2366,9 @@ impl HudRenderer {
depth_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
group,
cit_glint: None,
uniform,
buffer,
capacity,
@@ -2132,6 +2378,18 @@ impl HudRenderer {
zoom: 1.0,
})
}
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
/// textures draws none.
pub fn set_cit_glints(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
@@ -2367,6 +2625,12 @@ impl HudRenderer {
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
}
continue;
}
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
@@ -2395,6 +2659,61 @@ impl HudRenderer {
mod tests {
use super::*;
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
let root = Path::new("/nonexistent/horizon-test-assets");
let plain = HudAtlas::build(root).unwrap();
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
}
#[test]
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
let _ = fs::remove_dir_all(&root);
let pixels: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
let rect = |u0: f32, v0: f32| Rect {
u0,
v0,
u1: u0 + 0.5,
v1: v0 + 0.5,
w: 8,
h: 8,
};
let sprites = HashMap::from([
("particle/smoke".to_string(), rect(0.5, 0.0)),
("gui/sprites/bars/experience".to_string(), rect(0.0, 0.5)),
]);
let path = dump_atlas(
&root,
"minecraft_textures_atlas_gui",
2,
2,
&pixels,
&sprites,
)
.expect("the dump writes");
assert_eq!(path, root.join("minecraft_textures_atlas_gui.png"));
let written = fs::read(&path).expect("the PNG is there");
let (width, height, rgba) = decode_bytes(&written).expect("the PNG decodes");
assert_eq!((width, height), (2, 2));
assert_eq!(rgba, pixels);
// Sorted by sprite name, rects in texels, as `dumpSpriteNames` writes them.
let mut expected = String::from("gui/sprites/bars/experience\tx=0\ty=1\tw=1\th=1\n");
expected.push_str("particle/smoke\tx=1\ty=0\tw=1\th=1\n");
assert_eq!(
fs::read_to_string(root.join("minecraft_textures_atlas_gui.txt")).unwrap(),
expected
);
// A build with no texels in hand writes no file at all.
let empty = dump_atlas(&root, "empty", 2, 2, &[], &sprites);
assert!(empty.is_err());
assert!(!root.join("empty.png").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn decode_bytes_ignores_crc_checksum_error() {
let mut encoded = Vec::new();
@@ -2723,6 +3042,18 @@ mod tests {
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
}
#[test]
fn packed_pixels_come_back_the_same() {
let root = animated_root();
let mut atlas = HudAtlas::build(&root).unwrap();
let built = atlas.pixels.clone();
atlas.pack_pixels();
assert!(atlas.pixels.is_empty());
assert!(atlas.pixels() == built.as_slice());
// Packing again keeps the same image.
atlas.pack_pixels();
assert!(atlas.pixels() == built.as_slice());
}
#[test]
fn a_strip_frames_size_comes_from_its_mcmeta() {
let root = animated_root();
let block = root.join("assets/minecraft/textures/block");
@@ -2827,6 +3158,25 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
}
#[test]
fn a_cit_models_relative_texture_is_packed() {
let root = cit_root(
"model",
&[
("a.properties", "items=stick\nmodel=sword"),
(
"sword.json",
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 255]);
}
#[test]
fn a_still_cit_texture_is_packed_whole() {
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
@@ -2862,7 +3212,7 @@ mod tests {
assert_eq!(animations[0].1.frame_count(), 3);
}
#[test]
fn a_texture_only_an_armor_rule_names_is_not_packed() {
fn an_armor_rules_texture_is_packed_too() {
let root = cit_root(
"armor",
&[(
@@ -2872,11 +3222,15 @@ mod tests {
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
// The rule loads; only its kind keeps the texture out.
assert_eq!(crate::cit::load_rules(&root).len(), 1);
let rules = crate::cit::load_rules(&root);
assert_eq!(rules.len(), 1);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
// Worn armour draws from the same atlas, under the name the layer looks up.
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
assert!(atlas.sprites.contains_key(&layer.unwrap()));
}
#[test]
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
@@ -3104,6 +3458,19 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn mixed_sizes_fill_the_ends_of_the_shelves() {
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
// 4000-wide ones (same height) come after them, and on shelves of their
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
// Each in a gap, it is 15.
let mut images = sized(15, 9000, 1000);
images.extend(sized(15, 4000, 1000));
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
assert_eq!(width, 16384);
assert!(free + GLYPH_ROWS <= 16384);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
+36
View File
@@ -10,6 +10,7 @@ struct Hud {
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
@group(0) @binding(4) var glint_sampler: sampler;
@group(0) @binding(5) var linear_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -41,11 +42,46 @@ struct Out {
if c.a < 0.004 { discard; }
return c;
}
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
struct CitSlot {
rgba: vec4<f32>,
// x: linear filtering (`blur`).
blur: vec4<f32>,
}
struct CitSlots {
slots: array<CitSlot, 256>,
}
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
@group(1) @binding(1) var cit_blur: sampler;
@group(1) @binding(2) var cit_nearest: sampler;
@group(1) @binding(3) var<uniform> cit: CitSlots;
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
let index = u32(round(in.color.r * 255.0));
let slot = cit.slots[index];
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
if mask < 0.1 { discard; }
// The layer's colour times its intensity (the glint strength option included) in alpha,
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
t.a *= in.color.a;
if t.a < 0.1 { discard; }
return t;
}
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
if c.a == 0.0 { discard; }
return c;
}
// RenderPipelines.VIGNETTE (WinScreen's credits vignette): bilinear sample of
// credits_vignette.png (which has blur: true in its .mcmeta and a wrapped border in the atlas).
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, linear_sampler, in.uv) * in.color;
return vec4(c.rgb, 1.0);
}
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
@@ -0,0 +1,563 @@
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
pub const MAX_SLOTS: usize = 256;
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
const MAX_SIDE: u32 = 1024;
/// The most the texture array takes, in bytes: past it the shared size is halved.
const MAX_BYTES: u64 = 64 << 20;
/// One slot's constants.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
/// The rule's `r`, `g`, `b`, `a`.
pub rgba: [f32; 4],
/// Linear filtering (`blur`), else nearest.
pub blur: bool,
/// An index into `GlintTextures::blends`.
pub blend: u8,
}
/// The enchantment rules' textures, resized to one size.
#[derive(Debug, Default)]
pub struct GlintTextures {
pub width: u32,
pub height: u32,
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
/// split-screen player's renderer builds its own array from it.
pub pixels: Vec<u8>,
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
/// textures that did not load and rules past `MAX_SLOTS`.
pub slot_of: Vec<Option<u8>>,
pub slots: Vec<Slot>,
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
pub blends: Vec<Option<Blend>>,
}
impl GlintTextures {
/// The textures of `rules` under the overlay resource root.
pub fn load(root: &Path, rules: &[Rule]) -> Self {
let mut images = Vec::new();
let mut past = 0;
for (i, rule) in rules.iter().enumerate() {
let Some(path) = rule.texture.as_deref() else {
continue;
};
if rule.kind != Kind::Enchantment {
continue;
}
if images.len() == MAX_SLOTS {
past += 1;
continue;
}
match super::decode(&root.join(path)) {
Ok(image) => images.push((i, image)),
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
}
}
if past > 0 {
log::warn!(
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
);
}
Self::new(rules, images, MAX_BYTES)
}
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
return Self::default();
};
let mut out = Self {
width,
height,
slot_of: vec![None; rules.len()],
..Self::default()
};
for (rule, (w, h, rgba)) in images {
let glint = &rules[rule].glint;
out.pixels
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
Some(i) => i,
None => {
out.blends.push(glint.blend);
out.blends.len() - 1
}
};
out.slot_of[rule] = Some(out.slots.len() as u8);
out.slots.push(Slot {
rgba: glint.rgba,
blur: glint.blur,
blend: blend as u8,
});
}
out
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// The size every slot takes: the largest width and height of `sizes`, each at most
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
fn shared_size(
sizes: impl Iterator<Item = (u32, u32)>,
layers: usize,
budget: u64,
) -> Option<(u32, u32)> {
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
})?;
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
while bytes(w, h) > budget && (w > 1 || h > 1) {
(w, h) = ((w / 2).max(1), (h / 2).max(1));
}
Some((w, h))
}
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
/// the glint repeats over the item.
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
if from == to {
return rgba.to_vec();
}
let ((w, h), (to_w, to_h)) = (from, to);
let texel = |x: i64, y: i64| {
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
let at = (y as usize * w as usize + x as usize) * 4;
&rgba[at..at + 4]
};
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
for y in 0..to_h {
let sy = centre(y, to_h, h);
for x in 0..to_w {
let sx = centre(x, to_w, w);
if !smooth {
let pick = |i: u32, n: u32, size: u32| {
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
};
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
continue;
}
let (fx, fy) = (sx - 0.5, sy - 0.5);
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
let [a, b, c, d] = [
texel(x0, y0),
texel(x0 + 1, y0),
texel(x0, y0 + 1),
texel(x0 + 1, y0 + 1),
];
let lerp =
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
out.extend((0..4).map(|k| {
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
(top * (1.0 - ty) + bottom * ty).round() as u8
}));
}
}
out
}
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
/// degrees, as OptiFine's doc has it.
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
let clock = f64::from(millis) * f64::from(speed);
let x = (clock % 110_000.0 / 110_000.0) as f32;
let y = (clock % 30_000.0 / 30_000.0) as f32;
let (sin, cos) = rotation.to_radians().sin_cos();
let (u, v) = (model[0] * scale, model[1] * scale);
[u * cos - v * sin - x, u * sin + v * cos + y]
}
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
super::rgba(slot, 0, 0, alpha)
}
fn factor(f: Factor) -> wgpu::BlendFactor {
use wgpu::BlendFactor as W;
match f {
Factor::Zero => W::Zero,
Factor::One => W::One,
Factor::SrcColor => W::Src,
Factor::OneMinusSrcColor => W::OneMinusSrc,
Factor::SrcAlpha => W::SrcAlpha,
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
Factor::DstAlpha => W::DstAlpha,
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
Factor::DstColor => W::Dst,
Factor::OneMinusDstColor => W::OneMinusDst,
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
}
}
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
let Some(b) = blend else {
return wgpu::BlendState::REPLACE;
};
let component = |src, dst| wgpu::BlendComponent {
src_factor: factor(src),
dst_factor: factor(dst),
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component(b.src, b.dst),
alpha: component(b.src_alpha, b.dst_alpha),
}
}
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
}
impl CitGlintPass {
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
return None;
}
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
// layers kept sample the last one.
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
let max = device.limits().max_texture_array_layers.max(1);
let slots = textures.slots.len() as u32;
let mut kept = slots.min(max);
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
kept -= 1;
}
if kept < slots {
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
}
let layers = crate::gfx::array_layers(kept, gl).min(max);
let layer_bytes = textures.width as usize * textures.height as usize * 4;
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
if layers != slots {
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
padded.resize(layer_bytes * layers as usize, 0);
data = std::borrow::Cow::Owned(padded);
}
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("CIT glints"),
size: wgpu::Extent3d {
width: textures.width,
height: textures.height,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
// hud.wgsl's `CitSlot`: rgba, then blur in x.
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
for (row, slot) in table.iter_mut().zip(&textures.slots) {
row[..4].copy_from_slice(&slot.rgba);
row[4] = if slot.blur { 1.0 } else { 0.0 };
}
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("CIT glint slots"),
contents: bytemuck::cast_slice(&table),
usage: wgpu::BufferUsages::UNIFORM,
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty,
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("CIT glint bindings"),
entries: &[
entry(
0,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
),
entry(1, filtering),
entry(2, filtering),
entry(
3,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
),
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("CIT glint resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&linear),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
wgpu::BindGroupEntry {
binding: 3,
resource: table.as_entire_binding(),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD (CIT glints)"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("CIT glint layout"),
bind_group_layouts: &[Some(hud), Some(&layout)],
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let pipelines = textures
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cit_glint"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(blend_state(blend)),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
Some(Self { group, pipelines })
}
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
use horizon_optifine::cit::parse_blend;
fn rule(name: &str, text: &str) -> Rule {
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
let text = format!("texture=glint\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
#[test]
fn slots_share_one_size_and_their_blends() {
let rules = [
rule("a", "type=enchantment\nr=0.5"),
rule("item", "items=stick"),
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
rule("c", "type=enchantment"),
];
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
let red = vec![255, 0, 0, 255];
let quad: Vec<u8> = (0..16).collect();
let t = GlintTextures::new(
&rules,
vec![
(0, (1, 1, red)),
(2, (2, 2, quad.clone())),
(3, (2, 2, quad.clone())),
],
MAX_BYTES,
);
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
assert_eq!(&t.pixels[16..32], &quad[..]);
assert_eq!(
t.blends,
[
parse_blend("add").unwrap(),
parse_blend("multiply").unwrap()
]
);
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
assert_eq!(blends, [0, 1, 0]);
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
assert!(!t.slots[1].blur && t.slots[2].blur);
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
}
#[test]
fn big_glints_are_scaled_down_to_the_largest_side() {
let rules = [rule("wide", "type=enchantment\nblur=false")];
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
let wide: Vec<u8> = (0..2048u32)
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
.collect();
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
assert_eq!((t.width, t.height), (1024, 1));
for x in [0u32, 1, 300, 1023] {
let at = x as usize * 4;
let source = 2 * x;
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
}
}
#[test]
fn the_array_is_halved_until_it_fits() {
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
// The largest of each side, never past MAX_SIDE.
let mixed = [(16, 4096), (64, 32)].into_iter();
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
}
#[test]
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
let row = [0, 0, 0, 255, 255, 255, 255, 255];
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
}
#[test]
fn a_layer_vertex_carries_its_slot_and_intensity() {
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
}
#[test]
fn a_still_layer_is_scaled_and_turned() {
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
// No speed: no scroll whatever the clock.
assert!(close(
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
[2.0, 4.0]
));
// A quarter turn takes (1, 0) to (0, 1).
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
assert!(close(
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
[-3.0 / 110.0, 0.1]
));
}
#[test]
fn named_blends_take_their_gl_factors() {
let multiply = blend_state(parse_blend("multiply").unwrap());
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
assert_eq!(
multiply.color.dst_factor,
wgpu::BlendFactor::OneMinusSrcAlpha
);
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
assert_eq!(
blend_state(parse_blend("replace").unwrap()),
wgpu::BlendState::REPLACE
);
}
}
+10
View File
@@ -11,7 +11,10 @@ pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
pub mod breaking;
pub mod brewing;
@@ -20,7 +23,9 @@ pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -35,6 +40,7 @@ pub mod extras;
pub mod fast_hash;
pub mod fog;
pub mod font;
pub mod fusion;
pub mod gfx;
pub mod graphics;
pub mod graphics_style;
@@ -46,6 +52,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -77,6 +84,7 @@ pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod server_terrain;
pub mod settings;
pub mod shader_options;
pub mod shader_pack_list;
@@ -87,7 +95,9 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_map;
pub mod split_profiles;
pub mod split_ui;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+11 -17
View File
@@ -562,11 +562,7 @@ fn pack_lighting(
}
}
}
if data.iter().all(|&v| v == data[0]) {
Light::Uniform(data[0])
} else {
Light::Full(data)
}
Light::packed(data)
})
.collect()
}
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
return;
};
let slot = &mut self.slots[slot];
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
Light::Uniform(v) => Box::new([*v; 4096]),
Light::Full(a) => a.clone(),
});
let light = slot
.light
.get_or_insert_with(|| slot.section.light.to_array());
let i = index(x, y, z);
light[i] = f(light[i]);
}
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
drop(slot.section);
let (cx, s, cz) = slot.key;
self.world
.set_section_light(cx, s as usize, cz, Light::Full(light));
.set_section_light(cx, s as usize, cz, Light::packed(light));
}
}
}
@@ -1624,21 +1619,20 @@ mod tests {
current.set(8, MIN_Y, 8, TORCH);
let region = [[None; 3], [None, Some(&current), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
let Light::Full(data) = &lights[0] else {
panic!("torch light varies");
let storage = |light: &Light| match light {
Light::Full(data) => data.as_ptr(),
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
Light::Uniform(_) => panic!("torch light varies"),
};
let result_storage = data.as_ptr();
let result_storage = storage(&lights[0]);
let snapshot = current.clone();
current.set(8, MIN_Y, 8, STONE);
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
let edited_snapshot = current.clone();
apply_lighting(&mut current, lights);
let Light::Full(data) = &current.sections[0].light else {
panic!("torch light varies");
};
assert_eq!(
result_storage,
data.as_ptr(),
storage(&current.sections[0].light),
"returned light storage moves without cloning"
);
current.lit = true;
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -214,7 +214,12 @@ impl Mesher {
Paint {
layer: face.layer,
light: span.light,
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
// The HDR tier, as the section mesher writes it, so a light source
// keeps shining across the LOD boundary.
color: crate::mesher::emitting(
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
context.tables.hdr_tier(span.state),
),
translucent: face.translucent,
}
})
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+428 -142
View File
@@ -10,7 +10,7 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -50,6 +50,9 @@ pub struct Vertex {
/// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls
/// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them.
pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
pub const SHADED_CULL_W: i8 = -126;
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
@@ -73,6 +76,15 @@ pub struct TerrainVertex {
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
pub color: u32,
}
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
/// baseline instruction set makes of `f32::round`: clamped first (the same
/// result), the fraction is exact below 2^23, and every float above is whole.
#[inline]
fn round_to_u16(v: f32) -> u16 {
let v = v.clamp(0.0, f32::from(u16::MAX));
let whole = v as u32;
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
@@ -82,7 +94,7 @@ impl TerrainVertex {
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
layer,
light,
color,
@@ -93,7 +105,7 @@ impl TerrainVertex {
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
layer,
light,
color,
@@ -116,6 +128,30 @@ mod vertex_tests {
}
}
#[test]
fn uv_rounding_matches_f32_round() {
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
1.5,
65534.5,
1e9,
];
for bits in (0..u32::MAX).step_by(9973) {
values.push(f32::from_bits(bits));
}
for i in 0..=140_000 {
values.push(i as f32 * 0.5 - 2.0);
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
}
for v in values {
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
}
}
#[test]
fn other_uvs_round_within_a_step() {
// A flowing fluid's rotated UV.
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
@@ -283,7 +319,7 @@ impl SectionMesh {
pub const BOUNDS_PAD: f32 = 0.5;
/// Retain geometry capacity after an upload, but none of its old contents.
pub(crate) fn clear(&mut self) {
pub fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
@@ -333,94 +369,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
solid_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
let mut opaque_count = 0;
for y in 0..16 {
for z in 0..16 {
let row = pi(0, y, z);
for x in 0..16 {
let opaque = info[pad.blocks[row + x] as usize].solid;
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
opaque_count += usize::from(opaque);
}
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
/// occluded fill each way, doubling its reach at each step.
#[inline]
fn fill_row(seeds: u16, open: u16) -> u16 {
let (mut up, mut through_up) = (seeds, open);
let (mut down, mut through_down) = (seeds, open);
for step in [1, 2, 4, 8] {
up |= through_up & up << step;
through_up &= through_up << step;
down |= through_down & down >> step;
through_down &= through_down >> step;
}
if opaque_count == 0 {
up | down
}
/// `visibility` on bit rows, one region at a time: a region grows a row at
/// a time (each row filled along its open runs at once) by sweeps over the
/// rows it can reach, until a sweep adds nothing.
fn visibility_rows(open: &[u16; 256]) -> u64 {
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
if open_count == 4096 {
return ALL_VISIBLE;
}
if opaque_count == 4096 {
if open_count == 0 {
return 0;
}
let mut unvisited = *open;
let mut region = [0u16; 256];
let mut result = 0u64;
for start in 0..4096usize {
if seen[start] {
continue;
}
let mut faces = 0u8;
seen[start] = true;
stack.push(start);
while let Some(c) = stack.pop() {
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
if y == 0 {
faces |= 1 << 0;
for start in 0..256 {
while unvisited[start] != 0 {
let seed = unvisited[start] & unvisited[start].wrapping_neg();
region[start] = fill_row(seed, unvisited[start]);
let (mut lo, mut hi) = (start, start);
loop {
let mut grown = false;
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
for r in (first..=last).chain((first..=last).rev()) {
let mut near = region[r];
if r >= 16 {
near |= region[r - 16];
}
if r + 16 < 256 {
near |= region[r + 16];
}
if r & 15 != 0 {
near |= region[r - 1];
}
if r & 15 != 15 {
near |= region[r + 1];
}
let reached = near & unvisited[r];
if reached & !region[r] != 0 {
region[r] = fill_row(reached, unvisited[r]);
(lo, hi) = (lo.min(r), hi.max(r));
grown = true;
}
}
if !grown {
break;
}
}
if y == 15 {
faces |= 1 << 1;
}
if z == 0 {
faces |= 1 << 2;
}
if z == 15 {
faces |= 1 << 3;
}
if x == 0 {
faces |= 1 << 4;
}
if x == 15 {
faces |= 1 << 5;
let mut faces = 0u8;
for r in lo..=hi {
let row = std::mem::take(&mut region[r]);
if row == 0 {
continue;
}
unvisited[r] &= !row;
let (y, z) = (r >> 4, r & 15);
faces |= u8::from(y == 0)
| u8::from(y == 15) << 1
| u8::from(z == 0) << 2
| u8::from(z == 15) << 3
| u8::from(row & 1 != 0) << 4
| u8::from(row & 0x8000 != 0) << 5;
}
// A single connected region touching all six faces sets every
// pair (including the diagonal); unvisited cavities cannot add more.
if faces == 0b11_1111 {
return ALL_VISIBLE;
}
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
continue;
}
let n = (ny << 8 | nz << 4 | nx) as usize;
if !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
}
}
}
}
}
if result == ALL_VISIBLE {
return result;
if result == ALL_VISIBLE {
return result;
}
}
}
result
@@ -505,6 +559,13 @@ pub struct MeshFacts {
}
pub struct MeshTables {
info: Vec<Info>,
/// `Info::solid` by state, packed apart: the opacity mask of a section
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
/// By state, bit `d` set where its occlusion face toward direction `d` is the
/// full block (`Info::face` 1): a face looking at it never shows.
full_faces: Vec<u8>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -673,6 +734,11 @@ impl MeshTables {
});
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
full_faces: info
.iter()
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
.collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -690,6 +756,10 @@ impl MeshTables {
.copied()
.unwrap_or(false)
}
/// HDR output's tier of a state (`hdr_tier`), for what meshes it elsewhere (the LOD tiles).
pub fn hdr_tier(&self, state: BlockStateId) -> u8 {
self.info.get(state.0 as usize).map_or(0, |i| i.tier)
}
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
let i = self.info.get(state.0 as usize)?;
Some(MeshFacts {
@@ -842,63 +912,137 @@ impl Padded {
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
}
for y in -B..16 + B {
let wy = base_y + y;
// Where each chunk column's cells of this layer come from, found once.
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
let i = pi(x, y, z);
if let Some(c) = chunk {
blocks[i] =
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
{
own[(y << 8 | z << 4 | x) as usize]
} else {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
light[i] = 0xF0;
}
}
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
let (middle, east) = rest.split_at_mut(16);
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
let (middle_light, east_light) = rest.split_at_mut(16);
let own_row = (rz == 1 && (0..16).contains(&y))
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
let at = |x: i32| crate::chunk::index(x, wy, z);
let [w, m, e] = &sources[rz];
let edge = (center, wy, z);
fill_run(west, west_light, w, None, at(-B), -B, edge);
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
fill_run(east, east_light, e, None, at(16), 16, edge);
}
}
}
}
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
enum Source<'a> {
/// A neighbour not loaded.
Missing,
/// Above or below the column's sections: air with this light.
Outside(u8),
Section(&'a crate::chunk::Section),
}
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
/// at section index `at`; `own` is the centre section's row, already unpacked.
#[inline(always)]
fn fill_run(
blocks: &mut [u32],
light: &mut [u8],
source: &Source,
own: Option<&[u32]>,
at: usize,
x0: i32,
(center, wy, z): (Option<&Chunk>, i32, i32),
) {
match source {
Source::Section(section) => {
match own {
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
section.light.copy_run(at, light);
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -990,6 +1134,12 @@ enum Resolver {
/// layer plus the blend radius around it, and the blended colours per column.
struct Layer {
zoomed: Vec<u16>,
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
/// a cell is in the blend square of up to `(2r+1)^2` columns.
cells: [Vec<u32>; 4],
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
/// their offset from the first: each is shared by up to 16 cells of the layer.
jitter: Vec<Option<[[f64; 3]; 8]>>,
colors: [[u32; 256]; 4],
}
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
@@ -1005,6 +1155,8 @@ struct Tints<'a> {
/// Chunk x, chunk z and the section's lowest block y.
origin: [i32; 3],
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1022,6 +1174,7 @@ impl<'a> Tints<'a> {
plains: biomes.id("minecraft:plains"),
origin,
layers: Default::default(),
uniform: None,
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
@@ -1041,6 +1194,16 @@ impl<'a> Tints<'a> {
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
Self::quart_biome(region, plains, [cx, cz], q)
}
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
/// there is none.
fn quart_biome(
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz]: [i32; 2],
q: [i32; 3],
) -> u16 {
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
@@ -1050,13 +1213,59 @@ impl<'a> Tints<'a> {
plains
}
}
/// The biome every block of the section's blend squares zooms to, when they
/// all do: `quart_at_block` picks one of the eight quart cells at its base
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
/// holds one biome, every zoomed cell is that biome.
fn uniform_biome(&mut self) -> Option<u16> {
if let Some(uniform) = self.uniform {
return uniform;
}
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
// The base cells of blocks lo..=hi, and the cell past the last.
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
let (xs, ys, zs) = (
span(cx * 16 - r, cx * 16 + 15 + r),
span(base_y, base_y + 15),
span(cz * 16 - r, cz * 16 + 15 + r),
);
let first = Self::quart_biome(
self.region,
self.plains,
[cx, cz],
[*xs.start(), *ys.start(), *zs.start()],
);
let uniform = ys
.flat_map(|y| zs.clone().map(move |z| (y, z)))
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
.then_some(first);
self.uniform = Some(uniform);
uniform
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
// One biome over every blend square: the mean of (2r+1)^2 equal
// colours is that colour, unless it varies by position (swamp grass).
if let Some(biome) = self.uniform_biome() {
let info = self.biomes.get(biome);
match resolver {
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
}
Resolver::Grass => {}
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
Resolver::Water => return info.water & 0xFF_FFFF,
}
}
let r = self.radius;
let dim = (16 + 2 * r) as usize;
let layer = self.layers[y as usize].get_or_insert_with(|| {
Box::new(Layer {
zoomed: vec![NO_BIOME; dim * dim],
cells: Default::default(),
jitter: Vec::new(),
colors: [[UNSET; 256]; 4],
})
});
@@ -1065,21 +1274,45 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
let cells = &mut layer.cells[resolver as usize];
if cells.is_empty() {
cells.resize(dim * dim, UNSET);
}
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let known = cells[cell];
if known != UNSET {
red += known >> 16 & 255;
green += known >> 8 & 255;
blue += known & 255;
continue;
}
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none): `quart_at_block`, its cell's
// corner jitter mixed once per layer.
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
let span = dim / 4 + 2;
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
if layer.jitter.is_empty() {
layer.jitter.resize(span * span, None);
}
let jitter =
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
let corner = BiomeZoom::select_corner(block, jitter);
let q = [
base[0].wrapping_add((corner >> 2 & 1) as i32),
base[1].wrapping_add((corner >> 1 & 1) as i32),
base[2].wrapping_add((corner & 1) as i32),
];
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1091,6 +1324,7 @@ impl<'a> Tints<'a> {
Resolver::Dry => self.biomes.get(biome).dry_foliage,
Resolver::Water => self.biomes.get(biome).water,
};
cells[cell] = c;
red += c >> 16 & 255;
green += c >> 8 & 255;
blue += c & 255;
@@ -1116,6 +1350,8 @@ pub struct Mesher<'a> {
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1144,7 +1380,9 @@ impl Mesher<'_> {
&mut MeshScratch::default(),
)
}
pub(crate) fn mesh_reusing(
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
/// rather than allocated per section.
pub fn mesh_reusing(
&mut self,
region: &[[Option<&Chunk>; 3]; 3],
sy: i32,
@@ -1160,7 +1398,9 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1175,6 +1415,7 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
@@ -1193,17 +1434,16 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
})
{
if hidden(opaque, x, y, z) {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let shade = (self.nether_shading, self.shader_pack_lighting);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
self.separate_ao,
);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
}
if !state.model {
@@ -1237,6 +1477,11 @@ impl Mesher<'_> {
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
@@ -1346,27 +1591,57 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
(gray, coords)
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
};
(gray, coords, brightness)
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
};
([lighting::gray(directional); 4], [coords; 4])
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
// drawn over it (a few per face at most; more are dropped).
let connected = &self.render.connected;
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
// and the overlays drawn over it (a few per face at most; more are dropped).
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
let mut overlays = [None; 8];
let mut count = 0;
let pick = if connected.is_empty() {
let mut push = |overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
id,
CtmFace::ALL[q.facing as usize],
q.tint_index,
neighbour,
|s| {
self.tables
.info
.get(s as usize)
.is_some_and(|i| i.opaque_cube)
},
&mut push,
)
});
let pick = if let Some(pick) = fused {
pick
} else if connected.is_empty() {
Pick::Keep
} else {
let tints = &*tints;
@@ -1375,14 +1650,17 @@ impl Mesher<'_> {
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
neighbour,
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
count += 1;
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|| {
if state.pane {
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
} else {
CtmAxes::texture(&q.positions, &q.uv)
}
},
&mut push,
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
@@ -1393,12 +1671,18 @@ impl Mesher<'_> {
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
color,
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
uv[i],
@@ -1452,6 +1736,7 @@ impl Mesher<'_> {
}
}
}
Pick::Nothing => {}
}
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
@@ -1464,6 +1749,7 @@ impl Mesher<'_> {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting): (bool, bool),
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
if separate_ao {
pack_rgb(rgb)
} else {
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
sprite_layers.push(layer);
}
let color = fluid_face(color, face);
let color = if separate_ao {
color
} else {
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
@@ -72,6 +72,7 @@ fn mesh(
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
};
channel(0) | channel(16) << 16
}
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
/// one lower where that rounded a negative value up (saturating as `as` does).
pub(super) fn gray(brightness: f32) -> i32 {
(brightness * 255.0).floor() as i32
let v = brightness * 255.0;
let whole = v as i32;
if whole as f32 > v {
whole.saturating_sub(1)
} else {
whole
}
}
/// One channel of `ARGB.scaleRGB(color, float)`.
pub(super) fn scale(channel: i32, by: f32) -> i32 {
@@ -406,6 +414,28 @@ mod tests {
);
}
#[test]
fn gray_floors_like_f32_floor() {
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
1.0,
-1.0,
1e10,
-1e10,
];
for bits in (0..u32::MAX).step_by(7919) {
values.push(f32::from_bits(bits));
}
for i in -3000..3000 {
values.push(i as f32 / 1024.0);
}
for v in values {
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
}
}
#[test]
fn colour_arithmetic_matches_argb_helpers() {
assert_eq!(gray(1.0), 255);
assert_eq!(gray(0.8), 204);
@@ -1,4 +1,5 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
@@ -45,6 +46,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -72,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -98,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
// Place stone with neighbouring blocks to create AO variations.
chunk.set(8, MIN_Y + 8, 8, stone);
chunk.set(9, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: true,
separate_ao: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
assert!(!mesh.solid.is_empty());
for v in &mesh.solid {
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
// Alpha holds the AO factor, which must be non-zero.
assert!(v.color >> 24 > 0);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -149,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -181,7 +291,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
+124 -32
View File
@@ -203,13 +203,37 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
@@ -218,15 +242,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let slots = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(&slots) {
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
@@ -246,33 +269,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
@@ -286,6 +310,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
@@ -522,6 +522,7 @@ mod tests {
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
let mut world = world_with(
(-2..=2)
@@ -609,6 +610,7 @@ mod tests {
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
// Grass everywhere, one stone block 10 blocks north of the player.
let mut world = world_with(
@@ -44,7 +44,7 @@ impl ParsedModels {
}
}
let parsed = crate::par::par_map(&files, |(_, path)| {
let text = fs::read_to_string(path).ok()?;
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
CuboidModel::from_json(&text).ok().map(Arc::new)
});
let models = files
@@ -89,8 +89,8 @@ impl ParsedModels {
let read = || -> Result<CuboidModel> {
let (namespace, path) =
name.split_once(':').ok_or("invalid model identifier")?;
let text = fs::read_to_string(
root.join(format!("assets/{namespace}/models/{path}.json")),
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/models/{path}.json")),
)?;
Ok(CuboidModel::from_json(&text)?)
};
+10 -1
View File
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
.state(state)
.ok_or("missing gallery registry state")?;
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
let selected = catalog.collect(state, position)?;
// A state no definition binds (a pack's partial blockstate file
// without vanilla's below it) is left out, as the mesher skips it,
// instead of failing the whole pack.
let selected = match catalog.collect(state, position) {
Ok(selected) => selected,
Err(e) => {
log::warn!("Horizon: gallery block {name} has no model: {e}");
continue;
}
};
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
"position":[position.x,position.y,position.z],
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
@@ -33,6 +33,9 @@ pub struct BakedFace {
pub opaque: bool,
/// Fractional alpha: drawn in the sorted translucent pass.
pub translucent: bool,
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
/// two bytes, within the padding every face already has.
pub fusion: Option<std::num::NonZeroU16>,
}
pub struct BakedModel {
pub ambient_occlusion: bool,
@@ -90,6 +93,7 @@ fn bake_resolved(
opaque: sprites.region_opaque(layer, &quad.uv),
quad,
layer,
fusion: None,
})
})
.collect::<Result<_>>()?;
@@ -101,6 +105,35 @@ fn bake_resolved(
cube,
})
}
/// The definitions a blockstate file holds, bottom first: the layers of a
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
/// file itself.
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
return Ok(vec![text.to_owned()]);
}
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
_ => Ok(vec![text.to_owned()]),
}
}
/// Each state's model from definitions bound bottom first: the topmost one
/// that binds the state, as (its layer, its selection). A higher pack's
/// definition replaces only the states it binds (`putAll` in
/// `loadBlockStateDefinitionStack`).
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
for (layer, bound) in layers.into_iter().enumerate() {
picked.resize(picked.len().max(bound.len()), None);
for (slot, selected) in picked.iter_mut().zip(bound) {
if let Some(selected) = selected {
*slot = Some((layer, selected));
}
}
}
picked
}
impl StateModels {
pub fn load(
root: &Path,
@@ -190,21 +223,17 @@ impl StateModels {
.collect::<Result<_>>()?;
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
// Vanilla's BlockStateModelLoader: a definition that does not
// read or bind is logged and its block gets no model; the other
// blocks still load.
let loaded = fs::read_to_string(
root.join(format!("assets/{namespace}/blockstates/{path}.json")),
// read or bind is logged and left out; with none left the block
// gets no model, and the other blocks still load. A file several
// packs provide binds each pack's definition on its own, bottom
// first (`loadBlockStateDefinitionStack`).
let layers = match horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
)
.map_err(|e| e.to_string())
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
let (definition, bound) = match loaded {
Ok(loaded) => loaded,
.and_then(|text| definition_layers(&text))
{
Ok(layers) => layers,
Err(e) => {
log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
@@ -212,6 +241,26 @@ impl StateModels {
continue;
}
};
let mut loaded = Vec::with_capacity(layers.len());
for text in &layers {
let layer = BlockStateDefinition::from_json(text)
.map_err(|e| e.to_string())
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
match layer {
Ok(layer) => loaded.push(layer),
Err(e) => log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
),
}
}
if loaded.is_empty() {
continue;
}
let native_order: Vec<_> = by_name
.get(name.as_str())
.map(Vec::as_slice)
@@ -229,32 +278,36 @@ impl StateModels {
"model state definitions disagree with the imported block registry".into(),
);
}
for ((id, _), selected) in native_order.into_iter().zip(bound) {
if let Some(selected) = selected {
states[id.0 as usize] = Some((choices.len(), selected));
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
let first = choices.len();
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
if let Some((layer, selected)) = picked {
states[id.0 as usize] = Some((first + layer, selected));
}
}
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
for definition in definitions {
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
choices.push(CompiledChoices::new(&definition)?);
}
choices.push(CompiledChoices::new(&definition)?);
}
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
let library = match models.block_library(root, names.clone()) {
@@ -343,6 +396,12 @@ impl StateModels {
}
Ok(())
}
/// Every baked model with the variant it was baked from, to change its faces.
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
self.baked
.iter_mut()
.map(|(variant, model)| (&*variant, model))
}
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
let &i = self.standalone.get(name)?;
@@ -418,6 +477,44 @@ mod tests {
use super::*;
use crate::model_library::ParsedModels;
/// #7013: a pack's blockstate file that defines only some states (here
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
/// rest, as vanilla binds each pack's definition and lets a higher one
/// replace only what it binds.
#[test]
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
let schema: PropertySchema = BTreeMap::from([(
"powered".to_owned(),
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
)]);
let states: Vec<Properties> = ["false", "true"]
.iter()
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
.collect();
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
]})
.to_string();
let layers = definition_layers(&stacked).unwrap();
assert_eq!(layers.len(), 2);
let bound = layers
.iter()
.map(|text| {
BlockStateDefinition::from_json(text)
.unwrap()
.bind(&schema, &states)
.unwrap()
})
.collect();
let picked = topmost(bound);
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
// A file that isn't stacked is one definition.
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
}
struct Fixture(std::path::PathBuf);
impl Fixture {
fn write(&self, name: &str, json: &str) {
@@ -119,6 +119,7 @@ mod tests {
layer: 7,
opaque: true,
translucent: false,
fusion: None,
})
.collect();
let Some(styles) = cube_styles(&faces) else {
@@ -153,6 +154,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
})
.collect();
faces[0].opaque = false;
@@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
}
}
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
fn tile_file(name: &str) -> Option<&str> {
name.strip_prefix("assets/")?;
name.strip_suffix(".png")
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
@@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
for layer in sprites.layers.values() {
*sharing.entry(*layer).or_default() += 1;
}
let bases: Vec<String> = sprites
let mut bases: Vec<String> = sprites
.layers
.iter()
.filter(|(name, layer)| {
sharing[layer] == 1
&& name
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
let mut pairs = Vec::new();
for base in bases {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
// A tile is named by its file, a block sprite by its id.
let (overlay, file) = match tile_file(&base) {
Some(stem) => {
let overlay = format!("{stem}{suffix}.png");
(overlay.clone(), root.join(overlay))
}
None => {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
(overlay, file)
}
};
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
continue;
}
@@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
continue;
}
// A file that doesn't load would glow as the missing texture: no pair instead.
match sprites.try_insert(root, &overlay) {
let loaded = if overlay.starts_with("assets/") {
sprites.insert_file(root, &overlay)
} else {
sprites.try_insert(root, &overlay)
};
match loaded {
Ok(_) => pairs.push((base, overlay)),
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
}
@@ -192,13 +218,40 @@ mod tests {
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
png(&tiles.join("0_glow.png"), 240);
png(&tiles.join("1.png"), 250);
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
png(
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
246,
);
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
sprites
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
)]
// Blocks first, then tiles.
[
(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
),
(
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
),
(
"assets/minecraft/optifine/glass/2.png".into(),
"assets/minecraft/optifine/glass/2_glow.png".into()
),
]
);
std::fs::remove_dir_all(&root).unwrap();
}
+319 -27
View File
@@ -126,7 +126,6 @@ impl Opt {
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::RotateWithMinecarts
// Saved, but no Horizon system reads them yet: no Realms, no
// friends list, no directional audio, no now-playing toast.
| Opt::RealmsNotifications
@@ -937,6 +936,7 @@ impl Opt {
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::DirectionalAudio
| Opt::RotateWithMinecarts
| Opt::NarratorHotkey => {
let key = self.file_key().unwrap();
s.file
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1308,6 +1308,10 @@ enum Act {
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
CopyAgentDetails,
/// AI Agent: start or stop the repro recording.
ToggleRecording,
/// AI Agent: write the repro bundle now.
ExportRepro,
}
enum WKind {
Button {
@@ -1435,6 +1439,10 @@ pub enum Event {
Credits,
/// Copy how an agent connects (the AI Agent page).
CopyAgentDetails,
/// The AI Agent page's recording button: start or stop.
ToggleRecording,
/// The AI Agent page's export button.
ExportRepro,
/// The narrator option changed (after its `Changed`): the option's
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
Narrator,
@@ -1504,6 +1512,8 @@ pub struct OptionsUi {
/// (`update_pack_infos`) differs from the settings'.
pack_reselected: bool,
pub in_world: bool,
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
pub minecart_improvements: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
@@ -1512,10 +1522,17 @@ pub struct OptionsUi {
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// A repro recording is running: the AI Agent page's button says stop.
pub agent_recording: bool,
/// Keyboard focus: the interactive target currently focused.
focus: Option<FocusId>,
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
/// from anywhere (`wheel`). A click focuses its widget too, for the highlight,
/// but vanilla's wheel ignores that: only the hovered widget then reacts.
focus_from_keyboard: bool,
/// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
pub(crate) can_change_value: bool,
/// The current GUI size (w, h).
@@ -1528,8 +1545,6 @@ 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 {
@@ -1563,19 +1578,22 @@ impl Default for OptionsUi {
pack_focus: Instant::now(),
pack_reselected: false,
in_world: false,
minecart_improvements: false,
center_language: false,
graphics: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
agent_recording: false,
focus: None,
focus_from_keyboard: false,
can_change_value: false,
size: (427.0, 240.0),
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -2093,6 +2111,7 @@ impl OptionsUi {
self.stack.clear();
self.data_pack_dir = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(page);
self.in_world = in_world;
@@ -2103,6 +2122,7 @@ impl OptionsUi {
self.scroll = 0.0;
self.scrollbar_drag_y = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
self.settings_search.set_value("");
@@ -2122,6 +2142,9 @@ impl OptionsUi {
) {
self.graphics.reset();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
@@ -2262,14 +2285,7 @@ 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::SplitSetup => Vec::new(),
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2376,6 +2392,30 @@ impl OptionsUi {
Act::CopyAgentDetails,
)),
)];
rows.push(Row::Buttons(
button(
0.0,
0.0,
150.0,
tr(if self.agent_recording {
"horizon.agent.record_stop"
} else {
"horizon.agent.record_session"
}),
Act::ToggleRecording,
),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.agent.export_repro"),
Act::ExportRepro,
)),
));
rows.push(Row::Buttons(
self.extra_button(s, crate::extras::Item::ReproKeys),
None,
));
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
rows
}
@@ -3199,6 +3239,14 @@ impl OptionsUi {
if widget.list {
widget.y += previous_scroll - self.scroll;
}
if let WKind::Button {
act: Act::Cycle(Opt::RotateWithMinecarts),
active,
..
} = &mut widget.kind
{
*active = self.in_world && self.minecart_improvements;
}
}
self.row_bands = rows
.bands
@@ -3346,6 +3394,13 @@ impl OptionsUi {
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
self.split_setup.size = (batch.width, batch.height);
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
@@ -3381,6 +3436,9 @@ impl OptionsUi {
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
}
let mut tooltip = None;
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
// keeps it against that widget, not at the pointer.
let mut tooltip_rect: Option<[f32; 4]> = None;
for (wd_idx, wd) in widgets.iter().enumerate() {
if !visible(wd) {
continue;
@@ -3390,9 +3448,9 @@ impl OptionsUi {
.as_ref()
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
let start = batch.vertices.len();
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|| is_focused;
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
let hover = pointer_over || is_focused;
// A pack's option: its label within the widget (the name shortened), and on hover
// the whole label when it was, then what the pack says of it.
let shader = s.shader_options.as_ref();
@@ -3436,6 +3494,7 @@ impl OptionsUi {
.collect();
if !lines.is_empty() {
tooltip = Some(lines);
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
}
if hover && let Some(tip) = tooltip_text(s, wd) {
@@ -3450,6 +3509,7 @@ impl OptionsUi {
.collect::<Vec<_>>(),
_ => atlas.font.split(&tip, 170.0),
});
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
@@ -3621,7 +3681,10 @@ impl OptionsUi {
self.settings_search = search;
}
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
match tooltip_rect {
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
None => ui::tooltip(batch, atlas, &lines, cursor),
}
}
}
/// Mouse press/drag. Returns events for the game.
@@ -3646,6 +3709,26 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::SplitSetup {
use crate::split_ui::Click;
if right {
return Vec::new();
}
self.split_setup.on = s.extras.splitscreen;
return match self.split_setup.click((x, y), size, drag) {
Click::Nothing => Vec::new(),
Click::Pressed => vec![Event::ButtonClick],
Click::Switch => {
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
vec![Event::ButtonClick, Event::Changed]
}
Click::Done => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::SplitProfiles {
use crate::split_profiles::Click;
if right || drag {
@@ -3750,6 +3833,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3762,6 +3846,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3875,7 +3960,10 @@ impl OptionsUi {
if wd.list && (y < top || y >= bottom) {
continue;
}
// A click focuses the widget for its highlight only: vanilla's wheel
// steps a keyboard-focused widget from anywhere, not a clicked one.
self.focus = Some(focus_id(wd_idx, &wd));
self.focus_from_keyboard = false;
match wd.kind {
WKind::Button {
active: true, act, ..
@@ -3994,6 +4082,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
}
@@ -4049,8 +4139,11 @@ impl OptionsUi {
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
} else if split_drop {
vec![Event::ButtonClick]
} else {
Vec::new()
}
@@ -4095,15 +4188,19 @@ impl OptionsUi {
}
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
/// over a slider it nudges the slider up or down.
/// The mouse wheel at the cursor. Vanilla scrolls the list
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered cycle button
/// takes the step and keeps the wheel (`CycleButton.mouseScrolled`: up goes
/// back); `AbstractSliderButton` does not override it, so a slider under the
/// cursor lets the list scroll.
///
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle button is keyboard-focused (highlighted), the wheel steps or
/// cycles that widget regardless of where the cursor is. Vanilla 26.3
/// does not intercept the wheel for sliders at all; this makes keyboard
/// navigation far more usable.
/// **Horizon improvement (diverges from Vanilla 26.3):** a slider or cycle
/// button focused by the keyboard (Tab, the arrows, a pad) steps or cycles on
/// the wheel wherever the cursor is. Vanilla 26.3 does not intercept the wheel
/// for sliders at all; this makes keyboard navigation far more usable. Focus
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4149,13 +4246,17 @@ impl OptionsUi {
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
if !plain || !s.extras.wheel_cycles_settings {
self.scroll_at(s, size, cursor, amount);
return Vec::new();
}
// Horizon improvement: a keyboard-focused slider or cycle button
// intercepts the wheel before hover detection (see doc-comment above).
if amount != 0.0 {
// A click focuses its widget for the highlight too, and vanilla's wheel
// must not act on that: it would toggle the clicked option from anywhere
// on the screen and the list would never scroll.
if amount != 0.0 && self.focus_from_keyboard {
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
match &self.focus {
Some(FocusId::Slider(opt)) => {
@@ -4341,6 +4442,7 @@ impl OptionsUi {
self.selected_key = None;
self.selected_pad_action = None;
self.focus = None;
self.focus_from_keyboard = false;
self.settings_search.focused = false;
self.language_search.focused = false;
self.can_change_value = false;
@@ -4684,6 +4786,7 @@ impl OptionsUi {
};
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
if *in_list {
if rect[1] < top {
@@ -4722,6 +4825,7 @@ impl OptionsUi {
if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4735,6 +4839,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4757,6 +4862,7 @@ impl OptionsUi {
if let Some(next_idx) = step {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4774,6 +4880,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4797,6 +4904,7 @@ impl OptionsUi {
match act {
Act::Push(p) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if p == Page::ShaderPacks {
events.push(Event::ListShaderPacks);
@@ -4805,6 +4913,7 @@ impl OptionsUi {
}
Act::Done => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
events.extend(self.back(s));
}
@@ -4844,6 +4953,7 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
@@ -4868,6 +4978,7 @@ impl OptionsUi {
// The shader options page's buttons, as a click does them.
Act::ShaderOptions => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
s.shader_options = None;
self.push(Page::ShaderOptions);
@@ -4885,6 +4996,7 @@ impl OptionsUi {
Act::ShaderScreen(screen) => {
if let Some(options) = &mut s.shader_options {
self.focus = None;
self.focus_from_keyboard = false;
options.path.push(screen);
self.scroll = 0.0;
}
@@ -4917,6 +5029,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
return events;
@@ -5345,6 +5459,61 @@ mod tests {
assert!(ui.resource_error.is_none());
}
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
let s = Settings::default();
lang::scoped(lang::Language::load(&[], code), || {
let mut texts = Vec::new();
for page in pages {
let mut ui = OptionsUi::default();
ui.show(*page);
for w in ui.layout(&s, 480.0, 4000.0).0 {
match w.kind {
WKind::Button { label: t, .. }
| WKind::Slider { label: t, .. }
| WKind::Text { text: t, .. } => texts.push(t.plain()),
_ => {}
}
}
}
texts
})
}
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
/// "Hotbar: Previous Slot" in every language (#1927).
#[test]
fn horizon_option_pages_follow_the_language() {
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
for want in [
"Splitscreen …",
"Partikel",
"Blockpartikel weg: Aus",
"Weicher Chat: Aus",
"Nachrichtenmarkierung: An",
"Kanäle für UI-Töne: Aus",
"Schnellzugriffsleiste: Vorheriger Platz",
"Schnellzugriffsleiste: Nächster Platz",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
// language does not have.
let missed: Vec<_> = missed
.into_iter()
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
.collect();
assert_eq!(missed, Vec::<String>::new());
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
for want in [
"Ukryj cząstki bloków: Wył.",
"Pasek szybkiego wyboru: poprzednie pole",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
}
#[test]
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
let atlas = atlas();
@@ -7092,6 +7261,129 @@ mod tests {
assert_eq!(s.horizon.chunk_updates, 11);
}
/// Vanilla's wheel scrolls the list (`AbstractScrollArea.mouseScrolled`); only a
/// widget focused by the keyboard takes the step from anywhere on the screen.
/// A click focuses its widget for the highlight, and honouring that focus made a
/// clicked option toggle on every later wheel event while the list never scrolled.
#[test]
fn a_clicked_option_lets_the_wheel_scroll_the_list() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let framebuffer = [854, 480];
let click_size = (427.0, 400.0);
let (widgets, click_top, click_bottom, _) = ui.layout(&s, click_size.0, click_size.1);
// A click leaves the widget focused for its highlight, not for the wheel.
let wd = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Cycle(_),
..
}
) && (!wd.list || (wd.y >= click_top && wd.y + wd.h <= click_bottom))
})
.expect("a visible cycle button");
ui.click(
&mut s,
&atlas,
wd.x + 5.0,
wd.y + 5.0,
click_size,
framebuffer,
false,
false,
);
assert!(
matches!(ui.focus, Some(FocusId::Cycle(_))),
"the click focuses the cycle button it hit"
);
assert!(
!ui.focus_from_keyboard,
"a click does not leave keyboard focus"
);
// The wheel over the list's empty margin scrolls it at a size where the
// page overflows, and the clicked cycle option stays put: no ButtonClick,
// no Changed.
let size = (427.0, 240.0);
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
assert!(
content > bottom - top,
"the video page scrolls at this size"
);
let before = s.view_bobbing;
let start = ui.scroll;
let cursor = (2.0, (top + bottom) / 2.0);
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_eq!(s.view_bobbing, before, "the clicked option does not toggle");
assert!(!events.contains(&Event::ButtonClick), "no click reaches it");
assert!(!events.contains(&Event::Changed));
assert!(ui.scroll > start, "the list scrolls down instead");
// Keyboard focus keeps the documented divergence: the wheel steps it
// wherever the cursor is, even off the widget.
ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
ui.focus_from_keyboard = true;
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_ne!(s.view_bobbing, before, "a keyboard-focused option cycles");
assert!(events.contains(&Event::ButtonClick));
assert!(events.contains(&Event::Changed));
}
#[test]
fn the_wheel_only_scrolls_with_the_extra_off() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let framebuffer = [854, 480];
let size = (427.0, 240.0);
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
assert!(
content > bottom - top,
"the video page scrolls at this size"
);
let (opt, over) = widgets
.iter()
.find_map(|wd| match wd.kind {
WKind::Button {
act: Act::Cycle(opt),
..
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
}
_ => None,
})
.expect("a visible cycle button");
s.extras.wheel_cycles_settings = false;
let before = format!("{s:?}");
let start = ui.scroll;
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
assert!(!events.contains(&Event::Changed));
assert!(
ui.scroll > start,
"the list scrolls under the cursor instead"
);
// Keyboard focus doesn't take the wheel either.
ui.focus = Some(FocusId::Cycle(opt));
ui.focus_from_keyboard = true;
ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_eq!(format!("{s:?}"), before);
// On (the default), vanilla's CycleButton.mouseScrolled again.
s.extras.wheel_cycles_settings = true;
let before = format!("{s:?}");
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
assert!(events.contains(&Event::Changed));
}
#[test]
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
let stepped_opts = [
+47 -2
View File
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
}
for name in names {
let child = format!("{prefix}{name}");
if hidden.contains(&child) {
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
continue;
}
if let Some(bytes) = merged.get(&child) {
@@ -840,7 +841,14 @@ enum Merge {
Font,
/// `SpriteSourceList.load`: sources from the bottom pack up.
Atlas,
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
/// definition binds on its own, and a higher one replaces only the states
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
BlockStates,
}
/// The key of a stacked blockstate file: its definitions, bottom first, each
/// as the pack wrote it.
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
fn merge_kind(name: &str) -> Option<Merge> {
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
if rest == "sounds.json" {
@@ -851,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
"lang" => Some(Merge::Lang),
"font" => Some(Merge::Font),
"atlases" => Some(Merge::Atlas),
"blockstates" => Some(Merge::BlockStates),
_ => None,
}
}
@@ -858,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
use serde_json::{Map, Value};
let parsed: Vec<Map<String, Value>> = layers
let mut parsed: Vec<Map<String, Value>> = layers
.iter()
// SoundManager skips a sounds.json Gson rejects, whole.
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
@@ -901,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
}
Merge::Font => list("providers", &mut parsed.iter().rev()),
Merge::Atlas => list("sources", &mut parsed.iter()),
// One definition is the file as it is.
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
Merge::BlockStates => Value::Object(Map::from_iter([(
BLOCKSTATE_LAYERS.to_owned(),
Value::Array(parsed.into_iter().map(Value::Object).collect()),
)])),
};
serde_json::to_vec(&out).ok()
}
@@ -1404,6 +1419,36 @@ mod tests {
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
);
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
// BlockStateModelLoader: every definition kept, bottom first, for the
// catalog to bind one by one; one that does not parse is skipped.
let gate = merge(
Merge::BlockStates,
&[
json(r#"{"variants":{"":{"model":"v"}}}"#),
b"not json".to_vec(),
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
],
);
assert_eq!(
parse(gate.unwrap()),
serde_json::json!({BLOCKSTATE_LAYERS:[
{"variants":{"":{"model":"v"}}},
{"variants":{"powered=true":{"model":"p"}}}
]})
);
// A single definition stays the file as it is.
let alone = merge(
Merge::BlockStates,
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
);
assert_eq!(
parse(alone.unwrap()),
serde_json::json!({"variants":{"":{"model":"p"}}})
);
assert_eq!(
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
Some(Merge::BlockStates)
);
assert_eq!(
merge_kind("assets/minecraft/lang/en_us.json"),
Some(Merge::Lang)
Loaded 100 of 990 files, more files were not shown because too many files have changed in this diff. Show more