Merge origin/main into fix/11214-chunk-refresh

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-07 13:07:40 +01:00
commit 5fdf9fb0ac
566 files changed
+35468 -2898

No files matched your search

+7 -6
View File
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
@@ -92,8 +93,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -105,8 +106,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
- A harness = `tools/test_<name>_parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (mostly horizon-world and horizon-client),
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
@@ -176,7 +177,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
- `struct Game` (game.rs) holds everything. Its methods are spread over many files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
+1 -1
View File
@@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Generated
+19 -82
View File
@@ -131,9 +131,9 @@ dependencies = [
[[package]]
name = "arboard"
version = "3.6.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"log",
@@ -142,7 +142,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1411,6 +1411,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"dunce",
"flate2",
"fs4",
@@ -1493,6 +1494,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"flate2",
"fs4",
"getrandom 0.3.4",
@@ -1649,7 +1651,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2 0.5.10",
"socket2 0.6.5",
"spake2",
]
@@ -1754,6 +1756,14 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"regex",
"serde_json",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1793,6 +1803,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"socket2 0.5.10",
]
[[package]]
@@ -2052,12 +2063,12 @@ dependencies = [
[[package]]
name = "if-addrs"
version = "0.13.4"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5237,15 +5248,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5279,30 +5281,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
@@ -5324,12 +5309,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5342,12 +5321,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5360,24 +5333,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5390,12 +5351,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5408,12 +5363,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5426,12 +5375,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5444,12 +5387,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
+2 -1
View File
@@ -37,6 +37,7 @@ members = [
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-deps",
@@ -47,7 +48,7 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
rust-version = "1.95"
# Ports mirror vanilla's Java signatures, index loops and nested generic types
# one to one so they can be checked against the decompiled sources.
-10
View File
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
+42
View File
@@ -1,5 +1,15 @@
# Third-party notices
## Catnip / Ponder
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
development-only oracle calls the unchanged helper from the player's local artifact. No
original classes, game shaders, textures or prepared resources are distributed here.
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -212,3 +222,35 @@ 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.
```
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
## Flywheel native model directional shading
The two-light diffuse expression and transformed-normal policy in
`terrain.wgsl`'s native model entries follow Flywheel 1.0.6
(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`).
No Flywheel classes, compiled shaders or original model/texture assets are bundled.
Copyright (c) 2021-2024 Jozufozu
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.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "cargo-horizon-mod"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
rust-version.workspace = true
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
rust-version.workspace = true
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
@@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value {
.collect();
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
}
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
#[allow(
dead_code,
reason = "shared by fixtures which normalize different model stages"
)]
pub fn model(v: &CuboidModel) -> Value {
let textures: BTreeMap<_, _> = v
.textures
+10 -2
View File
@@ -34,6 +34,8 @@ pub struct ModelQuad {
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
/// A declared render type unknown to this baker; distinct from an absent default.
Unsupported,
Solid,
Cutout,
CutoutMipped,
@@ -234,7 +236,10 @@ fn bake_into(
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for element in model.elements() {
let element = element
.as_ref()
@@ -292,7 +297,10 @@ fn bake_obj(
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
+9 -9
View File
@@ -179,19 +179,19 @@ impl Condition {
let fields = object(raw)?;
if fields.len() == 1 {
for name in ["OR", "AND"] {
if let Some(raw) = fields.get(name) {
if let Ok(values) = array(raw).and_then(|values| {
if let Some(raw) = fields.get(name)
&& let Ok(values) = array(raw).and_then(|values| {
values
.into_iter()
.map(Self::decode)
.collect::<Result<Vec<_>>>()
}) {
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
})
{
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
}
}
+17
View File
@@ -777,6 +777,23 @@ mod tests {
);
}
#[test]
fn an_unknown_declared_render_type_remains_distinct_from_absent() {
let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material");
let library = library(&[("block/custom", &json)]);
let model = library.resolve("block/custom").unwrap();
let quads = crate::bake::bake_block_model(
&model,
crate::bake::BlockModelTransform::new([0; 3], false),
)
.unwrap();
assert!(!quads.is_empty());
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported))
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
+1
View File
@@ -2,6 +2,7 @@
name = "horizon-bench"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
+6 -6
View File
@@ -694,7 +694,7 @@ fn costly(
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
if i.is_multiple_of(2) {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
@@ -723,7 +723,7 @@ fn costly(
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
let text = if i.is_multiple_of(2) {
"/"
} else {
LONG_COMMAND.as_str()
@@ -1555,10 +1555,10 @@ fn main() {
});
if o.report.as_deref() == Some("-") {
println!("{summary:#}");
} else if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
} else if let Some(path) = &o.report
&& let Err(e) = std::fs::write(path, format!("{summary:#}\n"))
{
eprintln!("horizon-bots: {path}: {e}");
}
let errors: Vec<String> = bots
.iter()
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
@@ -275,6 +275,25 @@ impl Registries {
.clone_from(&e.advancement_criteria);
}
}
/// Add the damage catalog's types ([`Self::damage_types`]: content mods'
/// and the world's packs' with vanilla's) to the commands'
/// `minecraft:damage_type` ids, which the export lists for vanilla alone.
/// `ResourceArgument` looks a type up in the server's registries, so
/// `/damage` takes every type the world has. The export's tags stay. The
/// ids are replaced, not extended: `by_registry` outlives a world, and a
/// pack's type must not stay accepted in the next world opened.
pub fn sync_damage_type_ids(&mut self) {
if self.damage_types.is_empty() {
return;
}
let mut ids = self.damage_types.clone();
ids.sort();
ids.dedup();
self.by_registry
.entry("minecraft:damage_type".into())
.or_default()
.ids = ids;
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -312,3 +331,49 @@ impl Registries {
}
}
}
#[cfg(test)]
mod tests {
use super::Registries;
/// A content mod's damage type joins vanilla's in the commands' ids, the
/// export's tags stay, and a second world open lists each type once.
#[test]
fn damage_type_ids_take_the_catalogs_types() {
let export = Registries::from_export(&serde_json::json!({
"by_registry": {"minecraft:damage_type": {
"ids": ["minecraft:fall", "minecraft:magic"],
"tags": ["minecraft:is_fall"],
}},
}));
let mut reg = Registries {
damage_types: vec![
"minecraft:magic".into(),
"example:test".into(),
"minecraft:fall".into(),
],
..Registries::default()
};
reg.fill_from_exported(&export);
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"],
"the export alone lists vanilla's types"
);
for _ in 0..2 {
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["example:test", "minecraft:fall", "minecraft:magic"]
);
assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]);
}
// The next world, without the pack: its type is no longer accepted.
reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()];
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"]
);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
+38 -31
View File
@@ -393,38 +393,17 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => enter_fullscreen(
window,
exclusive,
preferred.as_ref(),
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None if reapply || mode_change => enter_fullscreen(
window,
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None => {}
}
}
@@ -602,6 +581,34 @@ fn monitor_current_mode(
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
/// state first: winit keeps a maximized window's client area inside the work area, so a
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
#[cfg(target_os = "windows")]
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen));
}
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
winit::window::Fullscreen::Borderless(
window
.current_monitor()
.or_else(|| window.primary_monitor()),
)
}
/// A window created fullscreen that the system also marked maximized enters it again
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
enter_fullscreen(window, borderless_fullscreen(window));
}
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
+2 -1
View File
@@ -6,7 +6,7 @@ autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "Reusable client systems, control tool and parity probes"
[lib]
@@ -76,6 +76,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
@@ -0,0 +1,55 @@
//! Headless production brewing-fuel component fixture.
use horizon_client_render::{
brewing::{Brewing, BrewingStand, brewing_fuel},
providers::Providers,
};
use horizon_items::{
item_data::decode_components,
items::{ItemStack, Items},
};
use horizon_world::loot::PackOverlay;
use std::{
io::{BufRead, Write},
path::Path,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(&args[1]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let providers = Providers::load(root);
let brewing = Brewing {
recipes: vec![],
reagents: Default::default(),
inputs: Default::default(),
};
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?);
for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() {
let input: serde_json::Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
input["item"].as_str().unwrap(),
input["count"].as_u64().unwrap() as u32,
);
let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]);
let (_, extra) = decode_components(Some(&components));
stack.extra = extra;
let accepted = brewing_fuel(&items, &stack).is_some();
let mut stand = BrewingStand::new();
// A newly constructed vanilla block entity has no saved total yet.
stand.total_fuel = 0;
stand.items[4] = Some(stack);
for _ in 0..2 {
stand.tick(&brewing, &items, &providers);
writeln!(
out,
"{} {} {} {} {}",
accepted,
stand.fuel,
stand.total_fuel,
stand.speed.to_bits(),
stand.items[4].as_ref().map_or(0, |s| s.count)
)?;
}
}
Ok(())
}
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
assert!(
@@ -348,6 +350,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
+76 -12
View File
@@ -261,6 +261,40 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
device_probe: Option<PendingProbe>,
}
/// What one look at the output devices found: the system default, the
/// fingerprint, and the device list when those say it may have changed.
#[cfg(feature = "audio")]
struct DeviceProbe {
default: Option<String>,
cards: Option<String>,
devices: Option<Vec<String>>,
}
#[cfg(feature = "audio")]
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
/// Looks at the output devices as `should_change_device` did on the render
/// thread: the device list only when the default or the fingerprint moved.
#[cfg(feature = "audio")]
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them; on Windows
// every endpoint, with no fingerprint to skip it); only the fingerprint's
// devices can change the list, and it is cheap.
let devices = (default != default_seen
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
.then(output_devices);
DeviceProbe {
default,
cards,
devices,
}
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
@@ -1051,6 +1085,8 @@ impl Audio {
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
#[cfg(not(feature = "audio"))]
@@ -1211,6 +1247,8 @@ impl Audio {
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1232,7 +1270,8 @@ impl Audio {
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
/// at once a second, on a thread of their own: a later tick reads what it
/// found.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
@@ -1246,29 +1285,54 @@ impl Audio {
};
if lost {
log::info!("Horizon: audio device was lost");
self.device_probe = None;
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
if let Some(pending) = &self.device_probe {
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
return false;
};
self.device_probe = None;
return self.devices_changed(probe, current);
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
let pending = PendingProbe::default();
let out = Arc::clone(&pending);
let seen = (self.default_seen.clone(), self.cards_seen.clone());
let started = std::thread::Builder::new()
.name("audio-devices".into())
.spawn(move || {
let probe = probe_devices(seen.0, seen.1);
if let Ok(mut found) = out.lock() {
*found = Some(probe);
}
});
match started {
Ok(_) => self.device_probe = Some(pending),
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
}
false
}
/// The rest of `shouldChangeDevice` with what a look at the devices found,
/// `current` being the device playing now.
#[cfg(feature = "audio")]
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
let DeviceProbe {
default,
cards,
devices,
} = probe;
let Some(devices) = devices else {
return false;
};
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
+7 -7
View File
@@ -114,12 +114,8 @@ mod tests {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
faces: vec![BakedFace::new(
ModelQuad {
positions: [
[0.0, 0.5, 0.0],
[0.0, 0.5, 1.0],
@@ -142,7 +138,11 @@ mod tests {
render_type: None,
shade: true,
},
}],
0,
true,
false,
None,
)],
}
}
#[test]
+58 -1
View File
@@ -387,8 +387,21 @@ impl BrewingStand {
out.brewed = true;
}
}
/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier).
/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier),
/// respecting component patches before falling back to the item's defaults.
pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> {
if let Some(data) = stack.data() {
if data.other("!minecraft:brewing_fuel").is_some() {
return None;
}
if let Some(own) = data.other("minecraft:brewing_fuel") {
let component = horizon_client_core::scoreboard::tag_to_json(own);
return Some((
component["uses"].clone(),
component["speed_multiplier"].clone(),
));
}
}
items.get(&stack.item).and_then(|i| i.brewing_fuel.clone())
}
@@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)>
mod tests {
use super::*;
#[test]
fn stack_fuel_controls_brew_duration_and_consumption() {
let items = Items::for_tests(Default::default());
let brewing = Brewing {
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
inputs: HashSet::new(),
recipes: vec![BrewingRecipe {
id: "awkward".into(),
input: PotionIngredient {
items: ["minecraft:potion".to_owned()].into_iter().collect(),
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
},
reagent: PotionIngredient {
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
potions: None,
},
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
}],
};
let mut fuel = ItemStack::new("minecraft:stone", 2);
fuel.data_mut().set_loot_component(
"minecraft:brewing_fuel",
&serde_json::json!({"uses": 7, "speed_multiplier": 2.5}),
);
let mut stand = BrewingStand::new();
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
stand.items[4] = Some(fuel);
let providers = Providers::empty();
stand.tick(&brewing, &items, &providers);
assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7));
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
for _ in 1..160 {
assert!(!stand.tick(&brewing, &items, &providers).brewed);
}
assert!(stand.tick(&brewing, &items, &providers).brewed);
assert_eq!(
stand.items[0].as_ref().unwrap().potion(),
Some("minecraft:awkward")
);
assert_eq!(stand.fuel, 6);
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
}
#[test]
fn potion_colours_and_names() {
// Swiftness: speed colour 3402751 = 0x33EBFF.
+85 -4
View File
@@ -1188,11 +1188,27 @@ impl SuggestLayout {
};
}
/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`).
#[derive(Debug, PartialEq, Eq)]
pub struct SuggestionCompletion {
/// The suggestion list's original input, also when cycling with Tab.
pub original: String,
/// The replaced range in `original`, as Java UTF-16 start/end offsets.
pub range: (usize, usize),
pub text: String,
/// The resulting input, after the text field's length limit.
pub input: String,
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1207,6 +1223,7 @@ pub struct ChatScreen {
/// the command up to the cursor.
request: Option<(i32, String)>,
suggestions: Option<SuggestionList>,
completion: Option<SuggestionCompletion>,
usage: Vec<Text>,
usage_position: i32,
usage_width: i32,
@@ -1300,11 +1317,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1336,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1326,6 +1346,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1353,6 +1374,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1362,6 +1384,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1377,12 +1400,18 @@ impl ChatScreen {
pub fn value(&self) -> &str {
&self.input.value
}
/// Take the most recently accepted suggestion, at most once. Call after
/// each key or click to observe every acceptance, including Tab cycling.
pub fn take_suggestion_completion(&mut self) -> Option<SuggestionCompletion> {
self.completion.take()
}
/// Replace the input for a suggest-command link and refresh completions.
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1419,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1440,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1415,6 +1454,24 @@ impl ChatScreen {
self.edited(m, c);
}
}
/// Edit the existing input and refresh its command responder when it changes.
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
let before = (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
);
f(&mut self.input);
if before
!= (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
)
{
self.edited(m, c);
}
}
/// Typed text (`charTyped` per character).
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
for ch in text.chars() {
@@ -1714,6 +1771,17 @@ impl ChatScreen {
self.select(l, l.current as isize);
self.keep_suggestions = false;
l.tab_cycles = true;
let units = |byte: usize| {
l.original[..byte.min(l.original.len())]
.encode_utf16()
.count()
};
self.completion = Some(SuggestionCompletion {
original: l.original.clone(),
range: (units(s.range.0), units(s.range.1)),
text: s.text,
input: self.input.value.clone(),
});
}
/// `ChatScreen.keyPressed`.
pub fn key(
@@ -1729,6 +1797,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1938,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
+193 -7
View File
@@ -7,6 +7,85 @@ fn full() -> ChatAbilities {
ChatAbilities::new(ChatVisibility::Full)
}
#[test]
fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into(), "Steven".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "😀 hi st");
assert!(screen.take_suggestion_completion().is_none());
screen.key(&m, &c, &[], K::Tab, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"opening is not accepting"
);
for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] {
screen.key(&m, &c, &[], K::Tab, shift, false);
assert_eq!(screen.value(), format!("😀 hi {text}"));
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "😀 hi st".into(),
range: (6, 8),
text: text.into(),
input: format!("😀 hi {text}"),
})
);
assert!(
screen.take_suggestion_completion().is_none(),
"reported once"
);
}
screen.key(&m, &c, &[], K::ArrowDown, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"selection is not accepting"
);
screen.key(&m, &c, &[], K::Escape, false, false);
assert!(screen.take_suggestion_completion().is_none());
}
#[test]
fn clicked_suggestion_reports_the_actual_length_limited_input() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.input.max_length = 6;
screen.type_text(&m, &c, "hi st");
screen.key(&m, &c, &[], K::Tab, false, false);
let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect;
assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1)));
assert_eq!(screen.value(), "hi Ste");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "hi st".into(),
range: (3, 5),
text: "Steve".into(),
input: "hi Ste".into(),
})
);
assert!(screen.take_suggestion_completion().is_none());
}
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -201,7 +280,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +321,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +343,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +450,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +500,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +548,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -395,15 +562,25 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt");
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
assert!(screen.take_suggestion_completion().is_none());
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt6:check");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "/bossbar get pt".into(),
range: (13, 15),
text: "pt6:check".into(),
input: "/bossbar get pt6:check".into(),
})
);
// Without the server's provider the local list is done at once.
let local = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +650,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +676,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -11,7 +11,7 @@ use horizon_world::{
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Mutex, OnceLock, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
@@ -108,6 +108,9 @@ pub enum BlockEntity {
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
// A client update can consume transient effects without changing saved
// fields. Most entities never use this; do not allocate their queue.
client_update_fields: OnceLock<Mutex<Compound>>,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
@@ -138,11 +141,55 @@ impl ModBlockEntity {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
client_update_fields: OnceLock::new(),
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// Replace the owner's pending, one-shot client update fields. These
/// fields are not saved, cloned, compared or retained for drawing, and
/// changing them does not change this entity's revision. The owner must
/// explicitly take and merge them into its update tag. Calling this does
/// not schedule a block update.
///
/// An empty batch clears a pending batch without allocating an idle queue.
pub fn set_client_update_fields(&mut self, fields: Compound) {
if fields.is_empty() {
self.clear_client_update_fields();
return;
}
*self
.client_update_fields
.get_or_init(|| Mutex::new(Compound::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner) = fields;
}
/// Discard the owner's pending client update fields, without changing
/// saved data, revision or a queue that has never been used.
pub fn clear_client_update_fields(&mut self) {
if let Some(fields) = self.client_update_fields.get() {
fields
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
/// Atomically take the owner's pending fields at update-tag construction.
/// A loaded chunk and a block change use this same boundary. Construct a
/// tag once, then send that tag to every recipient; another construction
/// sees an empty batch. A save or a clone does not consume this batch.
///
/// This is safe for an update-tag hook receiving only a shared reference,
/// including a saved chunk encoded by a worker thread. If a previous lock
/// holder panicked, preserve its pending data instead of dropping it.
pub fn take_client_update_fields(&self) -> Compound {
self.client_update_fields
.get()
.map_or_else(Compound::new, |fields| {
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
})
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
@@ -706,6 +753,85 @@ pub struct CommandBlock {
mod tests {
use super::*;
#[test]
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let saved = machine.fields.clone();
let debug = format!("{machine:?}");
machine.clear_client_update_fields();
machine.set_client_update_fields(Compound::new());
assert!(machine.take_client_update_fields().is_empty());
assert!(machine.client_update_fields.get().is_none());
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
let copy = machine.clone();
assert_eq!(machine, copy, "transient fields are not retained contents");
assert_ne!(machine.revision(), copy.revision());
assert!(copy.client_update_fields.get().is_none());
assert!(copy.take_client_update_fields().is_empty());
assert_eq!(machine.take_client_update_fields(), expected);
machine.set_client_update_fields(expected);
machine.clear_client_update_fields();
assert!(machine.take_client_update_fields().is_empty());
assert_eq!(machine.fields, saved);
assert_eq!(machine.revision(), revision);
assert_eq!(format!("{machine:?}"), debug);
}
#[test]
fn concurrent_client_update_constructions_consume_one_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
let received = std::thread::scope(|scope| {
let handles = (0..16)
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
let nonempty = received
.into_iter()
.filter(|c| !c.is_empty())
.collect::<Vec<_>>();
assert_eq!(nonempty, [expected]);
}
#[test]
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
std::thread::scope(|scope| {
assert!(
scope
.spawn(|| {
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
panic!("interrupt before consuming the queued batch");
})
.join()
.is_err()
);
});
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
}
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
@@ -0,0 +1,243 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Draw {
pub image: u16,
pub blend: Blend,
pub brightness: f32,
/// Camera-relative world from the skybox's space.
pub rotation: Mat4,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
+170 -31
View File
@@ -14,6 +14,8 @@ pub struct EffectInstance {
pub amplifier: i32,
pub ambient: bool,
pub visible: bool,
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
pub show_icon: bool,
pub hidden: Option<Box<EffectInstance>>,
}
impl EffectInstance {
@@ -24,9 +26,18 @@ impl EffectInstance {
amplifier: amplifier.clamp(0, 255),
ambient: false,
visible: true,
show_icon: true,
hidden: None,
}
}
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
/// the icon shows exactly when the particles do.
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
self.ambient = ambient;
self.visible = visible;
self.show_icon = visible;
self
}
fn infinite(&self) -> bool {
self.duration == -1
}
@@ -71,6 +82,10 @@ impl EffectInstance {
self.visible = take.visible;
changed = true;
}
if take.show_icon != self.show_icon {
self.show_icon = take.show_icon;
changed = true;
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
@@ -98,6 +113,7 @@ impl EffectInstance {
self.amplifier = h.amplifier;
self.ambient = h.ambient;
self.visible = h.visible;
self.show_icon = h.show_icon;
self.hidden = h.hidden;
}
}
@@ -116,8 +132,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -168,6 +187,57 @@ pub enum OmenTick {
/// It added this effect, then returned false.
Add(EffectInstance),
}
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
#[derive(Clone, Debug, PartialEq)]
pub struct SavedEffect {
pub id: String,
pub duration: i32,
pub amplifier: i32,
pub ambient: bool,
/// `show_particles`.
pub particles: bool,
/// `show_icon`.
pub icon: bool,
/// `hidden_effect`: the weaker, longer instance of the same effect that
/// comes back when this one ends.
pub hidden: Option<Box<SavedEffect>>,
}
impl SavedEffect {
/// Ambient off, particles and icon shown, nothing hidden: the codec's
/// defaults.
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
Self {
id: id.into(),
duration,
amplifier,
ambient: false,
particles: true,
icon: true,
hidden: None,
}
}
fn of(e: &EffectInstance) -> Self {
Self {
id: potions::namespaced(e.id).to_owned(),
duration: e.duration,
amplifier: e.amplifier,
ambient: e.ambient,
particles: e.visible,
icon: e.show_icon,
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
}
}
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
/// with it.
fn instance(&self, id: &'static str) -> EffectInstance {
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
e.ambient = self.ambient;
e.visible = self.particles;
e.show_icon = self.icon;
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
e
}
}
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -200,39 +270,25 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
/// The effects of a saved player (`Player.active_effects`), each with
/// the instances it hides.
pub fn from_saved(saved: &[SavedEffect]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
for s in saved {
if let Some(def) = potions::effect(&s.id) {
effects.add(s.instance(def.id));
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
self.list
.iter()
.map(|e| {
(
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect()
pub fn saved(&self) -> Vec<SavedEffect> {
self.list.iter().map(SavedEffect::of).collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
/// particles visible and the icon shown.
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
@@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
/// false)`) shows neither particles nor its icon; a renewal that shows the
/// icon turns it back on (`MobEffectInstance.update`).
#[test]
fn an_effect_without_particles_hides_its_icon() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
let darkness = fx.get("darkness").unwrap();
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
assert_eq!(PlayerEffects::flags(darkness), 0);
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
// Details keeps show_icon apart from show_particles.
let mut speed = EffectInstance::new("speed", 100, 0);
speed.show_icon = false;
let mut fx = PlayerEffects::default();
fx.add(speed);
let saved = fx.saved();
assert_eq!(
saved,
[SavedEffect {
icon: false,
..SavedEffect::new("minecraft:speed", 100, 0)
}]
);
let loaded = PlayerEffects::from_saved(&saved);
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
@@ -531,6 +655,21 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
#[test]
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
// /effect give poison 6 s level II over poison 30 s level I, saved
// and loaded: when level II runs out, level I resumes with what is
// left of its 30 s.
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("poison", 600, 0));
fx.add(EffectInstance::new("poison", 120, 1));
let mut loaded = PlayerEffects::from_saved(&fx.saved());
for _ in 0..120 {
loaded.tick();
}
let poison = loaded.get("poison").expect("level I resumed");
assert_eq!((poison.amplifier, poison.duration), (0, 480));
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
@@ -546,11 +685,11 @@ mod tests {
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
.list
.is_empty()
);
@@ -0,0 +1,210 @@
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
// Copyright (c) 2022 The Create Team
//
// 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.
use horizon_core::geometry::Direction;
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
#[derive(Clone, Copy, Debug)]
pub struct NativeFluidRenderOptions {
pub argb: Option<u32>,
pub render_bottom: bool,
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
pub uv_shrink_ratio: f32,
}
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub normal: [i8; 3],
}
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
}
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
pub fn fluid_box(
bounds: [f32; 6],
options: NativeFluidRenderOptions,
mut emit: impl FnMut(FluidVertex),
) -> bool {
if !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|| bounds
.iter()
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
{
return false;
}
let tiles =
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
let quads = tiles[0] * tiles[1] * 2
+ tiles[1] * tiles[2] * 2
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
if quads > 65_536 {
return false;
}
let [x0, y0, z0, x1, y1, z1] = bounds;
for (side, face) in [
(Direction::Down, [x0, z0, x1, z1, y0]),
(Direction::Up, [x0, z0, x1, z1, y1]),
(Direction::North, [x0, y0, x1, y1, z0]),
(Direction::South, [x0, y0, x1, y1, z1]),
(Direction::West, [z0, y0, z1, y1, x0]),
(Direction::East, [z0, y0, z1, y1, x1]),
] {
if side != Direction::Down || options.render_bottom {
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
}
}
true
}
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
let [a0, b0, a1, b1, plane] = face;
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
let shrink = ratio * 0.25;
let lerp = |v| v + shrink * (0.5 - v);
let mut a = a0;
while a < a1 {
let end_a = (a.floor() + 1.0).min(a1);
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
(end_a.ceil() - end_a, end_a.ceil() - a)
} else {
(a - a.floor(), end_a - a.floor())
};
let (u0, u1) = (lerp(u0), lerp(u1));
let mut b = b0;
while b < b1 {
let end_b = (b.floor() + 1.0).min(b1);
let (v0, v1) = if side == Direction::Up {
(b - b.floor(), end_b - b.floor())
} else {
(end_b.ceil() - end_b, end_b.ceil() - b)
};
let (v0, v1) = (lerp(v0), lerp(v1));
let positions = match side {
Direction::West | Direction::East => {
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
[
[plane, end_b, near],
[plane, b, near],
[plane, b, far],
[plane, end_b, far],
]
}
Direction::North | Direction::South => {
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
[
[near, end_b, plane],
[near, b, plane],
[far, b, plane],
[far, end_b, plane],
]
}
Direction::Down | Direction::Up => {
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
[
[a, plane, near],
[a, plane, far],
[end_a, plane, far],
[end_a, plane, near],
]
}
};
let normal = match side {
Direction::Down => [0, -1, 0],
Direction::Up => [0, 1, 0],
Direction::North => [0, 0, -1],
Direction::South => [0, 0, 1],
Direction::West => [-1, 0, 0],
Direction::East => [1, 0, 0],
};
for (pos, uv) in positions
.into_iter()
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
{
out(FluidVertex { pos, uv, normal });
}
b = end_b;
}
a = end_a;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_tiling_is_outward_and_does_not_inset_geometry() {
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
let mut result = Vec::new();
assert!(fluid_box(
bounds,
NativeFluidRenderOptions {
argb: None,
render_bottom: false,
uv_shrink_ratio: 4.0 / 2048.0,
},
|v| result.push(v),
));
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
for quad in result.as_chunks::<4>().0 {
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
assert!(
(b - a)
.cross(c - a)
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
> 0.0
);
assert!(
quad.iter()
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
);
}
assert!(
result
.iter()
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
);
assert!(!fluid_box(
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
NativeFluidRenderOptions {
argb: None,
render_bottom: true,
uv_shrink_ratio: 0.0,
},
|_| panic!("invalid geometry must fail before emitting"),
));
}
#[test]
fn luminosity_changes_only_block_light() {
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
assert_eq!(fluid_light(0xe030, 3), 0xe030);
}
}
+36 -2
View File
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
/// the sky reaches the eye, halved in a biome without precipitation.
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
rain * sky * if biome_rains { 1.0 } else { 0.5 }
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
biome_rains: bool,
delta_ticks: f32,
) {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
let target = rain_fog_target(rain, sky_light, biome_rains);
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
}
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
/// at its level and easing would wait for ticks that do not come.
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
}
fn rgb(c: u32) -> [f32; 3] {
[
(c >> 16 & 255) as f32 / 255.0,
@@ -726,6 +736,30 @@ mod tests {
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
/// of the way, as before.
#[test]
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
assert_eq!(
rain_fog_target(1.0, 15, false),
0.5,
"a dry biome halves it"
);
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
let start = FogState {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
assert_eq!(
eased.rain_fog_multiplier.to_bits(),
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
+486 -66
View File
@@ -45,18 +45,21 @@ fn static_entity_shader() -> String {
mod crumbling_tests;
mod entity_batch;
mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
mod weather;
mod world_text;
use entity_batch::EntityBatch;
pub use entity_batch::{
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY,
SHADOW_OVERLAY, TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, TERRAIN_MODEL_CUTOUT_OVERLAY,
TERRAIN_MODEL_SOLID_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY,
TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain, mark_oit,
};
pub use view::{View, ViewSet};
@@ -170,6 +173,7 @@ mod hdr_icc;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
mod custom_sky;
mod displace;
pub mod lightning;
mod lod;
@@ -227,6 +231,13 @@ fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
/// The End during the dragon fight, whose world fog leaves out vanilla's sky: a shader pack's
/// `gbuffers_skybasic` still draws a fan around the horizon (`pack_sky`), as our probe packs read
/// it in Iris 1.11.7.
fn end_fog_sky(frame: &Frame) -> bool {
!frame.title && frame.end_sky && frame.boss_world_fog
}
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
/// depth loaded, and its `surface` target, cleared on the first of these passes.
fn surface_pass<'e>(
@@ -469,9 +480,9 @@ struct SectionGpu {
/// while its instance carries a fade (`drawn_faded`), and GPU culling's
/// pass leaves it out.
gpu_fading: bool,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
/// When it came in over the far terrain it replaces (drawn whole at once,
/// `fade`): a section new where far tiles are drawn, which skips vanilla's
/// fade-in. Meshed again, it stays as it was.
fading: Option<std::time::Instant>,
/// When it began to fade out, its column gone over far terrain that
/// stays behind it (`Renderer::leave_section`); released once it has.
@@ -493,17 +504,19 @@ pub(crate) fn collect_animated_layers(
}
impl SectionGpu {
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
/// its column has left the render distance, far terrain is coming in
/// under it (`lod::HELD`).
fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 {
match self.leaving {
Some(since) => lod::fade_out_code(since, 0, now),
None if retiring => lod::HELD,
None => self
.fading
.and_then(|since| lod::fade_code(Some(since), 0, now))
.unwrap_or(0),
/// Its fade code this frame: 0, drawn whole, or `lod::HELD` (whole, in
/// front of far terrain) while it leaves (`leaving`) or its column has left
/// the render distance and far terrain is coming in under it (`retiring`).
/// A near section never dithers over far terrain: the holes of its pattern
/// showed its own inside (cave walls, the stone under the grass), nearer
/// than the tile behind, an x-ray of each column coming in or going out.
/// It comes in whole over the tile it replaces, which stays behind it until
/// `fading` is `lod::FADE` old, and leaves whole after as long.
fn fade(&self, retiring: bool) -> u32 {
if self.leaving.is_some() || retiring {
lod::HELD
} else {
0
}
}
/// Its vertices in `arena`, and in `translucent_arena` when `split` (there
@@ -569,23 +582,21 @@ impl SectionGpu {
/// once false, this stays false until the section is meshed again, leaves,
/// is held (`lod_retiring`), or the chunk fade-in time changes.
fn drawn_faded(&self, key: SectionKey, f: FadeInputs<'_>) -> bool {
let fade = self.fade(f.now, f.retiring.contains(&key));
let fade = self.fade(f.retiring.contains(&key));
let visibility = section_visibility(f.uploaded.get(&key), f.fade_ms, f.now);
section_instance([0.0; 3], fade, visibility)[3].to_bits()
!= section_instance([0.0; 3], 0, 1.0)[3].to_bits()
}
/// When its fade is over at the latest (`drawn_faded`): the later of the
/// ends of its fade-in over far terrain (`fading` and `lod::FADE`, a few
/// milliseconds after `fade` reaches 0) and of vanilla's (its first upload
/// and `f.fade_ms`). None while it leaves or is held, which ends only when
/// it goes or is meshed again, and without either fade-in.
/// When its fade is over at the latest (`drawn_faded`): the end of
/// vanilla's fade-in (its first upload and `f.fade_ms`; one coming in over
/// far terrain is whole at once, `fade`). None while it leaves or is held,
/// which ends only when it goes or is meshed again, and without a fade-in.
fn fade_end(&self, key: SectionKey, f: FadeInputs<'_>) -> Option<Instant> {
if self.leaving.is_some() || f.retiring.contains(&key) {
return None;
}
let vanilla = f.uploaded.get(&key).filter(|_| f.fade_ms > 0);
let vanilla = vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms));
vanilla.max(self.fading.map(|t| t + lod::FADE))
vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms))
}
}
/// Which of a mesh's ranges `move_off_page` moves: what it holds in the section
@@ -883,6 +894,8 @@ pub struct Frame<'a> {
pub end_sky: bool,
/// End sky atlas bounds (u0, v0, u1, v1); absent when the sprite is missing.
pub end_sky_sprite: Option<[f32; 4]>,
/// A boss bar makes world fog (`BossHealthOverlay.shouldCreateWorldFog`: the Ender Dragon's).
pub boss_world_fog: bool,
/// Environmental fog start/end (Nether 10..96), if any.
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
@@ -1204,10 +1217,15 @@ pub struct Renderer {
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
world_text_pipeline: wgpu::RenderPipeline,
fluid_pipeline: wgpu::RenderPipeline,
terrain_model_pipelines: [wgpu::RenderPipeline; 3],
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
entity_outline: entity_outline::EntityOutline,
panorama: panorama::Panorama,
@@ -1236,6 +1254,10 @@ pub struct Renderer {
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
fluid_group: wgpu::BindGroup,
fluid_sampler: wgpu::Sampler,
terrain_model_group: wgpu::BindGroup,
terrain_model_sampler: wgpu::Sampler,
globals: wgpu::Buffer,
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
pbr_maps: Option<Vec<[wgpu::Texture; 2]>>,
@@ -1399,9 +1421,12 @@ pub struct Renderer {
hdr_output: Option<hdr::Output>,
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
/// swapchain through `readback`.
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
@@ -1468,6 +1493,8 @@ pub struct Renderer {
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -2003,7 +2030,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
features.contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) && format.allowed_usages.contains(
) && draws_frame(format)
}
/// Whether a format with the adapter's `format` features takes every usage and flag the
/// passes take of the frame.
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
format.allowed_usages.contains(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
@@ -2013,17 +2046,28 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
)
}
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md)
/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1
/// survive from the world to the tonemap and the HDR pass.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if wide_frame && hdr_offered && device_draws_it {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
@@ -2497,6 +2541,8 @@ impl Renderer {
translucent_batching: bool,
wide_frame: bool,
shader_pack: bool,
linear_pack: bool,
hdr_float: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2630,7 +2676,23 @@ impl Renderer {
adapter.get_texture_format_features(WIDE_FRAME),
);
crate::graphics::offer_smooth_gradients(wide_ok);
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
let linear = (
linear_pack,
hdr_float,
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
);
if linear.0 && !linear.2 {
log::warn!(
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
}
let frame_format = frame_format(
wide_frame,
hdr_output.is_some(),
wide_ok,
config.format,
linear,
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2729,6 +2791,20 @@ impl Renderer {
bind_group_layouts: &[Some(&bindings)],
..Default::default()
});
let fluid_pipeline = fluid::pipeline(
&device,
Some(&layout),
&shader,
config.format.remove_srgb_suffix(),
&sprite_constants,
);
let terrain_model_pipelines = terrain_model::pipelines(
&device,
Some(&layout),
&shader,
config.format.remove_srgb_suffix(),
&sprite_constants,
);
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let terrain_buffers = [
Some(wgpu::VertexBufferLayout {
@@ -3287,6 +3363,34 @@ impl Renderer {
&lightmap_sampler,
&emission_stats.view,
);
let fluid_sampler = device.create_sampler(&fluid::SAMPLER);
let fluid_group = terrain_group(
&device,
"Still fluid resources",
&bindings,
&globals,
&sprite_views,
&fluid_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let terrain_model_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native cutout model (unmipped)"),
lod_max_clamp: 0.0,
..fluid::SAMPLER
});
let terrain_model_group = terrain_group(
&device,
"Native cutout model resources",
&bindings,
&globals,
&sprite_views,
&terrain_model_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let crosshair_group = terrain_group(
&device,
"Debug crosshair resources",
@@ -3854,12 +3958,8 @@ impl Renderer {
alpha_blend,
false,
);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
config.format.remove_srgb_suffix(),
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -4561,6 +4661,7 @@ impl Renderer {
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, frame_format);
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
@@ -4679,10 +4780,14 @@ impl Renderer {
world_primitives,
text_polygon_offset_pipeline,
world_text_pipeline,
fluid_pipeline,
terrain_model_pipelines,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
stars,
custom_sky,
custom_sky_draws: Vec::new(),
entity_outline,
panorama,
border_pipeline,
@@ -4709,6 +4814,10 @@ impl Renderer {
entity_batch: EntityBatch::default(),
entity_skin,
group,
fluid_group,
fluid_sampler,
terrain_model_group,
terrain_model_sampler,
globals,
pbr_maps,
lightmap,
@@ -4796,6 +4905,7 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
pack_loading: None,
@@ -4828,6 +4938,7 @@ impl Renderer {
started: Instant::now(),
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -4898,6 +5009,15 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
@@ -6580,6 +6700,50 @@ impl Renderer {
}
Ok(report)
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
if let Some(native) = &mut self.native {
native.frame.reset_history();
if let Some(post) = &mut native.post {
post.reset_history();
}
}
}
/// The running shader pack's kind: `iris` (a `shaders/` pack) or `native` (one of
/// Horizon's own).
pub fn shader_pack_kind(&self) -> Option<&'static str> {
match (&self.pack_post, &self.native) {
(Some((_, Some(_))), _) => Some("iris"),
(_, Some(_)) => Some("native"),
_ => None,
}
}
/// `screenshot-sequence`: waits (30 seconds at most) for the frame just drawn to finish on
/// the GPU, and takes the centre depth a `shaders/` pack copied in it, which its next frame
/// reads: that frame's, or an error, never an older one. The last frame's PNG has already
/// waited for it, without a limit, as `screenshot` does.
pub fn finish_sequence_frame(&mut self) -> Result<()> {
self.device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_secs(30)),
})
.map_err(|e| format!("the frame didn't finish on the GPU: {e}"))?;
if self.device_lost() {
return Err("the graphics device was lost".into());
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.take_center_depth(&self.device)?;
}
Ok(())
}
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
@@ -6743,6 +6907,18 @@ impl Renderer {
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
if (linear || self.frame_format == LINEAR_FRAME)
&& linear != (self.frame_format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
{
log::info!(
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it",
self.frame_format
);
}
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
@@ -6988,6 +7164,28 @@ impl Renderer {
&self.lightmap_sampler,
&self.emission_stats.view,
);
self.fluid_group = terrain_group(
&self.device,
"Still fluid resources",
&self.terrain_layout,
&self.globals,
&self.sprite_views,
&self.fluid_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
&self.emission_stats.view,
);
self.terrain_model_group = terrain_group(
&self.device,
"Native cutout model resources",
&self.terrain_layout,
&self.globals,
&self.sprite_views,
&self.terrain_model_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
&self.emission_stats.view,
);
}
/// Block sprite layers per array on this device: a sprite's array is its layer over it, its
/// layer in that array the rest (`block_sprites::Lookup`).
@@ -7034,6 +7232,24 @@ impl Renderer {
planes: frustum(views.cull().view_proj(bob)),
}
}
/// A conservative view for geometry built before TAA chooses this frame's
/// jitter. Cover a whole rendered pixel on each side, at the configured
/// resolution scale/upscale, rather than borrowing the previous frame's size.
pub fn camera_frustum_with_jitter(
&self,
fov_degrees: f32,
yaw: f32,
pitch: f32,
bob: Mat4,
depth_far: f32,
) -> Frustum {
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
taa_frustum(
views.cull().view_proj(bob),
self.world_post
.taa_render_size([self.config.width, self.config.height]),
)
}
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
View::screen(yaw, pitch, fov_degrees, aspect, far)
@@ -8008,7 +8224,7 @@ impl Renderer {
let fading_over_lods = self.sections_fading_over_lods(now);
let fade_code = move |section: &SectionGpu, key: &SectionKey| {
if fading_over_lods {
section.fade(now, retiring.contains(key))
section.fade(retiring.contains(key))
} else {
0
}
@@ -8629,8 +8845,12 @@ impl Renderer {
translucent_particle_quads,
translucent_item_quads,
oit_entity_quads,
fluid_quads,
world_text_quads,
world_text_offset_quads,
native_solid_quads,
native_cutout_quads,
native_cutout_mipped_quads,
] = self.entity_batch.update(
entities,
frame.additive,
@@ -8657,13 +8877,23 @@ impl Renderer {
+ translucent_particle_quads
+ translucent_item_quads
+ oit_entity_quads
+ fluid_quads
+ world_text_quads
+ world_text_offset_quads;
+ world_text_offset_quads
+ native_solid_quads
+ native_cutout_quads
+ native_cutout_mipped_quads;
laps.lap("r_draw_lists");
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads =
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
let base_quads = world_quads
- oit_entity_quads
- fluid_quads
- world_text_quads
- world_text_offset_quads
- native_solid_quads
- native_cutout_quads
- native_cutout_mipped_quads;
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
let (rebuilt, uniform) =
@@ -8907,6 +9137,12 @@ impl Renderer {
brightness,
);
}
self.custom_sky.prepare(
&mut self.uploads,
&mut encoder,
jittered,
&self.custom_sky_draws,
);
let celestial_vertices = frame.celestial.len().min(2048 * 4);
if frame.end_sky && !frame.title {
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
@@ -9515,6 +9751,8 @@ impl Renderer {
}
}
if let Some(native) = &mut self.native {
let reflection =
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
native
.frame
.set_reflection(&self.device, reflection.as_ref());
@@ -9648,6 +9886,12 @@ impl Renderer {
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
}
// OptiFine custom sky layers, over the disc and sunrise and under the
// sun, moon and stars.
if self.custom_sky.is_drawing() {
self.custom_sky.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
}
}
}
@@ -9660,6 +9904,13 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if frame.end_sky {
self.end_sky.draw(&mut pass);
// The End's custom sky layers go over its sky box.
if self.custom_sky.is_drawing() && !pack_draws_sky && self.native.is_none()
{
self.custom_sky.draw(&mut pass);
pass.set_pipeline(&self.celestial_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
}
}
if celestial_vertices > 0 && !pack_draws_celestial {
// The Overworld's bodies start with the sun's quad
@@ -10074,6 +10325,50 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
// Native terrain models use the block sprite arrays with their own
// alpha/mipmap policies and ordinary opaque depth. Storage follows
// the existing materials without moving their indices or frame tails.
let mut first =
world_quads - native_solid_quads - native_cutout_quads - native_cutout_mipped_quads;
for (i, count) in [
native_solid_quads,
native_cutout_quads,
native_cutout_mipped_quads,
]
.into_iter()
.enumerate()
{
if count > 0 {
pass.set_pipeline(&self.terrain_model_pipelines[i]);
pass.set_bind_group(
0,
if i == 1 {
&self.terrain_model_group
} else {
&self.fluid_group
},
&[],
);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
}
first += count;
}
pass.set_bind_group(0, &self.group, &[]);
// Still-fluid block entities draw as their own ordinary sorted
// translucent material, before terrain transparency. This material
// always writes depth and never joins the improved-transparency queues.
if fluid_quads > 0 {
pass.set_pipeline(&self.fluid_pipeline);
pass.set_bind_group(0, &self.fluid_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..fluid_quads * 6,
((base_quads + oit_entity_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -10183,12 +10478,7 @@ impl Renderer {
depth.size(),
);
if let Some((color, _)) = &scaled {
let color = color.texture();
encoder.copy_texture_to_texture(
color.as_image_copy(),
native.frame.opaque_color.as_image_copy(),
color.size(),
);
native.frame.copy_opaque(&self.device, &mut encoder, color);
}
pass = world_pass_after_culling(
&mut encoder,
@@ -10465,7 +10755,7 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
let first = base_quads + oit_entity_quads;
let first = base_quads + oit_entity_quads + fluid_quads;
for (pipeline, count, offset) in [
(&self.world_text_pipeline, world_text_quads, first),
(
@@ -10820,12 +11110,18 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
lifts_world,
self.own_timing.as_mut(),
),
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
@@ -10844,7 +11140,7 @@ impl Renderer {
.filter(|(request, _)| request.size.is_none())
.zip(self.hdr.as_ref())
.and_then(|(_, hdr)| {
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
@@ -10853,7 +11149,10 @@ impl Renderer {
// submission needs this one: screenshots' readbacks, the error scopes (one per
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
// Frame generation presents its own frames (`frame_gen.present` below).
// A `screenshot-sequence` frame is submitted here, so it can wait for it to
// finish.
let (encoder, surface_frame, after) = if readback.is_none()
&& frame.pack.frame_timing.is_none()
&& fg_frames.is_none()
&& hdr_readback.is_none()
&& checks.is_none()
@@ -10938,10 +11237,14 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
@@ -11976,6 +12279,16 @@ fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
// view, including a posed camera, without changing the near/far planes.
let [x, y] = rendered_size.map(|size| 1.0 / (1.0 + 2.0 / size.max(1) as f32));
Frustum {
planes: frustum(Mat4::from_scale(Vec3::new(x, y, 1.0)) * view_proj),
}
}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
@@ -11991,6 +12304,57 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
let mut outside_unjittered = 0;
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
* Mat4::from_scale(Vec3::new(1.1, 0.9, 1.0))
* crate::camera::view_matrix(yaw, pitch);
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
let guarded = taa_frustum(matrix, size);
let ordinary = Frustum {
planes: frustum(matrix),
};
for x in [-1.0, 1.0] {
for y in [-1.0, 1.0] {
let jitter = Mat4::from_translation(Vec3::new(
x / size[0] as f32,
y / size[1] as f32,
0.0,
));
let inverse = (jitter * matrix).inverse();
for clip_x in [-1.0, 0.0, 1.0] {
for clip_y in [-1.0, 0.0, 1.0] {
for depth in [0.001, 0.5, 0.999] {
// Independent oracle: a point on an actual jittered
// viewport edge, transformed back into world space.
let point = inverse * Vec4::new(clip_x, clip_y, depth, 1.0);
let point = point.truncate() / point.w;
assert!(guarded.intersects_sphere(point, 1.0e-4));
let slack = Vec3::splat(1.0e-4);
assert!(guarded.intersects(point - slack, point + slack));
if !ordinary.intersects_sphere(point, 0.0) {
outside_unjittered += 1;
}
}
}
}
}
}
let behind = pose.inverse().transform_point3(Vec3::new(0.0, 0.0, 10.0));
assert!(!guarded.intersects_sphere(behind, 0.3));
}
}
assert!(
outside_unjittered > 0,
"the test must need the jitter guard"
);
}
#[test]
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
use super::*;
@@ -12374,20 +12738,75 @@ mod tests {
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
// No float frame asked for (or a device without it) leaves it as before.
for linear in [
(false, false, false),
(false, false, true),
(true, false, false),
(false, true, false),
(true, true, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
Bgra8Unorm
frame_format(true, true, true, Bgra8Unorm, linear),
Rgba16Unorm
);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
Bgra8Unorm
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
Rgba8Unorm
);
}
}
}
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
/// wherever the device draws it, HDR on or off.
#[test]
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::Bgra8UnormSrgb;
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
assert_eq!(
frame_format(
wide_frame,
hdr_offered,
draws,
Bgra8UnormSrgb,
(true, false, true)
),
LINEAR_FRAME
);
}
}
/// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the
/// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as
/// without the switch.
#[test]
fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for (wide_frame, draws) in [(true, true), (false, false)] {
assert_eq!(
frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)),
LINEAR_FRAME
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
Rgba8Unorm
frame_format(
wide_frame,
false,
draws,
Bgra8UnormSrgb,
(false, true, true)
),
Bgra8Unorm
);
}
}
@@ -12464,7 +12883,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -13223,7 +13642,8 @@ mod tests {
last = now;
}
assert!(set_by.iter().all(|&n| n > 20), "flags set {set_by:?}");
assert!(handed_back > 2_000, "{handed_back} handed back");
// Over a thousand: the half of the uploads over far terrain come in whole.
assert!(handed_back > 1_000, "{handed_back} handed back");
// Switched off with sections still flagged: none keeps a record or a
// flag, and each is marked as while GPU culling is off.
assert!(sim.records.fading() > 0, "none flagged at the end");
@@ -199,6 +199,8 @@ pub struct FrameInputs {
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
#[serde(default)]
pub boss_world_fog: bool,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
@@ -414,6 +416,7 @@ impl Renderer {
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
boss_world_fog: frame.boss_world_fog,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
@@ -510,6 +513,7 @@ impl Scene {
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
boss_world_fog: f.boss_world_fog,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
@@ -0,0 +1,416 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
Blend::Add,
Blend::Subtract,
Blend::Multiply,
Blend::Dodge,
Blend::Burn,
Blend::Screen,
Blend::Replace,
Blend::Overlay,
Blend::Alpha,
];
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
const MAX_DRAWS: usize = 32;
/// Dynamic uniform offsets are 256-byte aligned on every backend.
const STRIDE: u64 = 256;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
clip_from_sky: [[f32; 4]; 4],
brightness: f32,
mode: u32,
pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
uv: [f32; 2],
cell: [f32; 4],
}
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
/// One bind group per layer texture of the pack.
groups: Vec<wgpu::BindGroup>,
/// This frame's layers: bind group and pipeline indices.
frame: Vec<(u16, u8)>,
}
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
fn blend(mode: Blend) -> wgpu::BlendComponent {
use wgpu::BlendFactor as F;
let (src_factor, dst_factor, operation) = match mode {
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
// dst + dst * src: lightens.
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
// dst * (1 - src): darkens.
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
// dst * src + src * dst.
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
};
wgpu::BlendComponent {
src_factor,
dst_factor,
operation,
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
for face in Face::ALL {
let cell = face.cell();
let [u0, v0, u1, v1] = cell;
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
let corners = face.corners();
for i in [0, 1, 2, 0, 2, 3] {
out.push(Vertex {
position: corners[i],
uv: uv[i],
cell,
});
}
}
out
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(
std::mem::size_of::<Uniform>() as u64
),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.map(|&mode| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_custom_sky"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_custom_sky"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: blend(mode),
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Custom sky"),
min_filter: wgpu::FilterMode::Linear,
mag_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Custom sky layers"),
size: STRIDE * MAX_DRAWS as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Custom sky box"),
contents: bytemuck::cast_slice(&mesh()),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
pipelines,
layout,
sampler,
uniform,
vertices,
groups: Vec::new(),
frame: Vec::new(),
}
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
(image.width, image.height, &image.rgba[..])
} else {
log::warn!(
"custom sky texture of {}x{} too large",
image.width,
image.height
);
(1, 1, &[0u8; 4][..])
};
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Custom sky layer"),
size: wgpu::Extent3d {
width,
height,
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 | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
rgba,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Custom sky layer"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.uniform,
offset: 0,
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
})
.collect();
}
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
pub(super) fn prepare(
&mut self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
) {
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
continue;
}
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
let uniform = Uniform {
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
self.frame.push((draw.image, mode as u8));
}
}
pub(super) fn is_drawing(&self) -> bool {
!self.frame.is_empty()
}
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
/// changed.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
let k = a * b;
let src = match mode {
Blend::Multiply => 1.0 + (t - 1.0) * k,
Blend::Overlay => 0.5 + (t - 0.5) * k,
Blend::Replace | Blend::Alpha => t,
_ => t * k,
};
let c = blend(mode);
let f = |factor: wgpu::BlendFactor| match factor {
wgpu::BlendFactor::One => 1.0,
wgpu::BlendFactor::Zero => 0.0,
wgpu::BlendFactor::Src => src,
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
wgpu::BlendFactor::Dst => dst,
wgpu::BlendFactor::SrcAlpha => k,
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
other => panic!("{other:?}"),
};
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
match c.operation {
wgpu::BlendOperation::Add => s + d,
wgpu::BlendOperation::ReverseSubtract => d - s,
other => panic!("{other:?}"),
}
}
#[test]
fn every_mode_gives_its_documented_result() {
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
let (d, t) = (0.4, 0.8);
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
for mode in MODES {
if mode != Blend::Replace {
close(mix(mode, d, t, 1.0, 0.0), d);
}
}
// Half faded: add adds half.
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
}
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_box_covers_every_face_with_its_cell() {
let mesh = mesh();
assert_eq!(mesh.len(), 36);
for v in &mesh {
let [u0, v0, u1, v1] = v.cell;
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
}
@@ -0,0 +1,61 @@
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
// brightness 0 leaves the sky as it was.
struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@group(0) @binding(2) var sky_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
@location(1) @interpolate(flat) cell: vec4<f32>,
}
@vertex fn vs_custom_sky(
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) cell: vec4<f32>,
) -> Out {
var out: Out;
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
out.uv = uv;
out.cell = cell;
return out;
}
const ADD: u32 = 0u;
const SUBTRACT: u32 = 1u;
const MULTIPLY: u32 = 2u;
const DODGE: u32 = 3u;
const BURN: u32 = 4u;
const SCREEN: u32 = 5u;
const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(t.rgb, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(t.rgb * k, 1.0); }
}
}
@@ -14,8 +14,24 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
layer & 0xffff,
FLUID_OVERLAY
| TERRAIN_MODEL_SOLID_OVERLAY
| TERRAIN_MODEL_CUTOUT_OVERLAY
| TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY
)
}
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
pub const FLUID_OVERLAY: u32 = 41;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -31,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38;
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
if !is_native_terrain(vertex.layer) {
vertex.layer |= OIT_ENTITY;
}
}
}
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
@@ -60,12 +78,21 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
match layer & 0xffff {
TERRAIN_MODEL_SOLID_OVERLAY => return 13,
TERRAIN_MODEL_CUTOUT_OVERLAY => return 14,
TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15,
_ => {}
}
if layer & 0xffff == FLUID_OVERLAY {
return 10;
}
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
WORLD_TEXT_OVERLAY => 11,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -148,10 +175,10 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
for runs in &self.runs[10..] {
// Fluid and RenderType.text sort their own alpha-blended quads far to
// near. Fluid precedes both world-canvas layers, after existing materials
// (including eyes moved out of additive).
for runs in &self.runs[10..13] {
let start = self.combined.len();
for &(first, count) in runs {
self.combined
@@ -169,6 +196,13 @@ impl EntityBatch {
distance(b).total_cmp(&distance(a))
});
}
// Native solid/cutout models are depth writing and keep their submission order.
for runs in &self.runs[13..] {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
@@ -189,7 +223,57 @@ mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() {
let quad = |layer, x| {
[Vertex {
layer,
pos: [x, 0.0, 0.0],
..geometry(1, false)[0]
}; 4]
};
let entities: Vec<_> = [
(FLUID_OVERLAY, 10.0),
(WORLD_TEXT_OVERLAY, 11.0),
(TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0),
(TERRAIN_MODEL_SOLID_OVERLAY, 1.0),
(TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0),
(TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0),
(OIT_ENTITY, 6.0),
(0, 7.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
let counts = batch.update(
&entities,
&quad(0, 8.0),
&quad(0, 9.0),
&quad(0, 12.0),
true,
);
assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]);
let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
xs,
[7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0]
);
assert_eq!(
batch.combined[16].layer,
TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16
);
}
#[test]
fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() {
let mut vertices = [geometry(1, false)[0]; 2];
vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16;
vertices[1].layer = 3;
mark_oit(&mut vertices);
assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16);
assert_eq!(vertices[1].layer, 3 | OIT_ENTITY);
}
#[test]
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
@@ -204,6 +288,8 @@ mod tests {
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
(FLUID_OVERLAY, 11.0),
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
@@ -215,12 +301,14 @@ mod tests {
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
[
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
]
);
}
@@ -320,12 +408,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
@@ -0,0 +1,69 @@
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
label: Some("Still fluid (nearest texels, linear mipmaps)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
lod_min_clamp: 0.0,
lod_max_clamp: 32.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
};
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Still fluid"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
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(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -217,6 +217,41 @@ impl CenterDepth {
})
}
/// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as
/// the next frame reads it: an error unless exactly that one sample was read back, never
/// an older one.
pub fn take_completed(&mut self, device: &wgpu::Device) -> Result<f32, String> {
device
.poll(wgpu::PollType::Poll)
.map_err(|e| e.to_string())?;
let state = |s: &Slot| s.state.load(Ordering::Acquire);
let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED);
let (Some(slot), None) = (mapped.next(), mapped.next()) else {
return Err("the frame's centre depth wasn't read back alone".into());
};
if self
.slots
.iter()
.any(|s| !matches!(state(s), FREE | MAPPED))
{
return Err("the frame's centre depth wasn't read back alone".into());
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]))
.map_err(|e| e.to_string());
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
let depth = depth?;
if !depth.is_finite() {
return Err("the centre depth isn't finite".into());
}
self.latest = Some(depth.clamp(0.0, 1.0));
Ok(depth.clamp(0.0, 1.0))
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
@@ -265,6 +300,76 @@ mod tests {
});
}
/// Inline and on the submit thread: each finished frame's depth, then none left to
/// take; several outstanding samples are refused.
#[test]
#[ignore = "requires a host GPU"]
fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() {
let (device, raw) = test_device().expect("host GPU required");
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 3,
height: 3,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
for threaded in [false, true] {
let queue = super::super::submit::Queue::new(raw.clone(), threaded);
if threaded {
assert!(queue.threaded(), "deferred submit unavailable");
}
let mut center = CenterDepth::new(&device);
assert!(center.take_completed(&device).is_err());
for value in [0.125, 0.75, 0.25] {
let mut encoder = device.create_command_encoder(&Default::default());
clear(&mut encoder, &view, value);
center.copy(&device, &mut encoder, &depth);
let after = center.submitted().expect("one copied sample");
if threaded {
let job = super::super::submit::Job {
encoder,
surface: None,
after: Some(Box::new(after)),
};
assert!(queue.submit_later(job).is_ok());
queue.wait();
} else {
queue.submit([encoder.finish()]);
after();
}
device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_secs(30)),
})
.unwrap();
assert_eq!(center.take_completed(&device).unwrap(), value);
assert_eq!(center.collect(&device), Some(value));
assert!(
center.take_completed(&device).is_err(),
"no stale latest fallback"
);
}
// Several outstanding copies are ambiguous, even after they all map.
let mut encoder = device.create_command_encoder(&Default::default());
for _ in 0..SLOTS {
center.copy(&device, &mut encoder, &depth);
}
queue.submit([encoder.finish()]);
center.submitted().unwrap()();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(center.take_completed(&device).is_err());
}
}
/// Frames that go to the submit thread start their readback there: the centre depth
/// follows the frames instead of keeping the first ones' depth.
#[test]
@@ -454,9 +454,31 @@ impl PackEntities {
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut without_fluids = Vec::new();
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
// Keep it in its own material even when a shader pack draws the
// model in the same block-entity run. Do not corrupt either UV space.
let whole: &[Vertex] = if whole
.chunks(4)
.any(|q| super::is_native_terrain(q[0].layer))
{
without_fluids.clear();
for quad in whole.chunks(4) {
if super::is_native_terrain(quad[0].layer) {
if !span.hidden {
rest.extend_from_slice(quad);
}
} else {
without_fluids.extend_from_slice(quad);
}
}
&without_fluids
} else {
whole
};
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
@@ -1141,6 +1163,83 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn native_fluid_stays_native_while_model_uses_pack() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
for overlay in [
super::super::FLUID_OVERLAY,
super::super::TERRAIN_MODEL_SOLID_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
] {
let mut mixed = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
for v in &mut mixed[4..] {
v.layer = overlay | 279 << 16;
v.uv = [0.25, 0.75];
v.glint_uv = [17.0, 0.0];
}
let mut rest = Vec::new();
for hidden in [false, true] {
geometry.prepare(
&device,
&queue,
&layout.layout,
&mixed,
&[EntitySpan {
start: 0,
id: 77,
stage: render_stage("BLOCK_ENTITIES"),
hidden,
..Default::default()
}],
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
geometry.scratch.len(),
4,
"the model still uses the shader pack"
);
assert_eq!(geometry.draws.len(), 1);
assert_eq!(
(geometry.draws[0].pack, geometry.draws[0].shadow),
(!hidden, true)
);
if hidden {
assert!(
rest.is_empty(),
"hidden native fluid must not leak into the visible pass"
);
} else {
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
"fluid UV/layer payload cannot be sampled from the entity atlas"
);
}
}
}
}
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
}
impl PackImage {
pub(super) fn reset_history(&mut self) {
self.view = None;
self.copy = None;
self.before = None;
self.made_for = [0; 2];
}
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
@@ -316,6 +323,11 @@ impl StandIn {
}
}
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
*images = stand_ins(device, wanted);
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn reset_history(&mut self) {
self.group = None;
self.buffers.clear();
self.made_for = [0; 2];
}
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod history_reset_tests;
#[cfg(test)]
mod world_target_tests;
@@ -73,6 +76,9 @@ mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod end_fan_tests;
#[cfg(test)]
mod runtime_length_tests;
@@ -399,6 +405,8 @@ struct Pass {
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
/// Shader-declared defaults must survive a reset of the mutable upload block.
initial_block: Vec<u8>,
/// (index into `pack_uniforms::NAMES`, declared type, offset).
uniforms: Vec<(Option<usize>, String, u32)>,
samplers: Vec<Binding>,
@@ -452,6 +460,16 @@ struct Pass {
}
impl Pass {
/// Back to the uniform values the pass was made with, and no bind groups.
fn reset_history(&mut self) {
self.block.clone_from(&self.initial_block);
self.groups.clear();
self.portal_groups.clear();
for compute in &mut self.compute {
compute.reset_history();
}
}
fn render(&self) -> &wgpu::RenderPipeline {
match &self.pipeline {
Pipeline::Render(p) => p,
@@ -1973,6 +1991,7 @@ fn build_pass(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -2108,6 +2127,7 @@ fn build_compute(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -3124,23 +3144,7 @@ impl PackPost {
.create_view(&Default::default()),
textures,
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default()),
super::pack_shadow::stand_in(device),
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow"),
compare: Some(wgpu::CompareFunction::LessEqual),
@@ -3189,6 +3193,74 @@ impl PackPost {
})
}
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
pub fn take_center_depth(&mut self, device: &wgpu::Device) -> Result<(), String> {
self.center_depth
.as_mut()
.ok_or("the shader pack copied no centre depth")?
.take_completed(device)
.map(|_| ())
}
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
/// and the centre depth too. Compiled programs and the pack's textures stay.
pub fn reset_history(&mut self, device: &wgpu::Device) {
self.uniforms.reset_history();
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
self.custom.restart_at(0);
self.custom.forget_world();
self.center_depth = None;
self.targets = None;
self.main = 0;
self.mips_valid = 0;
self.rgb_unset = true;
self.final_target = None;
self.blit_group = None;
self.page_groups.clear();
for image in &mut self.images {
image.reset_history();
}
self.buffers.reset_history();
pack_images::reset_stand_ins(device, &mut self.stand_ins);
if let Some(shadow) = &mut self.shadow_targets {
shadow.reset_history(device, &self.uniforms.shadow);
}
self.shadow.0 = super::pack_shadow::stand_in(device);
self.shadow_cleared = false;
self.shadow_cache = Default::default();
self.setup_pending = true;
self.portal_texture = None;
self.portal_rect = [0.0; 4];
let sky = self
.sky
.iter_mut()
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
let entities = self
.entities
.iter_mut()
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
for pass in self
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
.chain(&mut self.clouds)
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp)
{
pass.reset_history();
}
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
/// current mapping. The buffers it both samples and stores to are copied first, and it
/// samples the copies.
@@ -3940,7 +4012,7 @@ impl PackPost {
frame: &super::Frame,
terrain: &mut TerrainDraw<'_>,
) {
if super::sky_visible(frame) {
if super::sky_visible(frame) || super::end_fog_sky(frame) {
self.draw_sky(device, queue, encoder, size, frame);
}
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
@@ -593,6 +593,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -431,6 +431,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -0,0 +1,151 @@
//! In the End, while the dragon fight's world fog leaves out vanilla's sky, a pack's
//! `gbuffers_skybasic` draws a fan in the fog colour, as our probe packs read it in Iris 1.11.7.
//! Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `gbuffers_skybasic` writes its vertex colour into colortex0 and `gl_Vertex` into colortex1
/// (32-bit floats). final shows, on the picture's top half, `gl_Vertex.y` of the view's lower
/// half as a 24-bit integer ((y + 64) * 65536), and on its bottom half colortex0 of the view's
/// upper half.
fn files() -> Vec<(String, String)> {
vec![
("shaders.properties".into(), "blend.gbuffers_skybasic=off\n".into()),
(
"gbuffers_skybasic.vsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\nvoid main() {\n\
\x20 gl_Position = ftransform();\n color = gl_Color;\n at = gl_Vertex.xyz;\n}\n"
.into(),
),
(
"gbuffers_skybasic.fsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\n/* DRAWBUFFERS:01 */\n\
void main() {\n gl_FragData[0] = color;\n gl_FragData[1] = vec4(at, 1.0);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 float y = texelFetch(colortex1, ivec2(gl_FragCoord.xy) - ivec2(0, 8), 0).y;\n\
\x20 uint n = min(uint((y + 64.0) * 65536.0 + 0.5), 16777215u);\n\
\x20 vec4 at = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
\x20 gl_FragColor = uv.y > 0.5 ? at : texelFetch(colortex0, ivec2(gl_FragCoord.xy) + ivec2(0, 8), 0);\n}\n"
.into(),
),
]
}
/// The pack's picture of the fixture's frame (16 pixels square, 90° field of view, looking level
/// toward +Z) in the End, the world having left the far plane everywhere.
fn picture(gpu: &Gpu, boss_world_fog: bool) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files(),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.end_sky = true;
frame.boss_world_fog = boss_world_fog;
frame.detail_distance = 192.0;
frame.fog = Some(crate::fog::FogData {
color: [0.2, 0.4, 0.6, 1.0],
environmental_start: 0.0,
environmental_end: 96.0,
render_distance_start: 172.8,
render_distance_end: 192.0,
sky_end: 96.0,
cloud_end: 96.0,
});
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
/// Where the view ray through pixel (`column`, `row`) meets the fan: from (0, -16, 0) to eight
/// points 16 above the eye at radius 192, every 45° from +Z. None when it passes above the rim.
fn fan_height(column: usize, row: usize) -> Option<f64> {
let side = |i: usize| (i as f64 + 0.5) / 8.0 - 1.0;
// The fan is mirrored across x = 0, so either sign of the view's x does.
let ray = glam::DVec3::new(side(column), -side(row), 1.0);
let azimuth = ray.x.atan2(ray.z).to_degrees();
let edge = (azimuth / 45.0).floor();
let rim = |k: f64| {
let a = (k * 45.0).to_radians();
glam::DVec3::new(192.0 * a.sin(), 16.0, 192.0 * a.cos())
};
let apex = glam::DVec3::new(0.0, -16.0, 0.0);
let normal = (rim(edge) - apex).cross(rim(edge + 1.0) - apex);
let along = apex.dot(normal) / ray.dot(normal);
let hit = ray * along;
(along > 0.0 && hit.y <= 16.0).then_some(hit.y)
}
#[test]
fn the_end_under_the_dragons_fog_has_the_packs_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, true);
let width = SIZE[0] as usize;
// Below the horizon: every pixel sees the fan, at the height the planes give (to 0.02:
// the GPU interpolates `gl_Vertex` across triangles 192 blocks wide; it came within 0.008).
for row in 8..16 {
for column in 0..width {
let p = pixels[(row - 8) * width + column];
let got = f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
/ 65536.0
- 64.0;
let expected = fan_height(column, row).expect("below the horizon");
assert!(
(got - expected).abs() < 0.02,
"row {row}, column {column}: gl_Vertex.y {got}, expected {expected}"
);
}
}
// Above it: only the row just over the horizon reaches the fan (the rim stands 4.8° up),
// drawn in the fog colour; above that, the world's grey stays.
for row in 0..8 {
for column in 0..width {
let p = pixels[(row + 8) * width + column];
let expected = match fan_height(column, row) {
Some(_) => [51, 102, 153, 255],
None => [16, 16, 16, 255],
};
assert_eq!(p, expected, "row {row}, column {column}");
}
assert_eq!(fan_height(0, row).is_some(), row == 7, "row {row}");
}
}
/// The End without the fog: vanilla's End sky, which no `gbuffers_skybasic` draws.
#[test]
fn the_end_without_the_fog_has_no_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, false);
let width = SIZE[0] as usize;
for (i, p) in pixels[8 * width..].iter().enumerate() {
assert_eq!(*p, [16, 16, 16, 255], "pixel {i}");
}
}
@@ -0,0 +1,109 @@
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
#[test]
#[ignore = "requires a host GPU with compute and fragment storage"]
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
add(
"setup.csh",
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
);
add(
"begin.csh",
&format!(
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
),
);
for name in ["composite", "shadowcomp"] {
add(&format!("{name}.vsh"), vertex);
let target = if name == "composite" { 2 } else { 0 };
let (declarations, value) = if name == "composite" {
("", "vec4(0.125)")
} else {
(
"uniform sampler2D shadowcolor0;varying vec2 uv;",
"texture2D(shadowcolor0,uv)+vec4(0.125)",
)
};
add(
&format!("{name}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{target} */\n\
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
),
);
}
add("final.vsh", vertex);
add(
"final.fsh",
&format!(
"#version 430 compatibility\n{state}\
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
),
);
add(
"shaders.properties",
"shadow.enabled=true\nbufferObject.0=8\n\
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
blend.composite=ONE ONE ONE ONE\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let load = || horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
let check = |pixels: &[[u8; 4]], frame: u8| {
let counter = 32 * frame;
for (i, pixel) in pixels.iter().enumerate() {
let expected = if i % 16 < 8 {
[counter, counter, counter, 255]
} else {
[counter, 32, 96, 255]
};
assert!(
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
);
}
};
for frame in 1..=3 {
check(&gpu.render(&mut post, [16, 16]), frame);
}
// At the same size too: the retained colortex2 and shadowcolor0, the image and
// the storage buffer start over, and setup runs once again. The control is a pack never
// reset.
for _ in 0..2 {
post.reset_history(&gpu.device);
for frame in 1..=2 {
let pixels = gpu.render(&mut post, [16, 16]);
check(&pixels, frame);
if frame == 1 {
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
}
}
fresh.reset_history(&gpu.device);
}
}
@@ -400,6 +400,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -76,7 +76,50 @@ fn target(
(texture, view)
}
/// The depth stand-in is initialized to no shadow on the next pack render.
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
Some([a, b]) => {
colors |= 1 << n;
if a.texture() != b.texture() {
doubled |= 1 << n;
}
a.texture().format()
}
None => wgpu::TextureFormat::Rgba8Unorm,
});
*self = Self::new(
device,
shadow,
formats,
colors,
doubled,
self.depth1.is_some(),
);
}
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
/// resolution is kept within the device's limit.
@@ -22,6 +22,8 @@ const SUNSET_APEX: u32 = 1;
const SUNSET_RIM: u32 = 2;
const STAR: u32 = 3;
const VOID: u32 = 4;
/// The End's fan (`fan`): `pos.xz` of a rim vertex is its direction, scaled by the radius.
const FAN: u32 = 5;
/// The frame's sky (std140).
#[repr(C)]
@@ -33,7 +35,7 @@ struct Uniform {
color: [f32; 4],
/// Sunset colour and alpha.
sunrise: [f32; 4],
/// cos and sin of the sunset fan's turn, star brightness.
/// cos and sin of the sunset fan's turn, star brightness, the End fan's radius.
sun: [f32; 4],
}
@@ -77,6 +79,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}} else if (hz_v_kind == {STAR}u) {{\n \
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
hz_Color = vec4(hz_sky_sun.z);\n \
}} else if (hz_v_kind == {FAN}u) {{\n \
hz_p = vec3(hz_v_pos.x * hz_sky_sun.w, hz_v_pos.y, hz_v_pos.z * hz_sky_sun.w);\n \
}} else if (hz_v_kind == {VOID}u) {{\n \
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
}}\n \
@@ -188,14 +192,37 @@ fn horizon(out: &mut Vec<Vertex>) {
}
}
/// The fan a pack's `gbuffers_skybasic` draws in the End while the dragon fight's fog leaves out
/// vanilla's sky (`super::end_fog_sky`): from (0, -16, 0) under the eye to eight rim vertices 16
/// above it, every 45° from +Z, at the render distance (the `far` uniform; scaled in the vertex
/// stage). Fitted to the `gl_Vertex` our probe packs' fragments read in Iris 1.11.7 at render
/// distances 12 and 8: 289 of 291 samples lie within 0.05 block of these eight planes (one of
/// the other two, checked, sat on a terrain edge), and nothing drew above the rim.
fn fan(out: &mut Vec<Vertex>) {
let rim = |i: i32| {
let a = (i as f32 * 45.0).to_radians();
Vertex {
pos: [a.sin(), 16.0, a.cos()],
kind: FAN,
}
};
let apex = Vertex {
pos: [0.0, -16.0, 0.0],
kind: FAN,
};
for i in 0..8 {
out.extend([apex, rim(i), rim(i + 1)]);
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
ranges: [Range<u32>; 4],
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
ranges: [Range<u32>; 5],
}
impl PackSky {
@@ -227,6 +254,8 @@ impl PackSky {
let stars = sunset.end..v.len() as u32;
disc(-4.0, VOID, &mut v);
let void = stars.end..v.len() as u32;
fan(&mut v);
let end_fan = void.end..v.len() as u32;
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack sky"),
contents: bytemuck::cast_slice(&v),
@@ -271,7 +300,7 @@ impl PackSky {
uniform,
layout,
group,
ranges: [sky, sunset, stars, void],
ranges: [sky, sunset, stars, void, end_fan],
}
}
@@ -294,7 +323,8 @@ impl PackSky {
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
/// and stars turn with it (7.1).
/// and stars turn with it (7.1). In the End under the dragon fight's fog, only the End's fan,
/// in the fog colour (`fogColor`), as stage SKY.
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
let turn = super::pack_uniforms::tilt_matrix(tilt);
let sun_angle = frame.sky.sun_angle.to_radians();
@@ -302,12 +332,22 @@ impl PackSky {
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
let theta = (flip + 90.0).to_radians();
let (rotation, brightness) = frame.stars.unwrap_or_default();
let [r, g, b] = frame.sky.sky_color;
let end_fan = super::end_fog_sky(frame);
let [r, g, b] = match &frame.fog {
Some(f) if end_fan => [f.color[0], f.color[1], f.color[2]],
None if end_fan => frame.sky.fog_color,
_ => frame.sky.sky_color,
};
let uniform = Uniform {
stars: (turn * rotation).to_cols_array_2d(),
color: [r, g, b, 1.0],
sunrise: frame.sky.sunrise_color,
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
sun: [
theta.cos(),
theta.sin(),
brightness.clamp(0.0, 1.0),
frame.detail_distance,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
@@ -327,12 +367,15 @@ impl PackSky {
})
.collect();
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void] = self.ranges.clone();
let [sky, sunset, stars, void, fan] = self.ranges.clone();
let part = |stage, textured, range| Part {
stage,
textured,
range,
};
if end_fan {
return vec![part("SKY", false, fan)];
}
let mut parts = vec![part("SKY", false, sky)];
if frame.sky.sunrise_color[3] > 0.001 {
parts.push(part("SUNSET", false, sunset));
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl FrameUniforms {
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
/// centre depth), keeping the pack's atlas size and shadow settings.
pub(super) fn reset_history(&mut self) {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
..Self::default()
};
}
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
/// at their targets, the clock from 0; the atlas size it was given stays.
#[test]
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
let hud = HudBatch::new([16, 16]);
let mut f = frame(&hud);
let mut uniforms = FrameUniforms {
atlas: [256, 128],
..Default::default()
};
let mut inputs = PackInputs {
rain: 1.0,
eye_light: [15, 15],
frame_timing: Some(FrameTiming {
counter: 800,
elapsed: 99.0,
delta: 1.0,
}),
..Default::default()
};
uniforms.center_depth_raw = Some(0.25);
uniforms.update(&f, &inputs, [16, 16]);
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
f.yaw = 77.0;
inputs.rain = 0.25;
inputs.eye_light = [3, 8];
inputs.frame_timing = None;
uniforms.reset_history();
uniforms.update(&f, &inputs, [16, 16]);
let float = |name| match uniforms.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
};
let int = |name| match uniforms.values[index(name).unwrap()] {
Value::I(v) => v,
_ => panic!("integer uniform expected"),
};
assert_eq!(int("frameCounter")[0], 0);
assert_eq!(float("frameTime")[0], 0.0);
assert_eq!(int("atlasSize")[..2], [256, 128]);
assert_eq!(float("wetness")[0], 0.25);
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
assert_eq!(float("centerDepthSmooth")[0], 1.0);
for (previous, current) in [
("previousCameraPosition", "cameraPosition"),
("gbufferPreviousModelView", "gbufferModelView"),
("gbufferPreviousProjection", "gbufferProjection"),
] {
assert_eq!(
float(previous).map(f32::to_bits),
float(current).map(f32::to_bits)
);
}
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -2411,6 +2476,7 @@ pub(super) mod initial_history_tests {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -323,4 +323,27 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
/// the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
@@ -0,0 +1,66 @@
//! Opaque/cutout terrain-sheet native models, with ordinary culling and depth writes.
pub(super) fn pipelines(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
sprite_constants: &[(&str, f64)],
) -> [wgpu::RenderPipeline; 3] {
std::array::from_fn(|i| {
let mut constants = sprite_constants.to_vec();
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
// The ordinary Vertex ABI is unchanged. This material's private payload uses
// normal's four bytes for a float z, and glint UV for float normal x/y.
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Uint32,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(
[
"Native model solid",
"Native model cutout",
"Native model cutout mipmapped",
][i],
),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
@@ -257,14 +257,23 @@ impl Post {
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
let upscale = if self.taa_on { self.upscale } else { 1.0 };
self.sizes_with_upscale(window, upscale)
}
fn sizes_with_upscale(&self, window: [u32; 2], upscale: f32) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
(
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
output,
)
}
/// The next TAA frame's input size, even before its first jitter or resize.
pub(super) fn taa_render_size(&self, window: [u32; 2]) -> [u32; 2] {
self.sizes_with_upscale(window, self.upscale).0
}
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
@@ -604,7 +613,7 @@ mod tests {
#[test]
fn jitter_stays_inside_the_pixel() {
for i in 1..=8 {
for i in 1..=128 {
let (x, y) = (halton(i, 2), halton(i, 3));
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
}
@@ -614,6 +623,30 @@ mod tests {
);
}
#[test]
fn culling_uses_the_next_taa_size_before_the_first_jitter_or_resize() {
let Some((device, _)) = test_device() else {
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
assert!(!post.taa_on);
assert!(post.size().is_none());
for (window, scale, upscale, expected) in [
([1280, 720], 1.0, 1.0, [1280, 720]),
([1281, 721], 0.5, 2.0, [321, 181]),
([3840, 2160], 0.25, 4.0, [240, 135]),
([10000, 10000], 2.0, 4.0, [2048, 2048]),
([0, 0], 0.5, 4.0, [1, 1]),
] {
post.scale = scale;
post.upscale = upscale;
post.taa_on = false;
assert_eq!(post.taa_render_size(window), expected);
post.taa_on = true;
assert_eq!(post.sizes(window).0, expected);
}
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
+7 -3
View File
@@ -1092,8 +1092,9 @@ impl Graphics {
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
// silicon only the performance cores count (horizon_threads::default_workers).
let auto = horizon_threads::jobs::default_threads();
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1476,7 +1477,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+15 -8
View File
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// 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 {
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
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;
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
+5
View File
@@ -1354,6 +1354,11 @@ impl HudAtlas {
bordered.insert(image.0.clone());
}
}
if full {
let (width, height, rgba) =
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
color: def.map_or(0, |d| d.color as i32),
ambient: effect.ambient,
duration: effect.duration,
// Horizon keeps no showIcon flag: every effect shows its icon.
show_icon: true,
show_icon: effect.show_icon,
}
}
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 mezz
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

+3
View File
@@ -36,12 +36,14 @@ pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
pub mod font;
pub mod fusion;
@@ -56,6 +58,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod terrain_model;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
+141 -39
View File
@@ -90,6 +90,11 @@ fn round_to_u16(v: f32) -> u16 {
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// A model quad's own UVs in `TerrainVertex` fixed point, the packing
/// `TerrainVertex::new` does, baked once per face (`BakedFace::uv_fixed`).
pub(crate) fn baked_uv(uv: [[f32; 2]; 4]) -> [[u16; 2]; 4] {
uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)))
}
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
/// which span `[0, 2048)`: merged faces repeat their sprite once per block.
pub const TILED_UV_STEPS: f32 = 32.0;
@@ -103,6 +108,23 @@ impl TerrainVertex {
color,
}
}
/// A vertex whose UV is already in `UV_STEPS` fixed point (a model face's
/// own UVs, baked in `BakedFace::uv_fixed`).
pub(crate) fn packed_uv(
pos: [f32; 3],
uv: [u16; 2],
layer: u16,
color: u32,
light: u16,
) -> Self {
Self {
pos,
uv,
layer,
light,
color,
}
}
/// A vertex of a merged distant-terrain face, its UV in blocks from the
/// face's corner. Drawn with an instance UV scale of `1 / TILED_UV_STEPS`.
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
@@ -122,6 +144,63 @@ impl TerrainVertex {
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
#[test]
fn baked_face_keeps_shape_bounds_and_fixed_point_uvs() {
use crate::models::catalog::BakedFace;
use horizon_assets::{
bake::ModelQuad,
model::{Direction, Material},
};
let face = BakedFace::new(
ModelQuad {
positions: [
[-0.25, 0.5, 0.125],
[-0.25, 0.5, 0.875],
[0.625, 0.5, 0.875],
[0.625, 0.5, 0.125],
],
uv: [
[-0.25, f32::NAN],
[1.0 / 65536.0, 3.0 / 65536.0],
[1.5, 2.0],
[f32::INFINITY, f32::NEG_INFINITY],
],
facing: Direction::Up,
cull: None,
material: Material {
sprite: "test:face".into(),
force_translucent: false,
},
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
0,
true,
false,
None,
);
// Literal expectations: axis-ordered extrema and complements, plus
// clamping and half-step rounding in 1/32768 UV units.
let bounds: [f32; 12] = [
0.5, 0.5, 0.125, 0.875, -0.25, 0.625, 0.5, 0.5, 0.875, 0.125, 1.25, 0.375,
];
for shape in face.shapes {
assert_eq!(shape.bounds.map(f32::to_bits), bounds.map(f32::to_bits));
assert!(shape.partial);
}
assert!(!face.shapes[0].cubic);
assert!(face.shapes[1].cubic);
assert_eq!(face.uv_fixed, [[0, 0], [1, 2], [49152, 65535], [65535, 0]]);
}
#[test]
fn model_uvs_pack_exactly() {
for i in 0..=32 {
@@ -957,6 +1036,12 @@ const P: usize = 16 + 2 * B as usize;
fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// `pi`'s index offset of a step `n`: `pi(p + n) - pi(p)`, so the samples
/// around a cell are `base + step_index(n)` rather than another `pi`.
#[inline]
const fn step_index(n: [i32; 3]) -> isize {
(n[0] + P as i32 * n[2] + (P * P) as i32 * n[1]) as isize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
@@ -1079,6 +1164,17 @@ const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// `step_index` of each direction's normal, indexed like `DIRECTIONS`: the
/// padded index of a block's neighbour is its own plus one of these.
const OFFSETS: [isize; 6] = {
let mut out = [0isize; 6];
let mut i = 0;
while i < 6 {
out[i] = step_index(DIRECTIONS[i].1);
i += 1;
}
out
};
/// 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).
@@ -1540,8 +1636,12 @@ impl Mesher<'_> {
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
// The padded index of the row's x = 0 cell: `pi(x, y, z)` is
// `row + x` and a neighbour adds `OFFSETS[d]`.
let row = pi(0, y, z);
for x in 0..16 {
let id = pad.blocks[pi(x, y, z)];
let cell = row + x as usize;
let id = pad.blocks[cell];
if id == 0 {
continue;
}
@@ -1596,7 +1696,7 @@ impl Mesher<'_> {
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let neighbor = pad.blocks[(cell as isize + OFFSETS[d as usize]) as usize];
// `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] >> opposite & 1 != 0 {
@@ -1656,7 +1756,8 @@ impl Mesher<'_> {
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
let other =
pad.blocks[(cell as isize + step_index([0, dy, 0])) as usize];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
@@ -1666,6 +1767,7 @@ impl Mesher<'_> {
&mut tints,
&mut out,
[x, y, z],
cell,
origin,
face,
ao,
@@ -1706,6 +1808,7 @@ impl Mesher<'_> {
tints: &mut Tints,
out: &mut SectionMesh,
pos: [i32; 3],
cell: usize,
origin: [f32; 3],
face: &BakedFace,
ao: bool,
@@ -1730,11 +1833,14 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// The quad's shape (bounds, partial, cubic), baked per face for full and
// non-full blocks: a pure function of the quad and the block's `full`.
let shape = &face.shapes[usize::from(state.full)];
// 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);
lighting::ambient_occlusion(pad, info, state, pos, shape, q.facing);
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
@@ -1744,7 +1850,7 @@ impl Mesher<'_> {
} 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),
None => lighting::flat_shaped(pad, state, pos, q.facing, shape),
};
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
@@ -1768,8 +1874,7 @@ impl Mesher<'_> {
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
@@ -1810,44 +1915,41 @@ impl Mesher<'_> {
&mut push,
)
};
let vertex = |tint: Option<u32>, i: usize, uv: [u16; 2]| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
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::packed_uv(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv,
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
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,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
),
uv[i],
)
})
let fixed = uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)));
std::array::from_fn::<_, 4, _>(|i| (vertex(tint, i, fixed[i]), uv[i]))
};
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
let corners = if state.pane && tiled {
vertices(tint, standard())
} else {
// The face's own UVs, already packed: no per-vertex rounding.
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
};
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -19,6 +19,11 @@ use std::{convert::Infallible, sync::OnceLock};
/// `FluidRenderer.MAX_FLUID_HEIGHT`.
const MAX_HEIGHT: f32 = 0.888_888_9;
const EPSILON: f32 = 0.001;
/// `super::step_index` of the unit steps of the padded index: one block east
/// (+x), one south (+z), one up (+y).
const X_STEP: isize = 1;
const P_STEP: isize = super::P as isize;
const UP: isize = super::step_index([0, 1, 0]);
/// For the occlusion face toward `direction`: the height from the bottom over
/// which its slice flush with the `direction` side covers the whole width
@@ -224,14 +229,14 @@ pub(super) fn tesselate(
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
// The cell's own padded index: every other cell sampled here is a constant
// step from it (`super::step_index`), so no `pi` per sample.
let cell = pi(x, y, z) as isize;
let at = |step: isize| &info[pad.blocks[(cell + step) as usize] as usize];
let me = at(0);
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let neighbor = |d: Direction| at(super::OFFSETS[d as usize]);
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
let should_render = |d: Direction| !same(neighbor(d)) && !occluded_by_self(me, d);
let render_up = !same(up);
@@ -265,10 +270,10 @@ pub(super) fn tesselate(
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
let height = |step: isize| -> f32 {
let s = at(step);
if same(s) {
if same(at([p[0], p[1] + 1, p[2]])) {
if same(at(step + UP)) {
1.0
} else {
s.fluid.own_height()
@@ -279,15 +284,15 @@ pub(super) fn tesselate(
-1.0
}
};
let own = height([x, y, z]);
let own = height(0);
let (mut ne, mut nw, mut se, mut sw) = (1.0f32, 1.0f32, 1.0f32, 1.0f32);
if own < 1.0 {
let n = height([x, y, z - 1]);
let s = height([x, y, z + 1]);
let e = height([x + 1, y, z]);
let w = height([x - 1, y, z]);
let n = height(-P_STEP);
let s = height(P_STEP);
let e = height(X_STEP);
let w = height(-X_STEP);
// calculateAverageHeight(self, height2, height1, corner)
let average = |h2: f32, h1: f32, corner: [i32; 3]| -> f32 {
let average = |h2: f32, h1: f32, corner: isize| -> f32 {
if h1 >= 1.0 || h2 >= 1.0 {
return 1.0;
}
@@ -313,16 +318,17 @@ pub(super) fn tesselate(
add(h2);
sum[0] / sum[1]
};
ne = average(n, e, [x + 1, y, z - 1]);
nw = average(n, w, [x - 1, y, z - 1]);
se = average(s, e, [x + 1, y, z + 1]);
sw = average(s, w, [x - 1, y, z + 1]);
ne = average(n, e, X_STEP - P_STEP);
nw = average(n, w, -X_STEP - P_STEP);
se = average(s, e, X_STEP + P_STEP);
sw = average(s, w, -X_STEP + P_STEP);
}
let light = |p: [i32; 3]| {
let above = [p[0], p[1] + 1, p[2]];
let light = |step: isize| {
let i = (cell + step) as usize;
let above = (cell + step + UP) as usize;
pack_coords(lighting::max_coords(
lighting::coords(pad, at(p), p),
lighting::coords(pad, at(above), above),
lighting::coords_at(pad, lighting::state_at(pad, info, i), i),
lighting::coords_at(pad, lighting::state_at(pad, info, above), above),
))
};
let (still, flow, overlay) = if water {
@@ -393,7 +399,7 @@ pub(super) fn tesselate(
// FluidState.shouldRenderBackwardUpFace over the 3x3 cells above.
let backward = (-1..=1).any(|ox| {
(-1..=1).any(|oz| {
let s = at([x + ox, y + 1, z + oz]);
let s = at(UP + ox as isize * X_STEP + oz as isize * P_STEP);
!same(s) && !s.solid
})
});
@@ -412,7 +418,7 @@ pub(super) fn tesselate(
corners,
layer,
color(cardinal(D::Up)),
light([x, y, z]),
light(0),
backward,
Direction::Up as usize,
);
@@ -428,12 +434,12 @@ pub(super) fn tesselate(
],
still,
color(cardinal(D::Down)),
light([x, y - 1, z]),
light(-UP),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let side_light = light(0);
let up_shade = cardinal(D::Up);
for (i, d) in [
Direction::North,
@@ -3,6 +3,7 @@
//! and `ModelBlockRenderer`'s integer colour arithmetic (`ARGB.gray`,
//! `scaleRGB`, `multiply`).
use super::{Info, Padded, pi};
use crate::models::catalog::QuadShape;
use horizon_assets::model::Direction as D;
/// `LightCoordsUtil.FULL_BRIGHT`.
@@ -25,28 +26,37 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
/// The normals of `D`'s variants, in the order [`ADJACENCY`] is indexed by.
const NORMALS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
D::Up => [0, 1, 0],
D::North => [0, 0, -1],
D::South => [0, 0, 1],
D::West => [-1, 0, 0],
D::East => [1, 0, 0],
}
NORMALS[face as usize]
}
pub(super) fn step(p: [i32; 3], face: D) -> [i32; 3] {
let n = normal(face);
[p[0] + n[0], p[1] + n[1], p[2] + n[2]]
}
fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
&info[pad.blocks[pi(p[0], p[1], p[2])] as usize]
/// The block state of the padded cell at index `i` (see `pi`, `step_index`).
#[inline]
pub(super) fn state_at<'a>(pad: &Padded, info: &'a [Info], i: usize) -> &'a Info {
&info[pad.blocks[i] as usize]
}
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of the cell at index `i`, as seen through `state`.
#[inline]
pub(super) fn coords_at(pad: &Padded, state: &Info, i: usize) -> u32 {
packed_coords(state, pad.light[i])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
@@ -238,64 +248,21 @@ const ADJACENCY: [Adjacency; 6] = [
adjacency(D::West),
adjacency(D::East),
];
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape).
struct Shape {
bounds: [f32; 12],
partial: bool,
cubic: bool,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> Shape {
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index as usize] = value;
bounds[index as usize + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial = |a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
D::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
D::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
D::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
D::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
D::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
D::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
Shape {
bounds,
partial,
cubic,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> QuadShape {
QuadShape::new(positions, face, full)
}
/// `prepareQuadFlat` for a quad without a cull face: light from the
/// neighbour when the quad is flush with the face, else from the block itself.
pub(super) fn flat(
/// neighbour when the quad is flush with the face (cubic), else from the block
/// itself. `shape` is the quad's baked [`QuadShape`].
pub(super) fn flat_shaped(
pad: &Padded,
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
shape: &QuadShape,
) -> u32 {
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
let at = if shape.cubic { step(pos, face) } else { pos };
coords(pad, own, at)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
@@ -314,6 +281,51 @@ pub(super) fn flat_position(
None => pos,
}
}
/// A face's AO sample offsets from the sample base, in `pi` index space, so a
/// sample is `base + offset` rather than a `pi` per coordinate triple.
struct Samples {
/// The four corners (`adjacency`'s).
corners: [isize; 4],
/// Each corner stepped out of the face (the permeability sample).
out: [isize; 4],
/// Corner `a` stepped toward corner `b`.
diagonal: [[isize; 4]; 4],
/// `step(pos, face)` from the block's own cell.
step: isize,
}
const fn samples(face: D) -> Samples {
let mut corners = [0isize; 4];
let mut out = [0isize; 4];
let mut diagonal = [[0isize; 4]; 4];
let step = super::step_index(NORMALS[face as usize]);
let mut i = 0;
while i < 4 {
let c = super::step_index(NORMALS[ADJACENCY[face as usize].corners[i] as usize]);
corners[i] = c;
out[i] = c + step;
let mut j = 0;
while j < 4 {
diagonal[i][j] =
c + super::step_index(NORMALS[ADJACENCY[face as usize].corners[j] as usize]);
j += 1;
}
i += 1;
}
Samples {
corners,
out,
diagonal,
step,
}
}
const SAMPLES: [Samples; 6] = [
samples(D::Down),
samples(D::Up),
samples(D::North),
samples(D::South),
samples(D::West),
samples(D::East),
];
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
pub(super) fn ambient_occlusion(
@@ -321,26 +333,34 @@ pub(super) fn ambient_occlusion(
info: &[Info],
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
shape: &QuadShape,
face: D,
) -> ([f32; 4], [u32; 4]) {
let shape = shape(positions, face, own.full);
let base = if shape.cubic { step(pos, face) } else { pos };
let samples = &SAMPLES[face as usize];
// The block's own cell and the sample base (the neighbour when the quad is
// cubic): every corner and diagonal is a constant step from it.
let cell = pi(pos[0], pos[1], pos[2]) as isize;
let base = if shape.cubic {
cell + samples.step
} else {
cell
};
let adjacency = &ADJACENCY[face as usize];
let corner = |i: usize| step(base, adjacency.corners[i]);
let sample = |p: [i32; 3]| {
let s = state(pad, info, p);
(coords(pad, s, p), s.shade)
let corner = |i: usize| base + samples.corners[i];
let sample = |i: isize| {
let i = i as usize;
let s = state_at(pad, info, i);
(coords_at(pad, s, i), s.shade)
};
let (light0, shade0) = sample(corner(0));
let (light1, shade1) = sample(corner(1));
let (light2, shade2) = sample(corner(2));
let (light3, shade3) = sample(corner(3));
let permeable = |i: usize| state(pad, info, step(corner(i), face)).permeable;
let permeable = |i: usize| state_at(pad, info, (base + samples.out[i]) as usize).permeable;
let (p0, p1, p2, p3) = (permeable(0), permeable(1), permeable(2), permeable(3));
let diagonal = |a: usize, b: usize, open: bool| {
if open {
sample(step(corner(a), adjacency.corners[b]))
sample(base + samples.diagonal[a][b])
} else {
(light0, shade0)
}
@@ -349,15 +369,15 @@ pub(super) fn ambient_occlusion(
let (light03, shade03) = diagonal(0, 3, p3 || p0);
let (light12, shade12) = diagonal(1, 2, p2 || p1);
let (light13, shade13) = diagonal(1, 3, p3 || p1);
let next = step(pos, face);
let next_state = state(pad, info, next);
let next = (cell + samples.step) as usize;
let next_state = state_at(pad, info, next);
let light_center = if shape.cubic || !next_state.solid {
coords(pad, next_state, next)
coords_at(pad, next_state, next)
} else {
coords(pad, own, pos)
coords_at(pad, own, cell as usize)
};
let shade_center = if shape.cubic {
state(pad, info, base).shade
state_at(pad, info, base as usize).shade
} else {
own.shade
};
@@ -393,6 +413,57 @@ pub(super) fn ambient_occlusion(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ao_offsets_match_coordinate_samples_at_section_edges() {
// Explicit coordinate directions, without NORMALS, ADJACENCY or step_index.
let (down, up, north, south, west, east) = (
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
);
let faces = [
(D::Down, down, [west, east, north, south]),
(D::Up, up, [east, west, north, south]),
(D::North, north, [up, down, east, west]),
(D::South, south, [west, east, down, up]),
(D::West, west, [up, down, north, south]),
(D::East, east, [down, up, north, south]),
];
let add = |a: [i32; 3], b: [i32; 3]| std::array::from_fn(|i| a[i] + b[i]);
let index = |p: [i32; 3]| pi(p[0], p[1], p[2]);
for x in [0, 1, 14, 15] {
for y in [0, 1, 14, 15] {
for z in [0, 1, 14, 15] {
let pos = [x, y, z];
for (face, normal, corners) in faces {
let samples = &SAMPLES[face as usize];
let cell = index(pos) as isize;
assert_eq!((cell + samples.step) as usize, index(add(pos, normal)));
for cubic in [false, true] {
let base_pos = if cubic { add(pos, normal) } else { pos };
let base = if cubic { cell + samples.step } else { cell };
for (i, corner) in corners.into_iter().enumerate() {
let p = add(base_pos, corner);
assert_eq!((base + samples.corners[i]) as usize, index(p));
assert_eq!((base + samples.out[i]) as usize, index(add(p, normal)));
}
for (a, b) in [(0, 2), (0, 3), (1, 2), (1, 3)] {
assert_eq!(
(base + samples.diagonal[a][b]) as usize,
index(add(add(base_pos, corners[a]), corners[b]))
);
}
}
}
}
}
}
}
const fn packed(block: u32, sky: u32) -> u32 {
block << 4 | sky << 20
}
@@ -1,6 +1,44 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_steps_match_coordinate_lookup_through_the_border() {
// Coordinate lookup is the old addressing path, independent of step_index
// and the direction table. Include every cell and the full two-cell halo.
let neighbors = [
(Direction::Down, [0, -1, 0]),
(Direction::Up, [0, 1, 0]),
(Direction::North, [0, 0, -1]),
(Direction::South, [0, 0, 1]),
(Direction::West, [-1, 0, 0]),
(Direction::East, [1, 0, 0]),
];
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
let cell = (pi(0, y, z) + x as usize) as isize;
assert_eq!(cell as usize, pi(x, y, z));
for (face, [dx, dy, dz]) in neighbors {
assert_eq!(
(cell + OFFSETS[face as usize]) as usize,
pi(x + dx, y + dy, z + dz)
);
}
for dy in -2..=2 {
for dz in -2..=2 {
for dx in -2..=2 {
assert_eq!(
(cell + step_index([dx, dy, dz])) as usize,
pi(x + dx, y + dy, z + dz)
);
}
}
}
}
}
}
}
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
let mut pad = Padded::default();
@@ -9,6 +9,7 @@ use horizon_assets::{
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
choice::CompiledChoices,
},
model::Direction,
};
use horizon_core::{
position::BlockPos,
@@ -26,6 +27,65 @@ use std::{
sync::OnceLock,
};
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape). A function of the quad's positions and the
/// block's `isCollisionShapeFullBlock` only, so it is baked per face, once for
/// each.
#[derive(Clone, Copy)]
pub struct QuadShape {
/// The quad's bounds by face index (`mesher::lighting`'s `SizeInfo`
/// order: down, up, north, south, west, east), and `1.0 - the bound`.
pub bounds: [f32; 12],
pub partial: bool,
pub cubic: bool,
}
impl QuadShape {
pub fn new(positions: &[[f32; 3]; 4], face: Direction, full: bool) -> Self {
// `SizeInfo` indices into the face shape.
const DOWN: usize = 0;
const UP: usize = 1;
const NORTH: usize = 2;
const SOUTH: usize = 3;
const WEST: usize = 4;
const EAST: usize = 5;
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index] = value;
bounds[index + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial =
|a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
Direction::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
Direction::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
Direction::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
Direction::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
Direction::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
Direction::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
QuadShape {
bounds,
partial,
cubic,
}
}
}
pub struct BakedFace {
pub quad: ModelQuad,
pub layer: u32,
@@ -37,6 +97,33 @@ pub struct BakedFace {
/// 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>,
/// `prepareQuadShape` of the quad, indexed by the block's `full` flag: the
/// mesher would otherwise recompute it for every face of every block.
pub shapes: [QuadShape; 2],
/// The quad's own UVs in `TerrainVertex` fixed point, which the mesh
/// otherwise recomputes for every face of every block.
pub uv_fixed: [[u16; 2]; 4],
}
impl BakedFace {
pub fn new(
quad: ModelQuad,
layer: u32,
opaque: bool,
translucent: bool,
fusion: Option<std::num::NonZeroU16>,
) -> Self {
let shapes = [false, true].map(|full| QuadShape::new(&quad.positions, quad.facing, full));
let uv_fixed = crate::mesher::baked_uv(quad.uv);
Self {
quad,
layer,
opaque,
translucent,
fusion,
shapes,
uv_fixed,
}
}
}
pub struct BakedModel {
pub ambient_occlusion: bool,
@@ -97,18 +184,12 @@ fn bake_resolved(
Some(RenderType::Translucent | RenderType::Tripwire) => (true, false),
Some(RenderType::Solid) => (false, true),
Some(RenderType::Cutout | RenderType::CutoutMipped) => (false, false),
None => (
None | Some(RenderType::Unsupported) => (
quad.material.force_translucent || sprites.translucent[layer as usize],
sprites.region_opaque(layer, &quad.uv),
),
};
Ok(BakedFace {
translucent,
opaque,
quad,
layer,
fusion: None,
})
Ok(BakedFace::new(quad, layer, opaque, translucent, None))
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
@@ -118,13 +118,7 @@ mod tests {
)
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 7,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 7, true, false, None))
.collect();
let Some(styles) = cube_styles(&faces) else {
assert!(lock);
@@ -153,13 +147,7 @@ mod tests {
bake_model_transformed(&model, BlockModelTransform::new([0; 3], false))
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 0,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 0, true, false, None))
.collect();
faces[0].opaque = false;
assert!(cube_styles(&faces).is_none());
@@ -1515,6 +1515,8 @@ pub enum Event {
PlayMusicTrack(String, String),
/// Open the recordings folder in the system file browser.
OpenStudioFolder,
/// Start playback from the selected Studio recording.
OpenStudioReplay(std::path::PathBuf),
}
/// A row of the Music page: the track, and the pool it is played from.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -3978,6 +3980,9 @@ impl OptionsUi {
return match self.studio.click((x, y), size) {
StudioAction::None => Vec::new(),
StudioAction::Refresh => vec![Event::ButtonClick],
StudioAction::OpenReplay(path) => {
vec![Event::ButtonClick, Event::OpenStudioReplay(path)]
}
StudioAction::OpenFolder => vec![Event::ButtonClick, Event::OpenStudioFolder],
StudioAction::Back => {
let mut events = vec![Event::ButtonClick];
@@ -4459,6 +4464,10 @@ impl OptionsUi {
self.graphics.scroll(s, size, amount);
return;
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.scroll(s, size, amount);
return;
+65 -31
View File
@@ -88,6 +88,7 @@ impl SavedRecordingItem {
#[derive(Clone, Debug, PartialEq)]
pub enum StudioAction {
None,
OpenReplay(PathBuf),
OpenFolder,
Refresh,
Back,
@@ -103,6 +104,12 @@ pub struct StudioUi {
pub record_dir: PathBuf,
}
/// A recording's row and the gap under it, in GUI pixels.
const ROW_STEP: f32 = 40.0;
const ROW_GAP: f32 = 4.0;
/// The panel's margins above and below its rows.
const LIST_MARGINS: f32 = 10.0;
impl Default for StudioUi {
fn default() -> Self {
let dir = PathBuf::from("recordings");
@@ -270,18 +277,19 @@ impl StudioUi {
);
} else {
// Render scrollable cards
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let visible_cards = (panel_h / row_step).floor() as usize;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
// Rows that fit whole between the panel's margins, as the loop below draws them.
let visible_cards = ((panel_h - LIST_MARGINS + ROW_GAP) / row_step).floor() as usize;
let max_scroll =
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
self.scroll = self.scroll.clamp(0.0, max_scroll);
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step) as usize;
// Whole rows: nothing here clips one that's partly scrolled off.
let mut y = panel_y + 6.0;
let start_idx = (self.scroll / row_step).round() as usize;
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -289,6 +297,8 @@ impl StudioUi {
}
let rect = [card_x, y, card_w, card_h];
crate::widgets::record_as(&item.title, rect, true, crate::widgets::Kind::Row);
crate::widgets::target_as(rect, crate::widgets::Kind::Row);
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
let is_selected = self.selected == Some(idx);
@@ -334,6 +344,12 @@ impl StudioUi {
y += row_step;
}
crate::widgets::scroll_area(
[panel_x, panel_y, panel_w, panel_h],
self.scroll,
max_scroll,
);
// Scrollbar track & thumb if scrollable
if max_scroll > 0.0 {
let track_x = panel_x + panel_w - 4.0;
@@ -357,35 +373,35 @@ impl StudioUi {
// Button 1: Open Folder (Left)
let f_btn = [panel_x, btn_y, 130.0, btn_h];
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
style::button(
batch,
atlas,
f_btn,
&Text::tr("horizon.studio.open_folder", &[]),
(f_hover, true, false),
);
let label = Text::tr("horizon.studio.open_folder", &[]);
crate::widgets::record(&label.plain(), f_btn, true);
style::button(batch, atlas, f_btn, &label, (f_hover, true, false));
// Button 2: Refresh (Center-Left)
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
let label = Text::tr("horizon.studio.refresh", &[]);
crate::widgets::record(&label.plain(), r_btn, true);
style::button(batch, atlas, r_btn, &label, (r_hover, true, false));
// Button 3: Open the selected recording for playback.
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
let e_hover = ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]);
style::button(
batch,
atlas,
r_btn,
&Text::tr("horizon.studio.refresh", &[]),
(r_hover, true, false),
e_btn,
&Text::tr("horizon.studio.open_replay", &[]),
(e_hover, true, false),
);
// Button 3: Done / Back (Right)
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
style::button(
batch,
atlas,
d_btn,
&Text::tr("gui.done", &[]),
(d_hover, true, false),
);
let label = Text::tr("gui.done", &[]);
crate::widgets::record(&label.plain(), d_btn, true);
style::button(batch, atlas, d_btn, &label, (d_hover, true, false));
crate::widgets::declare_action(crate::widgets::Action::Done, d_btn, true);
}
/// Process mouse click on the Studio Browser screen.
@@ -411,20 +427,32 @@ impl StudioUi {
return StudioAction::Refresh;
}
// Button 3: Done
// Button 3: Open Replay
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
if ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]) {
if let Some(idx) = self.selected
&& let Some(item) = self.recordings.get(idx)
{
return StudioAction::OpenReplay(item.path.clone());
} else if let Some(item) = self.recordings.first() {
self.selected = Some(0);
return StudioAction::OpenReplay(item.path.clone());
}
}
// Button 4: Done
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
if ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]) {
return StudioAction::Back;
}
// Card clicks
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let start_idx = (self.scroll / row_step) as usize;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step).round() as usize;
let mut y = panel_y + 6.0;
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -444,7 +472,8 @@ impl StudioUi {
/// Scroll list by mouse wheel delta.
pub fn wheel(&mut self, amount: f32) {
self.scroll = (self.scroll - amount * 24.0).max(0.0);
// A notch is a row, as a focus step needs (`gamepad::list_step`).
self.scroll = (self.scroll - amount * ROW_STEP).max(0.0);
}
}
@@ -516,6 +545,11 @@ mod tests {
assert_eq!(action, StudioAction::None);
assert_eq!(ui.selected, Some(0));
// Click the bottom-bar Open Replay button.
// Panel width = 387, panel_x = 20, button x = 227..337, y = 692..712.
let open_replay_action = ui.click((280.0, 700.0), (427.0, 720.0));
assert!(matches!(open_replay_action, StudioAction::OpenReplay(_)));
// Clean up
let _ = fs::remove_dir_all(&temp_dir);
}
@@ -527,6 +561,6 @@ mod tests {
assert_eq!(ui.scroll, 0.0); // clamped at 0
ui.scroll = 100.0;
ui.wheel(1.0);
assert_eq!(ui.scroll, 76.0);
assert_eq!(ui.scroll, 100.0 - ROW_STEP);
}
}
@@ -723,6 +723,72 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
return apply_fog(color, in.spherical, in.cylindrical);
}
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
// packing or entity-model diffuse shading. The absolute sprite layer occupies glint UV x.
struct NativeFluidOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) sprite: u32,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
}
@vertex fn vs_native_fluid(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(6) sprite: vec2<f32>) -> NativeFluidOut {
let block = max(f32(light.y), dynamic_block_light(position));
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
}
return NativeFluidOut(globals.view_proj * vec4(position, 1.0), uv, lit, u32(sprite.x),
length(position), max(length(position.xz), abs(position.y)));
}
@fragment fn fs_native_fluid(in: NativeFluidOut) -> @location(0) vec4<f32> {
// The source translucent shader has no alpha discard; transparent texels write depth too.
let color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
return apply_fog(color, in.spherical, in.cylindrical);
}
// Native terrain models retain floating-point transformed normals. Fullbright still receives
// entity directional shading; the material's alpha test and mip policy are independent.
// Diffuse/normal policy: Flywheel 1.0.6, copyright2021-2024 Jozufozu (MIT);
// full permission/warranty notice in THIRD_PARTY_NOTICES.md.
override NATIVE_MODEL_ALPHA_CUTOFF: f32 = 0.0;
struct NativeModelOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) sprite: u32,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
@location(5) normal: vec3<f32>,
}
@vertex fn vs_native_model(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal_z: u32, @location(6) normal_xy: vec2<f32>) -> NativeModelOut {
let block = max(f32(light.y), dynamic_block_light(position));
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
}
return NativeModelOut(globals.view_proj * vec4(position, 1.0), uv, lit, layer >> 16u,
length(position), max(length(position.xz), abs(position.y)),
vec3(normal_xy, bitcast<f32>(normal_z)));
}
@fragment fn fs_native_model(in: NativeModelOut) -> @location(0) vec4<f32> {
var color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
if NATIVE_MODEL_ALPHA_CUTOFF > 0.0 && color.a < NATIVE_MODEL_ALPHA_CUTOFF { discard; }
if any(in.normal != vec3(0.0)) {
let light0 = normalize(vec3(0.2, 1.0, -0.7));
let light1 = normalize(vec3(-0.2, select(1.0, -1.0, globals.time.y > 0.5), 0.7));
let shade = min(1.0, (max(0.0, dot(light0, in.normal))
+ max(0.0, dot(light1, in.normal))) * 0.6 + 0.4);
color = vec4(color.rgb * shade, color.a);
}
return apply_fog(color, in.spherical, in.cylindrical);
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
// the sky colour with sky fog (sky.fsh: fog 0..FogSkyEnd on the spherical
// distance, a step at FogSkyEnd on the cylindrical one), evaluated per pixel
@@ -0,0 +1,215 @@
//! Native terrain-sheet models with explicit material and UV policy.
use crate::{gfx, mesher::Vertex, models::catalog::BakedFace};
use glam::{Mat3, Mat4, Vec3};
use horizon_assets::bake::RenderType;
fn same_sprite(left: &str, right: &str) -> bool {
left.strip_prefix("minecraft:").unwrap_or(left)
== right.strip_prefix("minecraft:").unwrap_or(right)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeModelMaterial {
/// Use each baked quad's material; unsupported translucent/tripwire quads fail the whole call.
#[default]
Model,
Solid,
Cutout,
CutoutMipped,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeModelShade {
/// Respect the baked quad's directional-shading flag.
#[default]
Model,
Off,
/// The level's two entity light directions, even at full block/sky light.
Entity,
}
/// Transform UVs only on the explicitly named original sprite, in unit-sprite coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeModelUvShift<'a> {
pub source_sprite: &'a str,
pub target_sprite: &'a str,
pub scale: [f32; 2],
pub offset: [f32; 2],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeModelRenderOptions<'a> {
pub material: NativeModelMaterial,
pub shade: NativeModelShade,
pub uv_shift: Option<NativeModelUvShift<'a>>,
/// Optional compatibility policy applied toward each face's UV bounds center before shifting.
/// Zero retains the baked UVs. For an older JSON FaceBakery use its actual atlas shrink ratio;
/// OBJ loaders that do not shrink UVs must keep zero.
pub uv_shrink_ratio: f32,
/// RGB multiplier on quads with a tint index; other quads keep their baked color.
pub tint: u32,
}
impl Default for NativeModelRenderOptions<'_> {
fn default() -> Self {
Self {
material: NativeModelMaterial::Model,
shade: NativeModelShade::Model,
uv_shift: None,
uv_shrink_ratio: 0.0,
tint: 0xff_ffff,
}
}
}
fn overlay(face: &BakedFace, policy: NativeModelMaterial) -> Option<u32> {
use NativeModelMaterial as M;
Some(match policy {
M::Solid => gfx::TERRAIN_MODEL_SOLID_OVERLAY,
M::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
M::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
M::Model => match face.quad.render_type.unwrap_or(RenderType::Solid) {
RenderType::Solid if !face.quad.material.force_translucent => {
gfx::TERRAIN_MODEL_SOLID_OVERLAY
}
RenderType::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
RenderType::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
_ => return None,
},
})
}
/// A whole draw validates before its first vertex. No temporary geometry allocation, resource
/// scans, partial fallback or change to the ordinary native model helper.
pub fn draw_model(
faces: &[BakedFace],
pose: Mat4,
light: u32,
options: NativeModelRenderOptions<'_>,
sprite: impl Fn(&str) -> Option<u32>,
mut emit: impl FnMut(Vertex),
) -> bool {
if !pose.is_finite()
|| !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
{
return false;
}
let normal_pose = Mat3::from_mat4(pose).inverse().transpose();
if !normal_pose.is_finite() {
return false;
}
let shift = match options.uv_shift {
Some(shift) => {
if shift
.scale
.iter()
.chain(&shift.offset)
.any(|v| !v.is_finite())
{
return false;
}
let (Some(source), Some(target)) =
(sprite(shift.source_sprite), sprite(shift.target_sprite))
else {
return false;
};
if source > u16::MAX.into() || target > u16::MAX.into() {
return false;
}
if !faces
.iter()
.any(|face| same_sprite(&face.quad.material.sprite, shift.source_sprite))
{
return false;
}
Some((source, target, shift))
}
None => None,
};
let vertices = |face: &BakedFace| -> Option<[Vertex; 4]> {
let quad = &face.quad;
let overlay = overlay(face, options.material)?;
let mut layer = sprite(&quad.material.sprite)?;
if layer > u16::MAX.into() || layer != face.layer {
return None;
}
let mut uv = quad.uv;
if uv.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
if options.uv_shrink_ratio != 0.0 {
let center = std::array::from_fn::<_, 2, _>(|i| {
let min = uv.iter().map(|p| p[i]).fold(f32::INFINITY, f32::min);
let max = uv.iter().map(|p| p[i]).fold(f32::NEG_INFINITY, f32::max);
(min + min + max + max) / 4.0
});
for p in &mut uv {
*p = std::array::from_fn(|i| p[i] + options.uv_shrink_ratio * (center[i] - p[i]));
}
}
if let Some((_, target, shift)) = shift
&& same_sprite(&quad.material.sprite, shift.source_sprite)
{
layer = target;
for p in &mut uv {
*p = std::array::from_fn(|i| p[i] * shift.scale[i] + shift.offset[i]);
}
}
let shaded = match options.shade {
NativeModelShade::Model => quad.shade,
NativeModelShade::Off => false,
NativeModelShade::Entity => true,
};
let normal = if shaded {
let p = quad.positions.map(Vec3::from_array);
let n = (p[1] - p[0]).cross(p[2] - p[0]).normalize_or_zero();
// Baked model normals are signed bytes before the instance's normal matrix.
// Keep the transformed normal as floats; quantizing it again changes rotated faces.
let n = n.map(|v| ((v * 127.0) as i8) as f32 / 127.0);
(normal_pose * n).normalize_or_zero()
} else {
Vec3::ZERO
};
let normal_bits = normal.z.to_ne_bytes().map(|b| b as i8);
let argb = quad.color.map_or(u32::MAX, |v| v as u32);
let mut color = (argb & 0xff00_ff00) | (argb >> 16 & 0xff) | (argb & 0xff) << 16;
if quad.tint_index >= 0 {
let tint = options.tint;
let rgb = (tint >> 16 & 0xff) | (tint & 0xff00) | (tint & 0xff) << 16;
color = (color & 0xff00_0000)
| ((color & 0xff) * (rgb & 0xff) / 255)
| (((color >> 8 & 0xff) * (rgb >> 8 & 0xff) / 255) << 8)
| (((color >> 16 & 0xff) * (rgb >> 16 & 0xff) / 255) << 16);
}
let light = (light & 0xff).max(u32::from(quad.light[1]) * 16)
| ((light >> 8 & 0xff).max(u32::from(quad.light[0].max(quad.light_emission)) * 16)
<< 8);
let positions = quad
.positions
.map(|p| pose.transform_point3(Vec3::from_array(p)));
if positions.iter().any(|p| !p.is_finite()) || uv.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
Some(std::array::from_fn(|i| Vertex {
pos: positions[i].to_array(),
uv: uv[i],
layer: overlay | layer << 16,
color,
light,
normal: normal_bits,
glint_uv: [normal.x, normal.y],
}))
};
if faces.iter().any(|face| vertices(face).is_none()) {
return false;
}
for face in faces {
for vertex in vertices(face).expect("validated immutable model") {
emit(vertex);
}
}
true
}
#[cfg(test)]
#[path = "terrain_model_tests.rs"]
mod tests;
@@ -0,0 +1,171 @@
use super::*;
use horizon_assets::{
bake::bake_flat_face,
model::{Direction, Material},
};
fn face() -> BakedFace {
BakedFace::new(
bake_flat_face(
[0.0; 3],
[16.0; 3],
[0.0, 0.0, 16.0, 16.0],
Direction::South,
Material {
sprite: "test:source".into(),
force_translucent: false,
},
-1,
)
.unwrap(),
7,
true,
false,
None,
)
}
fn sprite(name: &str) -> Option<u32> {
match name {
"test:source" => Some(7),
"test:target" => Some(11),
_ => None,
}
}
fn draw(
faces: &[BakedFace],
pose: Mat4,
options: NativeModelRenderOptions<'_>,
) -> (bool, Vec<Vertex>) {
let mut output = Vec::new();
let accepted = draw_model(faces, pose, 0xf0f0, options, sprite, |v| output.push(v));
(accepted, output)
}
#[test]
fn late_unknown_material_or_sprite_rejects_the_whole_model() {
let first = face();
let mut second = face();
second.quad.render_type = Some(RenderType::Unsupported);
let mut faces = [first, second];
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
assert!(!accepted);
assert!(output.is_empty());
let (accepted, output) = draw(
&faces,
Mat4::IDENTITY,
NativeModelRenderOptions {
material: NativeModelMaterial::Cutout,
..Default::default()
},
);
assert!(accepted);
assert_eq!(output.len(), 8);
faces[1].quad.material.sprite = "test:missing".into();
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
assert!(!accepted);
assert!(output.is_empty());
}
#[test]
fn exact_sprite_shift_and_explicit_json_shrink_precede_array_mapping() {
let options = NativeModelRenderOptions {
uv_shrink_ratio: 4.0 / 2048.0,
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:source",
target_sprite: "test:target",
scale: [0.5; 2],
offset: [0.25, 0.125],
}),
..Default::default()
};
let (accepted, output) = draw(&[face()], Mat4::IDENTITY, options);
assert!(accepted);
assert_eq!(output[0].uv, [0.25048828, 0.12548828]);
assert_eq!(output[2].uv, [0.7495117, 0.6245117]);
assert!(output.iter().all(|v| v.layer >> 16 == 11));
let (accepted, output) = draw(
&[face()],
Mat4::IDENTITY,
NativeModelRenderOptions {
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:target",
target_sprite: "test:source",
..options.uv_shift.unwrap()
}),
..options
},
);
assert!(!accepted);
assert!(output.is_empty());
}
#[test]
fn transformed_slope_normal_stays_float_and_fullbright_keeps_shading() {
let mut face = face();
face.quad.positions = [
[0.0, 0.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 0.0, 0.0],
];
let pose = Mat4::from_rotation_y(0.372) * Mat4::from_scale(Vec3::new(-2.0, 3.0, 1.0));
let (accepted, output) = draw(&[face], pose, Default::default());
assert!(accepted);
let normal = Vec3::new(
output[0].glint_uv[0],
output[0].glint_uv[1],
f32::from_ne_bytes(output[0].normal.map(|v| v as u8)),
);
assert!((normal.length() - 1.0).abs() < 1e-6);
assert!((normal.y - 0.31622776).abs() < 1e-6);
assert!((normal.x + 0.3448316).abs() < 1e-5, "{normal:?}");
assert_eq!(output[0].light, 0xf0f0);
assert_eq!(output[0].color, u32::MAX);
// Float payload, not a second signed-byte normal quantization.
assert_ne!(normal.y, (normal.y * 127.0) as i8 as f32 / 127.0);
}
#[test]
fn invalid_shift_and_singular_pose_emit_nothing() {
let options = NativeModelRenderOptions {
uv_shrink_ratio: f32::NAN,
..Default::default()
};
let (ok, output) = draw(&[face()], Mat4::IDENTITY, options);
assert!(!ok);
assert!(output.is_empty());
let (ok, output) = draw(
&[face()],
Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
Default::default(),
);
assert!(!ok);
assert!(output.is_empty());
}
#[test]
fn a_deduplicated_array_layer_is_not_the_same_named_source_sprite() {
let mut alias = face();
alias.quad.material.sprite = "test:alias".into();
let mut output = Vec::new();
assert!(draw_model(
&[face(), alias],
Mat4::IDENTITY,
0xf0f0,
NativeModelRenderOptions {
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:source",
target_sprite: "test:target",
scale: [0.5; 2],
offset: [0.25; 2]
}),
..Default::default()
},
|name| if name == "test:alias" {
Some(7)
} else {
sprite(name)
},
|v| output.push(v)
));
assert_eq!(output[0].layer >> 16, 11);
assert_eq!(output[4].layer >> 16, 7);
assert_eq!(output[4].uv, [0.0, 0.0]);
assert_eq!(output[6].uv, [1.0, 1.0]);
}
+29 -1
View File
@@ -184,6 +184,14 @@ impl Weather {
self.rain_level.1 =
(self.rain_level.1 + if self.raining { 0.01 } else { -0.01 }).clamp(0.0, 1.0);
}
/// The levels at their targets at once (rain 1 while it rains, else 0, and thunder the
/// same), both halves, so the frame shows them at any partial tick: a frozen world under
/// `HORIZON_STILL`, which would otherwise have to run about a hundred ticks to fade them.
pub fn settle_levels(&mut self) {
let target = |on: bool| if on { 1.0 } else { 0.0 };
self.rain_level = (target(self.raining), target(self.raining));
self.thunder_level = (target(self.thundering), target(self.thundering));
}
/// Level.getRainLevel / getThunderLevel (thunder scales with rain).
pub fn levels(&self, partial: f32) -> (f32, f32) {
let rain = self.rain_level.0 + (self.rain_level.1 - self.rain_level.0) * partial;
@@ -562,11 +570,31 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::{DirtySections, World};
use super::{DirtySections, Weather, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// `/weather thunder` then `/weather clear` in a frozen world: the levels sit at their
/// targets at every partial tick, where a tick of fading would have moved them 0.01.
#[test]
fn settled_weather_shows_its_targets_at_any_partial_tick() {
let mut weather = Weather {
raining: true,
thundering: true,
rain_level: (0.25, 0.26),
..Weather::default()
};
weather.settle_levels();
for partial in [0.0, 0.37, 1.0] {
assert_eq!(weather.levels(partial), (1.0, 1.0), "{partial}");
}
weather.raining = false;
weather.thundering = false;
weather.settle_levels();
assert_eq!(weather.levels(0.5), (0.0, 0.0));
}
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
+6 -1
View File
@@ -4,7 +4,7 @@ default-run = "horizon-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "Horizon native development client"
autobins = false
autoexamples = false
@@ -69,6 +69,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -146,6 +147,10 @@ path = "tests/mod_api_hooks.rs"
name = "native_gui_overlay_probe"
path = "examples/native_gui_overlay_probe.rs"
[[example]]
name = "wind_burst_hook_probe"
path = "examples/wind_burst_hook_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS.
@@ -30,6 +30,30 @@
"horizon.studio.record.error": "Studio-Aufzeichnungsfehler: %s",
"horizon.studio.record.status.active": "Studio-Aufzeichnung aktiv — %s s, %s Pakete, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio-Aufzeichnung inaktiv",
"horizon.studio.replay.usage": "Verwendung: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Verwendung: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio-Wiedergabe geöffnet: %s (%s s, %s Pakete)",
"horizon.studio.replay.open_error": "Wiedergabe konnte nicht geöffnet werden: %s",
"horizon.studio.replay.paused": "Studio-Wiedergabe pausiert",
"horizon.studio.replay.resumed": "Studio-Wiedergabe fortgesetzt",
"horizon.studio.replay.inactive": "Keine aktive Studio-Wiedergabe",
"horizon.studio.replay.speed.usage": "Verwendung: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio-Wiedergabegeschwindigkeit auf %s× gesetzt",
"horizon.studio.replay.speed.invalid": "Ungültige Wiedergabegeschwindigkeit",
"horizon.studio.replay.seek.usage": "Verwendung: /studio replay seek <MILLISEKUNDEN>",
"horizon.studio.replay.seeked": "Studio-Wiedergabe auf %s ms (%s s) gesetzt",
"horizon.studio.replay.seek.error": "Wiedergabe konnte nicht verschoben werden: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Zurückspulen ist nur bei einer eigenständigen Wiedergabe verfügbar.",
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
"horizon.studio.open_folder": "Aufnahmen öffnen",
"horizon.studio.open_replay": "Wiedergabe starten",
"horizon.studio.refresh": "Aktualisieren",
"horizon.studio.unknown_server": "Unbekannt",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
@@ -34,6 +34,23 @@
"horizon.studio.record.error": "Studio recording error: %s",
"horizon.studio.record.status.active": "Studio recording active — %s s, %s packets, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio recording inactive",
"horizon.studio.replay.usage": "Usage: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Usage: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio replay opened: %s (%s s, %s packets)",
"horizon.studio.replay.open_error": "Could not open replay: %s",
"horizon.studio.replay.paused": "Studio replay paused",
"horizon.studio.replay.resumed": "Studio replay resumed",
"horizon.studio.replay.inactive": "No active Studio replay",
"horizon.studio.replay.speed.usage": "Usage: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio replay speed set to %s×",
"horizon.studio.replay.speed.invalid": "Invalid replay speed",
"horizon.studio.replay.seek.usage": "Usage: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Studio replay seeked to %s ms (%s s)",
"horizon.studio.replay.seek.error": "Could not seek replay: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Rewind is only available for a standalone replay.",
"horizon.studio.replay.seek.invalid": "Invalid replay position",
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
"horizon.studio.replay.closed": "Studio replay closed",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -88,6 +105,7 @@
"horizon.studio.empty": "No recordings found in the recordings folder.",
"horizon.studio.empty_hint": "Use /studio record start <name> in a world, or drop .mcpr files there.",
"horizon.studio.open_folder": "Open Recordings",
"horizon.studio.open_replay": "Play Replay",
"horizon.studio.refresh": "Refresh",
"horizon.studio.unknown_server": "Unknown",
"horizon.pages.open.music": "Music...",
@@ -892,5 +910,9 @@
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"horizon.pad.notice.connected.press": "Press to use it"
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
}
@@ -2,7 +2,10 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[allow(
dead_code,
reason = "this fixture shares another one's source and uses part of it"
)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,6 +1,5 @@
//! Shared by the audio fixtures: a backend recording what an OpenAL source
//! would hold, and the per-step dump the Java oracles print.
#![allow(dead_code)]
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
use horizon_client::sound_events::Sound;
use std::collections::HashMap;
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
&m,
&c,
case["initial"].as_str().unwrap_or(""),
false,
history.len(),
w,
h,
@@ -8,7 +8,8 @@ use horizon_client::{
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES,
species::can_be_a_baby, sulfur_cube,
},
random_sequences::RandomSequences,
};
@@ -111,7 +112,7 @@ pub fn main() -> horizon_client::Result<()> {
for slot in drops::SLOTS {
drops::clear_slot(&mut m, slot);
}
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
if get("baby", "0") == "1" && can_be_a_baby(m.species()) {
m.extra.age = -24000;
}
if get("fire", "0") == "1" {
@@ -1200,6 +1200,7 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
@@ -0,0 +1,36 @@
//! Headless native wind-burst hook regression. Uses the dispenser fixture's
//! synthetic server Game; never starts a server or opens a world.
use horizon_client::mod_api::{Hooks, Host, WindBurstEvent};
use std::sync::Arc;
struct Protect;
impl Hooks for Protect {
fn wind_burst(&self, _host: &mut dyn Host, event: &mut WindBurstEvent<'_>) {
assert_eq!(event.dimension, "minecraft:overworld");
assert_eq!(event.center, [0.5, 100.5, 0.5]);
// The protected lever and candle are present in the real ray result.
assert!(event.blocks.contains(&[0, 100, 0]));
assert!(event.blocks.contains(&[1, 100, 0]));
if std::env::var_os("HORIZON_WIND_PROTECT").is_some() {
event
.blocks
.retain(|p| *p != [0, 100, 0] && *p != [1, 100, 0]);
}
}
}
fn main() {
let mods: Arc<[Box<dyn Hooks>]> = if std::env::var_os("HORIZON_WIND_NO_MOD").is_some() {
Arc::from([])
} else {
Arc::from([Box::new(Protect) as Box<dyn Hooks>])
};
// Debug Game construction exceeds the Windows main thread's default stack.
// This worker only loads the isolated component fixture; it has no window.
std::thread::Builder::new()
.name("wind-burst-fixture".into())
.stack_size(64 * 1024 * 1024)
.spawn(move || horizon_client::entry::main_with(mods))
.expect("start wind fixture")
.join()
.expect("wind fixture panicked");
}
@@ -587,6 +587,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
&[p("frames", Ty::Str, "frames to draw, 1 to 10000, or cancel"), opt("step_ms", Ty::Num, "milliseconds between frames on the pack's clock, more than 0 (16.666667 for 60 fps)"), opt("path", Ty::Rest, "the PNG to write")], mcp: false, local: false),
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
@@ -361,6 +361,11 @@ fn absolute_paths(words: &mut [String]) {
*path = cwd.join(&*path).display().to_string();
}
}
Some("screenshot-sequence") => {
if let Some(path) = words.get_mut(3) {
*path = cwd.join(&*path).display().to_string();
}
}
// `repro-export [NAME] [PATH]`: the second positional is the zip.
Some("repro-export") => {
if let Some(path) = words.get_mut(2)
@@ -831,5 +836,9 @@ mod tests {
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
absolute_paths(&mut words);
assert!(Path::new(&words[1]).is_absolute());
let mut words = ["screenshot-sequence", "64", "50", "a.png"].map(str::to_owned);
absolute_paths(&mut words);
assert!(Path::new(&words[3]).is_absolute());
assert_eq!(words[1..3], ["64", "50"]);
}
}
+20 -8
View File
@@ -42,6 +42,8 @@ pub struct Interaction {
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
/// `lowerFillLevel` posts BLOCK_CHANGE with the new state.
pub lower_fill_level: bool,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -60,6 +62,7 @@ fn pass() -> Interaction {
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
}
@@ -126,9 +129,20 @@ fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -
block: Some(block),
sound,
stat: Some(stat),
lower_fill_level: false,
}
}
fn changed_lowered(
cauldron: &BlockState,
sound: Option<&'static str>,
stat: &'static str,
) -> Interaction {
let mut result = changed(lowered(cauldron), sound, stat);
result.lower_fill_level = true;
result
}
const COLORS: [&str; 16] = [
"white",
"orange",
@@ -183,7 +197,7 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed_lowered(cauldron, None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
@@ -208,6 +222,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -231,6 +246,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -266,11 +282,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed_lowered(cauldron, Some("minecraft:item.bottle.fill"), "use_cauldron")
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -298,7 +310,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed_lowered(cauldron, None, "clean_banner")
}
("water", name)
if name
@@ -307,7 +319,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed_lowered(cauldron, None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+12 -11
View File
@@ -13,17 +13,18 @@ pub use horizon_client_render::{
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx,
graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash,
fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+32 -2
View File
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
world,
facts,
collision,
player,
server,
next_float,
border,
fluids,
on_sound,
&[],
)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
// Entity.collide: the world border's walls stop the orb too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
+135 -20
View File
@@ -105,6 +105,16 @@ impl Base<'_> {
}
tag
}
/// Preserve Entity data without flags owned by Mob's save writer.
/// Only the dedicated boat, item and armor-stand writers use this path.
fn non_mob_tag(&self, raw: Option<&Tag>) -> Tag {
let mut tag = self.tag(raw);
if let Tag::Compound(map) = &mut tag {
map.remove(&"NoAI".into());
map.remove(&"PersistenceRequired".into());
}
tag
}
}
/// `Entity.saveWithoutId`'s common fields for entities Horizon keeps as raw
@@ -128,8 +138,8 @@ pub fn fill_common_defaults(tag: &mut Tag) {
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
use crate::mobs::{Species, finalize::ageable, species::can_be_a_baby};
s == Species::Tadpole || (ageable(s) && can_be_a_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
@@ -633,15 +643,11 @@ pub fn mob_to_nbt(
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
} else if matches!(
species,
crate::mobs::Species::Parrot
| crate::mobs::Species::Slime
| crate::mobs::Species::MagmaCube
| crate::mobs::Species::ZombieNautilus
) {
} else if crate::mobs::finalize::ageable(species) {
// AgeableMob.addAdditionalSaveData writes nothing when canBeABaby is
// false: drop the ignored values a raw input compound carried.
// false — the frog, the parrot, the zombie nautilus, the camel husk,
// the slime and the magma cube: drop the ignored values a raw input
// compound carried rather than writing them back out.
for key in ["Age", "ForcedAge", "AgeLocked"] {
map.remove(&key.into());
}
@@ -1116,9 +1122,17 @@ pub fn mob_to_nbt(
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
}
}
// Armadillo.addAdditionalSaveData: the time left to the next scute.
// Armadillo.addAdditionalSaveData: the time left to the next scute, and
// the state it is in, which is always written (`idle` when it is not
// rolled up).
"minecraft:armadillo" => {
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
map.insert(
"state".into(),
string(crate::mobs::armadillo::state_name(
crate::mobs::armadillo::state(m),
)),
);
}
"minecraft:ravager" => {
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
@@ -2211,11 +2225,18 @@ pub fn mob_from_nbt(
st.vibration = crate::sculk::Data::from_nbt(listener);
}
}
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
if m.species() == crate::mobs::Species::Armadillo
&& let Some(t) = number(tag.get("scute_time"))
{
m.extra.egg_time = t as i32;
// Armadillo.readAdditionalSaveData: the saved state through `switchToState`
// (`onSyncedDataUpdated` then restarts the time in that state), idle for a
// save that names none; then the time to the next scute, the constructor's
// when absent.
if m.species() == crate::mobs::Species::Armadillo {
crate::mobs::armadillo::set_state(
&mut m,
crate::mobs::armadillo::state_by_name(text(tag.get("state")).as_deref()),
);
if let Some(t) = number(tag.get("scute_time")) {
m.extra.egg_time = t as i32;
}
}
if m.species() == crate::mobs::Species::Ravager {
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
@@ -2356,7 +2377,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: e.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
for (key, value) in [
("Item", item_to_nbt(&e.stack)),
("Health", Tag::Short(e.health as i16)),
@@ -3428,7 +3449,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: a.on_ground,
fall_distance: a.enchantment_fall_distance,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
.iter()
.zip(&a.equipment)
@@ -3582,7 +3603,7 @@ pub fn boat_to_nbt(
on_ground: false,
fall_distance: 0.0,
}
.tag(raw);
.non_mob_tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
}
@@ -4038,11 +4059,16 @@ mod tests {
}
#[test]
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
fn an_ageable_mob_that_cannot_be_a_baby_saves_no_age() {
use crate::mobs::Species;
// AgeableMob.addAdditionalSaveData writes Age only when canBeABaby is
// true, so every ageable class that overrides it to false writes none
// of it: a frog and a camel husk keep no raw `Age` either.
for (species, id) in [
(Species::Slime, "minecraft:slime"),
(Species::MagmaCube, "minecraft:magma_cube"),
(Species::Frog, "minecraft:frog"),
(Species::CamelHusk, "minecraft:camel_husk"),
(Species::Pig, "minecraft:pig"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
@@ -4934,6 +4960,95 @@ mod tests {
}
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = crate::mobs::Species::Pig;
let types = [("minecraft:pig".into(), kind)].into();
for input in [None, Some(0), Some(1)] {
for id in [
"minecraft:oak_boat",
"minecraft:oak_chest_boat",
"minecraft:armor_stand",
"minecraft:item",
"minecraft:pig",
] {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 200.0, 4.5])),
("Motion", doubles(&[0.0, 0.0, 0.0])),
("Silent", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Invulnerable", Tag::Byte(1)),
("PortalCooldown", Tag::Int(17)),
("Tags", Tag::List(vec![string("audit")])),
("Unrelated", string("keep")),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:stone", 1)),
),
]);
if let Some(value) = input {
put(&mut raw, "NoAI", Tag::Byte(value));
put(&mut raw, "PersistenceRequired", Tag::Byte(value));
}
let stand = armor_stand_from_nbt(&raw, 1).unwrap();
let item = item_entity_from_nbt(&raw, 1, 0.0).unwrap();
let boat = boat_from_nbt(&raw).unwrap();
for source in [None, Some(&raw)] {
let saved = match id {
"minecraft:armor_stand" => armor_stand_to_nbt(&stand, uuid, source),
"minecraft:item" => item_entity_to_nbt(&item, uuid, source),
"minecraft:pig" => {
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
// The writer must use live state, even opposite stale raw values.
mob.no_ai = input != Some(1);
mob.persistent = input != Some(1);
mob_to_nbt(&mob, uuid, source, None, |_| None)
}
_ => boat_to_nbt(
id,
boat.0,
boat.1,
[boat.2, 0.0],
uuid,
source,
(id == "minecraft:oak_chest_boat").then_some(&[]),
),
};
if id == "minecraft:pig" {
let live = input != Some(1);
assert_eq!(saved.get("NoAI"), live.then_some(&Tag::Byte(1)));
assert_eq!(
saved.get("PersistenceRequired"),
Some(&Tag::Byte(i8::from(live)))
);
} else {
assert!(saved.get("NoAI").is_none(), "{id} {input:?}");
assert!(saved.get("PersistenceRequired").is_none(), "{id} {input:?}");
}
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(saved.get("Pos"), raw.get("Pos"));
if source.is_some() {
for key in [
"Silent",
"NoGravity",
"Invulnerable",
"PortalCooldown",
"Tags",
"Unrelated",
] {
assert_eq!(saved.get(key), raw.get(key), "{id} {key}");
}
}
}
}
}
}
#[test]
fn animal_integer_loads_save_modeled_values_without_raw_source() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+63 -3
View File
@@ -78,9 +78,6 @@ impl FurnaceCooking for Furnace {
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
@@ -250,6 +247,69 @@ mod tests {
(items, recipes, providers)
}
#[test]
fn loaded_totals_survive_until_actual_completion() {
let (items, recipes, providers) = data("loaded-total");
assert_eq!(
recipes
.cook(
FurnaceKind::Furnace.recipe_type(),
&ItemStack::new("test:ore", 3)
)
.unwrap()
.time,
200
);
// Fresh Java's five-case Game fixture independently admits the loaded
// zero (including a missing saved field), five and normal totals.
for (total, timer, completion) in [(0, 0, 1), (5, 0, 5), (200, 0, 200), (5, 1, 4)] {
let mut f = Furnace {
kind: FurnaceKind::Furnace,
items: [Some(ItemStack::new("test:ore", 3)), None, None],
lit_remaining: 20,
lit_total: 20,
cooking_timer: timer,
cooking_total: total,
speed: 1.0,
recipes_used: HashMap::new(),
};
for elapsed in 1..=6 {
let mut drops = Vec::new();
assert!(!f.idle());
assert_eq!(f.tick(&recipes, &items, &providers, &mut drops), None);
assert!(drops.is_empty());
assert_eq!((f.lit_remaining, f.lit_total), (20 - elapsed, 20));
assert!(f.items[1].is_none());
let complete = elapsed >= completion;
assert_eq!(
f.items[0].as_ref().unwrap().count,
if complete { 2 } else { 3 },
"loaded {total}/{timer}, tick {elapsed}"
);
assert_eq!(
f.items[2].as_ref().map(|s| (&*s.item, s.count)),
if complete {
Some(("test:ingot", 1))
} else {
None
}
);
assert_eq!(f.cooking_total, if complete { 200 } else { total });
assert_eq!(
f.cooking_timer,
if complete {
elapsed - completion
} else {
timer + elapsed
}
);
}
// A changed input still goes through setItem's independent reset.
f.set_input(&recipes, Some(ItemStack::new("test:ingot", 1)));
assert_eq!((f.cooking_timer, f.cooking_total), (0, 200));
}
}
#[test]
fn cooking_requires_output_components_and_capacity() {
let (items, recipes, providers) = data("output-components");
+225 -57
View File
@@ -104,6 +104,7 @@ mod creative_hotbar;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod custom_sky;
mod debug_keys;
mod debug_lines;
mod debug_options;
@@ -243,6 +244,7 @@ mod native_entity_motion;
mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_fluid_render;
mod native_hooks;
mod native_host_bridge;
mod native_inventories;
@@ -277,6 +279,8 @@ mod native_scene;
mod native_server;
mod native_shaders;
mod native_structures;
mod native_terrain_model;
pub mod native_text_field;
mod native_ticks;
mod native_travel;
mod native_view_distance;
@@ -342,6 +346,7 @@ mod saving;
mod scoreboard;
mod screen_info;
mod screens;
mod screenshot_sequence;
mod sculk;
mod sculk_fixture;
mod server_links;
@@ -1615,6 +1620,9 @@ impl LevelAccess for GameLevel<'_> {
/// the count a recount of the players with that container open finds, and
/// the count it replaces. Only the block entities that keep a counter and
/// a scheduled recheck answer; anything else is `None`.
fn container_open(&self, pos: BlockPos) -> bool {
self.open_containers.contains(&(pos.x, pos.y, pos.z))
}
fn recheck_container_openers(&mut self, pos: BlockPos) -> Option<(i32, i32)> {
use horizon_world::level::classes::Class;
let data = self.behaviours.data(self.world.block(pos.x, pos.y, pos.z));
@@ -2281,6 +2289,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
}
impl PackResources {
/// Everything a pack overlay at `root` provides, over `assets` and `render`
@@ -2356,6 +2366,7 @@ impl PackResources {
names: Arc::new(Names::load(root)),
clouds: Arc::new(load_clouds(root)),
forcefield: Arc::new(load_forcefield(root)),
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
}
}
}
@@ -2948,6 +2959,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// OptiFine custom sky layers' eased brightness (`custom_sky.rs`).
custom_sky: custom_sky::CustomSkyView,
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
rain_sound_time: i32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
@@ -3010,6 +3023,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
location_armor: [Option<ItemStack>; 4],
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
@@ -3157,6 +3172,8 @@ struct Game<'a> {
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
screenshot_sequence: Option<screenshot_sequence::Sequence>,
/// `render-capture DIR`: replied once the renderer wrote the scene.
pending_render_capture: Option<std::sync::mpsc::Sender<String>>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
@@ -3230,6 +3247,14 @@ struct Game<'a> {
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -5675,9 +5700,10 @@ impl Game<'_> {
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
// LevelEventHandler 2011 and 2012: a bee growing a crop, a
// turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
id: 2011 | 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
@@ -6234,11 +6260,12 @@ impl Game<'_> {
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
/// its place in the list; the caller has checked it is ticking.
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
let mut f = self.falling.remove(i);
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
match self.fall_step(&mut f, entity_drops, entities) {
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
@@ -6251,7 +6278,12 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(
&mut self,
f: &mut FallingBlock,
entity_drops: bool,
entities: &[Aabb],
) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
@@ -6285,10 +6317,11 @@ impl Game<'_> {
false,
);
// On an error the caller keeps the block as it was before this tick.
// Entity.collide: the world border's walls stop it too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
let movement = collisions::EntityCollisions {
blocks: scene,
entities: &[],
entities,
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
}
.resolve_with_context(
@@ -7536,6 +7569,7 @@ impl Game<'_> {
);
instance.ambient = eff.ambient;
instance.visible = eff.show_particles;
instance.show_icon = eff.show_icon;
self.add_player_effect(instance);
}
}
@@ -8181,6 +8215,11 @@ impl Game<'_> {
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
} else if self.clock.still() {
// Under `HORIZON_STILL` a frozen world's rain and thunder sit at their levels, as a
// shader pack's smoothed values do, so `/weather rain` shows full rain without
// stepping the world (and its mobs) through the fade.
self.world.weather.settle_levels();
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
@@ -9177,7 +9216,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let show_hud = !self.ui.hide_gui;
let in_studio = self.studio_replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -9415,14 +9455,13 @@ impl Game<'_> {
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
}
// The chat screen draws the focused chat itself; F1 hides the rest.
if show_hud && self.screen != Screen::Chat {
if self.screen == Screen::Chat {
self.draw_chat(&mut batch);
} else if show_hud {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
g.draw_tab_list(batch);
});
} else {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
@@ -10548,18 +10587,27 @@ impl Game<'_> {
if let Some((s, mob)) = session.as_mut() {
self.merchant_sync_in(s, *mob);
}
// The cartography table reads and makes the level's maps; a
// Crafting and cartography read and derive the level's maps; a
// server's (the client menu's ContainerLevelAccess.NULL) doesn't.
let level_maps = (kind == Kind::Cartography && self.remote.is_none()).then(|| {
let held: Vec<ItemStack> = before
let level_maps = (matches!(kind, Kind::Cartography | Kind::Crafting | Kind::Inventory)
&& self.remote.is_none())
.then(|| {
before
.iter()
.flatten()
.chain(self.inventory.iter().flatten())
.chain(self.carried.iter())
.cloned()
.collect();
self.level_maps(&held)
});
.collect::<Vec<_>>()
})
.filter(|held| {
kind == Kind::Cartography
|| held
.iter()
.filter_map(maps::map_id)
.any(|id| self.map(id).is_some())
})
.map(|held| self.level_maps(&held));
let next_map = if merchant_open { self.peek_map_id() } else { 0 };
let tag = |t: &str| self.structure_tag(t);
let maps = trading::GameMaps {
@@ -10669,6 +10717,9 @@ impl Game<'_> {
let predicting = self.remote.is_some();
if !predicting {
self.register_exploration_maps(made);
for (item, count) in std::mem::take(&mut menu.crafted_counts) {
self.award_stat("crafted", &item, count);
}
}
if let Some(s) = menu.merchant.take() {
self.merchant_sync_out(s);
@@ -11520,6 +11571,7 @@ impl Game<'_> {
camera: glam::DVec3,
alpha: f64,
fov_degrees: f32,
billboard_frustum: Option<horizon_client::gfx::Frustum>,
mut out: Vec<Vertex>,
) -> (
Vec<Vertex>,
@@ -11550,13 +11602,11 @@ impl Game<'_> {
} else {
self.yaw
};
// Packs may move vertices or draw from another camera; TAA jitters the
// projection beyond this unjittered frustum. Keep their original geometry path.
let fire_view = self.view_frustum.as_ref().filter(|_| {
self.pack_ids.is_none()
&& self.native_shaders.picked.is_empty()
&& !self.settings.horizon.taa
});
// Packs may move vertices or draw from another camera. The guarded
// billboard frustum covers TAA's jitter without changing model culling.
let fire_view = billboard_frustum
.as_ref()
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
let fire_visible = |position, width, height| {
fire_view.is_none_or(|view| {
horizon_client::fire_render::in_view(view, camera, position, width, height)
@@ -11691,7 +11741,12 @@ impl Game<'_> {
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
// the first-person model.
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -13494,6 +13549,13 @@ impl Game<'_> {
Ok(())
}
fn close_world(&mut self) -> Result<()> {
// Pause-menu and death-screen exits use the normal world-close action.
// A standalone replay owns a synthetic remote session, so it must go
// through replay cleanup instead of being treated like a server.
if self.studio_replay.is_some() {
self.close_studio_replay();
return Ok(());
}
// A recording cannot cross into another world, and a replay feeding the
// title screen is nonsense.
self.repro_world_closed();
@@ -13918,6 +13980,9 @@ impl Game<'_> {
&& let Ok(new) = registry.resolve(&block)
{
self.level_set(pos, new)?;
if result.lower_fill_level {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
if let Some(sound) = result.sound {
self.audio.play(
@@ -14474,12 +14539,19 @@ impl Game<'_> {
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
/// `tryMergeToExisting` compares from the level random, then moves with
/// its own random.
fn spawn_orbs(&mut self, at: [f64; 3], mut value: u32) {
fn spawn_orbs(&mut self, at: [f64; 3], value: u32) {
self.spawn_orbs_toward(at, [0.0; 3], value);
}
/// `ExperienceOrb.awardWithDirection`: `award` whose fresh orbs start
/// `roughly` that way (`new ExperienceOrb(level, pos, roughly, value)`).
fn spawn_orbs_toward(&mut self, at: [f64; 3], roughly: [f64; 3], mut value: u32) {
while value > 0 {
let v = ExperienceOrb::split(value);
value -= v;
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.award_orb(at, v, merge_id);
if !self.merge_new_orb(at, v, merge_id) {
self.add_orb_toward(at, roughly, v);
}
}
}
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
@@ -14487,6 +14559,12 @@ impl Game<'_> {
/// the new one as one more in its count (and restarts its age); otherwise
/// a fresh orb spawns.
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
if !self.merge_new_orb(at, value, merge_id) {
self.add_orb(at, value);
}
}
/// `tryMergeToExisting`: true when an orb took the new one.
fn merge_new_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) -> bool {
let centre = Vec3::new(at[0], at[1], at[2]);
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
@@ -14508,8 +14586,9 @@ impl Game<'_> {
if let Some(i) = target {
self.orbs[i].count += 1;
self.orbs[i].age = 0;
true
} else {
self.add_orb(at, value);
false
}
}
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
@@ -14583,10 +14662,33 @@ impl Game<'_> {
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
/// the orb's own random.
fn add_orb(&mut self, at: [f64; 3], value: u32) {
let at = self.unstuck_orb(at);
self.add_orb_toward(at, [0.0; 3], value);
}
/// `new ExperienceOrb(level, pos, roughly, value)`: a random motion that
/// points against `roughly` is turned round, and the orb starts half its
/// size (0.25) along `roughly`, unstuck if it collides there.
fn add_orb_toward(&mut self, at: [f64; 3], roughly: [f64; 3], value: u32) {
let len_sqr = roughly.iter().map(|v| v * v).sum::<f64>();
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let len = len_sqr.sqrt();
let unit = if len < 1.0e-5 {
[0.0; 3]
} else {
roughly.map(|v| v / len)
};
let at = self.unstuck_orb([0, 1, 2].map(|a| at[a] + unit[a] * 0.25));
let mut r = dispenser::entity_random();
let _yaw = r.next_float();
let p = Vec3::new(at[0], at[1], at[2]);
let mut velocity = Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
);
let dot = roughly[0] * velocity.x + roughly[1] * velocity.y + roughly[2] * velocity.z;
if len_sqr > 0.0 && dot < 0.0 {
velocity = Vec3::new(-velocity.x, -velocity.y, -velocity.z);
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
@@ -14595,11 +14697,7 @@ impl Game<'_> {
id: self.next_entity,
position: p,
previous: p,
velocity: Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
),
velocity,
value,
age: 0,
on_ground: false,
@@ -14929,6 +15027,7 @@ impl Game<'_> {
|| self.ui.hide_gui
|| self.sleeping.is_some()
|| !self.camera_type.first_person()
|| self.studio_replay.is_some()
|| self
.player
.as_ref()
@@ -15271,7 +15370,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -15312,7 +15411,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -16446,6 +16545,17 @@ impl Game<'_> {
// (`take_parent_vehicle`); here it stays in the chunk saves.
let mut host = host;
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
// Read-only fixture input, correlated with the real save receipt.
// Keep this identical on the untouched/fixed selector builds.
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
eprintln!(
"advancement selector model raw: {}",
serde_json::json!({"conn":g.conn,"name":g.name,
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
"game_time":self.world.game_time,
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
);
}
self.guest_attachments_into(g);
self.player_autosaves.enqueue(g, &self.world_root);
}
@@ -16477,6 +16587,10 @@ impl Game<'_> {
/// saved icon is on the screen, grab the rendered frame once a second until
/// the save has one. The Select World list shows it as the world's preview.
fn tick_world_icon(&mut self) {
// Its grab would take a sequence's frame.
if self.screenshot_sequence.is_some() {
return;
}
let Some(icon) = self.world_icon.as_ref() else {
return;
};
@@ -16524,6 +16638,12 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
if self.screenshot_sequence.is_some() {
return self.render_sequence_frame();
}
self.render_frame()
}
fn render_frame(&mut self) -> Result<bool> {
let prepare = spans::guard("prepare");
let mut pr = spans::laps();
// The window's GPU holds the atlas: its copy in memory is kept
@@ -16956,6 +17076,13 @@ impl Game<'_> {
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
let billboard_frustum = if self.settings.horizon.taa {
self.renderer.as_ref().map(|r| {
r.camera_frustum_with_jitter(fov_degrees, yaw, pitch, bob * spin, depth_far)
})
} else {
self.view_frustum
};
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
@@ -16969,7 +17096,7 @@ impl Game<'_> {
if !title {
let (built, static_sections, visible_static_sections, static_stats) =
spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, fov_degrees, entities)
self.entity_geometry(camera, alpha, fov_degrees, billboard_frustum, entities)
});
entities = built;
static_block_entities = static_sections;
@@ -17011,6 +17138,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
self.custom_sky_frame([camera.x, camera.y, camera.z], &sky, (rain, thunder), title);
pr.lap("pr_celestial");
let (mut hand, hand_items) = if title || self.scoping() {
Default::default()
@@ -17056,12 +17184,16 @@ impl Game<'_> {
self.gather_dynamic_lights(camera, alpha);
}
pr.lap("pr_lights");
let pack_inputs = if title {
let mut pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32, &sky)
};
pr.lap("pr_shader_inputs");
pack_inputs.frame_timing = self
.screenshot_sequence
.as_ref()
.map(screenshot_sequence::Sequence::timing);
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
@@ -17123,6 +17255,7 @@ impl Game<'_> {
.sprites
.get("environment/end_sky")
.map(|r| [r.u0, r.v0, r.u1, r.v1]),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
cave_culling,
water_fog,
@@ -17446,6 +17579,10 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.studio_replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
@@ -17575,7 +17712,9 @@ impl Game<'_> {
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
if self.screen == Screen::Creative && self.creative_screen.tab().search => {}
KeyCode::KeyE => match self.screen {
@@ -18041,6 +18180,9 @@ impl Game<'_> {
if let Err(e) = horizon_client::spans::time("poll_remote", || self.poll_remote()) {
log::warn!("Horizon: {e}");
}
if let Err(e) = self.advance_studio_replay() {
log::warn!("Horizon Studio replay: {e}");
}
// The renderer laid translucent quads out again (a shader pack's flat terrain came
// or went) and dropped every section and far tile: meshed again, as for a new one.
if self
@@ -18329,13 +18471,7 @@ impl ApplicationHandler for Game<'_> {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
@@ -18747,6 +18883,19 @@ impl Game<'_> {
}
}
}
// Title boxes keep their text on the key path. A field hook takes
// it through the same per-character editor as IME and control input.
if !canceled
&& control::title_owns_text(&self.screen, self.title.is_some())
&& self.types_text()
&& let Some(text) = key_event.text.as_deref()
&& let Some(result) = self.type_text_field_input(text)
{
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
key_event.text = None;
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
@@ -19940,6 +20089,7 @@ impl Game<'_> {
}
/// The loop ended: close the pads' output, save the world, write the perf report.
pub(super) fn shut_down(&mut self) {
self.end_screenshot_sequence(Err("cancelled: the game is closing".into()));
self.pad_close_output();
if let Err(e) = self.save_for_exit() {
log::warn!("Horizon: failed to save the world on exit: {e}");
@@ -20359,6 +20509,7 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
custom_sky: Default::default(),
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
@@ -20394,6 +20545,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Default::default(),
location_armor: Default::default(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
@@ -20483,6 +20635,7 @@ impl<'a> Game<'a> {
frame_stats: Default::default(),
perf: Default::default(),
pending_screenshot: None,
screenshot_sequence: None,
pending_render_capture: None,
pending_bench: None,
flight: None,
@@ -20522,6 +20675,11 @@ impl<'a> Game<'a> {
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
studio_replay: None,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -20776,6 +20934,7 @@ enum PendingWorldEntry {
/// A transfer's carried-over server cookies (`TransferState`).
cookies: Option<horizon_client::net::Cookies>,
},
StudioReplay(std::path::PathBuf),
}
struct WorldRestLoader {
@@ -20859,6 +21018,12 @@ impl Game<'_> {
}) => {
self.connect_server_with_options(&address, &name, cookies, lan)?;
}
Some(PendingWorldEntry::StudioReplay(path)) => {
let path_str = path.to_string_lossy().to_string();
if let Err(e) = self.open_studio_replay(&path_str) {
log::warn!("Horizon Studio: could not open {path_str}: {e}");
}
}
None => {}
}
Ok(())
@@ -22021,7 +22186,7 @@ fn build_renderer(
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
shader_pack: shader_packs::PickedPack,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
@@ -22060,7 +22225,9 @@ fn build_renderer(
api,
translucent_batching,
wide_frame,
shader_pack,
shader_pack.draws_flat,
shader_pack.linear,
shader_pack.hdr_float,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -22073,6 +22240,7 @@ fn build_renderer(
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
.hud
@@ -22132,6 +22300,8 @@ fn loading_renderer(
// An 8-bit frame, which HDR output takes as well.
false,
false,
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -22185,6 +22355,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -22552,13 +22723,7 @@ impl Loading {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
@@ -22749,7 +22914,7 @@ impl Loading {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -24345,3 +24510,6 @@ fn write_level_for_role(
level.write_without_player(dir)
}
}
#[cfg(test)]
mod vibration_tests;
+3 -1
View File
@@ -1647,7 +1647,9 @@ impl Game<'_> {
}
if text == "\\b" {
title.backspace();
} else {
} else if let Some(result) = self.type_text_field_input(text) {
result?;
} else if let Some(title) = self.title.as_mut() {
title.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff. Show more