mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
client_lib.rs: main's list plus bluemap and bluemap_detect. Main's every_graphics_text_is_in_the_language_table now covers Find BlueMap and BlueMap Radius: en_us and fr_fr entries for their captions and help. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
5a9e71d756
761 files changed
+76771
-6707
No files matched your search
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
|
||||
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
|
||||
@@ -15,6 +15,8 @@ name: launcher
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -26,6 +28,7 @@ on:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mcp/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
@@ -174,7 +177,7 @@ jobs:
|
||||
run: |
|
||||
if ! xcrun metal --version > /dev/null 2>&1; then
|
||||
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
@@ -240,6 +243,18 @@ jobs:
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
|
||||
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
setup="$RUNNER_TEMP/setup-smoke"
|
||||
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
|
||||
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
|
||||
installed="$setup/Horizon Launcher/horizon-launcher.exe"
|
||||
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
|
||||
"$installed" uninstall --quiet
|
||||
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
|
||||
rm -rf "$setup"
|
||||
fi
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
@@ -275,6 +290,12 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- name: Setup exe (Windows)
|
||||
# The launcher's binary under another name: started with no arguments it shows the per-user
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
|
||||
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
|
||||
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
|
||||
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
|
||||
([docs/native-mods.md](docs/native-mods.md)).
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
|
||||
Generated
+14
@@ -1399,6 +1399,7 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mcp",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
@@ -1461,6 +1462,7 @@ dependencies = [
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
@@ -1542,6 +1544,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -1646,6 +1655,7 @@ dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-mcp",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"libc",
|
||||
@@ -1733,6 +1743,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
@@ -5120,6 +5131,9 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-theme",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -17,6 +17,8 @@ members = [
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
|
||||
"crates/horizon-mcp",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
+1
-10
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -92,6 +92,30 @@ licensed under the MIT license:
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## FusionExamples
|
||||
|
||||
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
|
||||
texture templates (`templates/template <layout>.png`) of
|
||||
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2024 Martijn van den Brand
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## BlueMap
|
||||
|
||||
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
|
||||
|
||||
@@ -130,6 +130,26 @@ impl ModelLibrary {
|
||||
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_chain(name, None)
|
||||
}
|
||||
/// Whether `name` and every parent it reaches are models of this library,
|
||||
/// with no cycle: `resolve` puts the missing model in for any other.
|
||||
pub fn resolves(&self, name: &str) -> bool {
|
||||
let Ok(mut name) = identifier(name) else {
|
||||
return false;
|
||||
};
|
||||
let mut visited = BTreeSet::new();
|
||||
loop {
|
||||
let Some(model) = self.models.get(&name) else {
|
||||
return false;
|
||||
};
|
||||
if !visited.insert(name.clone()) {
|
||||
return false;
|
||||
}
|
||||
match &model.parent {
|
||||
Some(parent) => name = parent.clone(),
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `name`'s resolved model with a virtual child in front of it that has
|
||||
/// only `textures`: the child's slots win, and `#references` anywhere in
|
||||
/// the chain re-bind through them (so `builtin/generated`'s
|
||||
@@ -529,4 +549,23 @@ mod tests {
|
||||
"minecraft:missingno"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_model_resolves_only_with_its_whole_parent_chain() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
("item/orphan", r#"{"parent":"item/gone"}"#),
|
||||
("item/a", r#"{"parent":"item/b"}"#),
|
||||
("item/b", r#"{"parent":"item/a"}"#),
|
||||
]);
|
||||
assert!(library.resolves("item/stick"));
|
||||
assert!(library.resolves("minecraft:item/stick"));
|
||||
assert!(!library.resolves("item/absent"));
|
||||
assert!(!library.resolves("item/orphan"));
|
||||
assert!(!library.resolves("item/a"));
|
||||
assert!(!library.resolves("Item/Stick"));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
|
||||
for crates/horizon-protocol/examples/net_replay.rs
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -177,6 +180,8 @@ struct Options {
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// --capture: where the first bot's play packets go.
|
||||
capture: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--capture" => o.capture = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
@@ -819,12 +826,53 @@ impl BatchSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
|
||||
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
|
||||
struct Capture {
|
||||
out: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
/// The second last flushed: the process may end before the reader thread does.
|
||||
flushed: u32,
|
||||
}
|
||||
impl Capture {
|
||||
fn create(path: &str) -> Option<Self> {
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => Some(Self {
|
||||
out: std::io::BufWriter::new(file),
|
||||
start: Instant::now(),
|
||||
flushed: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("--capture {path}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(&mut self, id: i32, body: &[u8]) {
|
||||
use std::io::Write;
|
||||
let ms = self.start.elapsed().as_millis() as u32;
|
||||
let mut head = [0u8; 12];
|
||||
head[..4].copy_from_slice(&ms.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&id.to_le_bytes());
|
||||
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
let _ = self
|
||||
.out
|
||||
.write_all(&head)
|
||||
.and_then(|()| self.out.write_all(body));
|
||||
if ms / 1000 != self.flushed {
|
||||
self.flushed = ms / 1000;
|
||||
let _ = self.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
@@ -848,6 +896,9 @@ fn read(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(c) = &mut capture {
|
||||
c.record(id, &body);
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
@@ -1007,7 +1058,10 @@ fn run_bot(
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
thread::spawn(move || read(reader, writer, bot, track));
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1025,6 +1079,10 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
if index == 0 {
|
||||
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
|
||||
println!("{}", json!({ "spawn": spawn }));
|
||||
}
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
|
||||
@@ -239,8 +239,19 @@ impl Action {
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -312,12 +323,38 @@ impl Guard {
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
/// The player's mute, if they are muted at `now`.
|
||||
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
self.mutes.get(&uuid).filter(|m| m.active(now))
|
||||
}
|
||||
|
||||
/// The mutes in force at `now`, by name.
|
||||
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
mutes
|
||||
}
|
||||
|
||||
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
|
||||
pub fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
by: Some(by.to_owned()),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
@@ -343,15 +380,24 @@ impl Guard {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
// An operator's longer mute stays.
|
||||
if !self
|
||||
.mutes
|
||||
.get(&uuid)
|
||||
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
|
||||
{
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until: Some(until),
|
||||
by: None,
|
||||
reason: None,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
@@ -377,7 +423,7 @@ impl Guard {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
assert_eq!(g.muted(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
|
||||
assert_eq!(g.muted(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert!(again.muted(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
assert_eq!(again.muted(5, now), None);
|
||||
// An operator's mutes are kept too, with who and why.
|
||||
again.mute(6, "Eve", None, "Op", Some("spam".into()));
|
||||
again.mute(7, "Fay", Some(now + MIN), "Op", None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
assert_eq!(reloaded.muted(5, now), None);
|
||||
let names: Vec<_> = reloaded
|
||||
.mutes(now)
|
||||
.iter()
|
||||
.map(|m| m.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["Eve", "Fay"]);
|
||||
let eve = reloaded.muted(6, now).unwrap();
|
||||
assert_eq!(
|
||||
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
|
||||
(None, Some("Op"), Some("spam"))
|
||||
);
|
||||
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mute_file_from_before_manual_mutes_still_loads() {
|
||||
let saved: MutesFile = serde_json::from_str(
|
||||
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.mutes[0].until, Some(5));
|
||||
assert_eq!(saved.mutes[0].by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_never_shortens_an_operators_mute() {
|
||||
let mut g = guard(Config::default());
|
||||
g.mute(1, "Bo", None, "Op", None);
|
||||
g.strike(1, "Bo", NOW);
|
||||
g.strike(1, "Bo", NOW + MIN);
|
||||
assert_eq!(
|
||||
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
|
||||
Some("Op")
|
||||
);
|
||||
// A shorter one is replaced by the filter's.
|
||||
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
|
||||
g.strike(2, "Cy", NOW);
|
||||
g.strike(2, "Cy", NOW);
|
||||
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_read_and_print() {
|
||||
assert_eq!(parse_duration("10m"), Some(10 * MIN));
|
||||
assert_eq!(parse_duration("2h"), Some(120 * MIN));
|
||||
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("30s"), Some(30_000));
|
||||
for bad in [
|
||||
"",
|
||||
"m",
|
||||
"0m",
|
||||
"-1m",
|
||||
"1.5h",
|
||||
"10",
|
||||
"10x",
|
||||
"99999999999999999w",
|
||||
] {
|
||||
assert_eq!(parse_duration(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(duration_text(61_000), "2 minutes");
|
||||
assert_eq!(duration_text(MIN), "1 minute");
|
||||
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
|
||||
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
|
||||
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
|
||||
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
Some("custom") => Some(crate::text::ClickEvent::Custom {
|
||||
id: crate::identifier::parse_identifier(&text("id")?)?,
|
||||
payload: e
|
||||
.get("payload")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(crate::scoreboard::json_to_tag),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -585,6 +592,63 @@ mod tests {
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
|
||||
use crate::text::ClickEvent;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
for (payload, expected) in [
|
||||
(None, None),
|
||||
(Some(serde_json::Value::Null), None),
|
||||
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
|
||||
(
|
||||
Some(serde_json::json!({"shop": "potions", "amount": 3})),
|
||||
Some(Tag::Compound(
|
||||
[
|
||||
("amount".into(), Tag::Int(3)),
|
||||
("shop".into(), Tag::String("potions".into())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = payload;
|
||||
}
|
||||
let component = serde_json::json!({
|
||||
"text": "", "click_event": event,
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
for (_, style) in text.chars() {
|
||||
assert_eq!(
|
||||
style.click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: expected.clone(),
|
||||
})
|
||||
);
|
||||
}
|
||||
let encoded = text.component();
|
||||
let decoded = to_text(&encoded, &mut |_| Text::empty());
|
||||
assert_eq!(decoded, text);
|
||||
if expected.is_none() {
|
||||
assert!(!encoded.to_string().contains("\"payload\""));
|
||||
}
|
||||
}
|
||||
for id in ["Bad ID", "Uppercase:shop"] {
|
||||
let text = to_text(
|
||||
&serde_json::json!({
|
||||
"text": "Shop", "click_event": {"action": "custom", "id": id}
|
||||
}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
assert_eq!(text.spans[0].style.click, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
@@ -34,6 +35,15 @@ pub struct Language {
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// A language that only the calling thread sees, and the keys it was asked for and did not have
|
||||
/// (`scoped`).
|
||||
struct Scope {
|
||||
language: Arc<Language>,
|
||||
missed: Vec<String>,
|
||||
}
|
||||
thread_local! {
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
return Some(language);
|
||||
}
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// Runs `f` with `language` as the current language of this thread only, and returns what it
|
||||
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
|
||||
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
|
||||
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
|
||||
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
|
||||
/// language's.
|
||||
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
|
||||
/// Puts the outer scope back, also when `f` panics.
|
||||
struct Restore(Option<Scope>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
s.borrow_mut().replace(Scope {
|
||||
language: Arc::new(language),
|
||||
missed: Vec::new(),
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
.as_mut()
|
||||
.map(|s| std::mem::take(&mut s.missed))
|
||||
});
|
||||
(result, missed.unwrap_or_default())
|
||||
}
|
||||
/// `key` was asked for in a `scoped` language that has no text for it.
|
||||
fn missed(key: &str) {
|
||||
SCOPE.with(|s| {
|
||||
if let Some(scope) = s.borrow_mut().as_mut()
|
||||
&& !scope.missed.iter().any(|m| m == key)
|
||||
{
|
||||
scope.missed.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
@@ -267,13 +319,19 @@ pub fn code() -> String {
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| key.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
key.to_owned()
|
||||
})
|
||||
}
|
||||
/// The translation, or `fallback` when the key is missing.
|
||||
pub fn get_or(key: &str, fallback: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
fallback.to_owned()
|
||||
})
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
@@ -350,6 +408,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let ((german, english_only, fallback, elsewhere), missed) =
|
||||
scoped(Language::load(&[&empty], "de_de"), || {
|
||||
(
|
||||
get("horizon.osk.close"),
|
||||
get("horizon.no_such_key"),
|
||||
get_or("horizon.no_fallback", "English"),
|
||||
// Another thread keeps the process-wide language.
|
||||
std::thread::spawn(code).join().unwrap(),
|
||||
)
|
||||
});
|
||||
assert_eq!(german, "Schließen");
|
||||
assert_eq!(english_only, "horizon.no_such_key");
|
||||
assert_eq!(fallback, "English");
|
||||
assert_eq!(elsewhere, outside);
|
||||
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
|
||||
// The scope ends with its closure.
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_end_when_their_closure_panics() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
|
||||
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
|
||||
(code(), get("horizon.inner_miss"))
|
||||
});
|
||||
assert_eq!(inner_missed, ["horizon.inner_miss"]);
|
||||
assert_eq!(code(), "fr_fr");
|
||||
inner.0
|
||||
});
|
||||
assert_eq!(inner, "pl_pl");
|
||||
assert!(missed.is_empty());
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
|
||||
panic!("a test failed inside a scope")
|
||||
})
|
||||
});
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
|
||||
}
|
||||
|
||||
/// Click actions carried by locally constructed text components.
|
||||
/// Click actions carried by text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
@@ -53,6 +53,11 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<horizon_world::nbt::Tag>,
|
||||
},
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -539,7 +544,7 @@ const STYLE_KEYS: [&str; 11] = [
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
/// `Style.Serializer`: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = style.color {
|
||||
@@ -581,6 +586,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::Custom { id, payload } => {
|
||||
let mut event = json!({ "action": "custom", "id": id });
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = crate::scoreboard::tag_to_json(payload);
|
||||
}
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -51,6 +51,10 @@ impl InteractionInput {
|
||||
pub fn press(&mut self, button: Button) {
|
||||
self.clicks[button as usize] += 1;
|
||||
}
|
||||
/// Whether a click waits for the next tick.
|
||||
pub fn has_clicks(&self) -> bool {
|
||||
self.clicks.iter().any(|&c| c > 0)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
|
||||
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
|
||||
horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
@@ -132,6 +134,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gen_light_bench"
|
||||
path = "../horizon-client/examples/gen_light_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
|
||||
@@ -105,6 +105,8 @@ pub struct RenderAssets {
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -224,7 +226,7 @@ impl RenderAssets {
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
let mut models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
@@ -244,6 +246,11 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
@@ -304,6 +311,7 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
let doc: serde_json::Value = agent()
|
||||
.get(&discovery_url())
|
||||
.call()
|
||||
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
|
||||
.ok()
|
||||
.and_then(|r| r.into_string().ok())
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A join that got no answer is worth one more try, as leaving and joining
|
||||
/// again would be: `join` is safe to repeat, it only records the server's
|
||||
/// hash. Any other answer is the service's own and stands.
|
||||
fn with_one_retry(
|
||||
mut join: impl FnMut() -> Result<(), JoinError>,
|
||||
pause: Duration,
|
||||
) -> Result<(), JoinError> {
|
||||
match join() {
|
||||
Err(JoinError::Unavailable) => {
|
||||
std::thread::sleep(pause);
|
||||
join()
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// MinecraftServicesSessionService.joinServer: tell the session service this
|
||||
/// account is joining the server whose hash is `server_hash`.
|
||||
/// account is joining the server whose hash is `server_hash`. Horizon tries
|
||||
/// twice when the service doesn't answer; vanilla asks the player to.
|
||||
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
with_one_retry(
|
||||
|| join_server_once(session, server_hash),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
}
|
||||
|
||||
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
let doc = discovery()?;
|
||||
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
|
||||
match agent()
|
||||
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
|
||||
.send_string(&join_request(session, server_hash))
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
)),
|
||||
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
|
||||
Err(ureq::Error::Status(status, response)) => {
|
||||
if status >= 500 {
|
||||
log::warn!("The session service's join answered {status}");
|
||||
}
|
||||
Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
Err(ureq::Error::Transport(e)) => {
|
||||
log::warn!("The session service's join: {e}");
|
||||
Err(JoinError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +597,41 @@ mod tests {
|
||||
|
||||
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
|
||||
|
||||
/// Runs `with_one_retry` over these answers, in order: the result and how
|
||||
/// many it asked for.
|
||||
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
|
||||
let mut asked = 0;
|
||||
let result = with_one_retry(
|
||||
|| {
|
||||
asked += 1;
|
||||
answers[asked - 1].clone()
|
||||
},
|
||||
Duration::ZERO,
|
||||
);
|
||||
(result, asked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_with_no_answer_is_tried_once_more() {
|
||||
let none = Err(JoinError::Unavailable);
|
||||
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
|
||||
// Twice without an answer: the message the player knows.
|
||||
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_the_service_answered_is_not_tried_again() {
|
||||
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
|
||||
for refused in [
|
||||
JoinError::InvalidSession,
|
||||
JoinError::Banned,
|
||||
JoinError::InsufficientPrivileges,
|
||||
JoinError::Other("Status: 429".into()),
|
||||
] {
|
||||
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
|
||||
let path = dir.join("session.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
|
||||
@@ -46,6 +46,7 @@ pub enum Ticking {
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
@@ -84,6 +85,18 @@ pub struct BlockEntities {
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
/// The positions of the block entities that draw a bounding box (structure and test
|
||||
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
|
||||
/// Like `mods`, one taken out stays listed until `put` or `remove`.
|
||||
boxes: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
/// A structure or test instance block: the block entities whose box the frame may draw.
|
||||
fn shows_box(entity: &BlockEntity) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
@@ -220,11 +233,53 @@ impl BlockEntities {
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
/// Attachment map for a live block entity.
|
||||
pub fn attachments_mut(
|
||||
&mut self,
|
||||
pos: Pos,
|
||||
) -> Option<&mut horizon_world::attachments::Attachments> {
|
||||
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
|
||||
return None;
|
||||
}
|
||||
Some(self.attachments.entry(pos).or_default())
|
||||
}
|
||||
/// Attachment maps in a loaded chunk, for a save snapshot.
|
||||
pub fn chunk_attachments(
|
||||
&self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
|
||||
self.attachments
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
|
||||
.map(|(p, data)| (*p, data.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Restore a loaded block entity's attachment map.
|
||||
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
|
||||
if self.map.contains_key(&pos) {
|
||||
self.attachments.insert(pos, data);
|
||||
}
|
||||
}
|
||||
/// Whether any mod block entity is in the map: until one is, a block change
|
||||
/// never looks one up (`LevelAccess::has_mod_block_entity`).
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The structure and test instance blocks (a frame draws their boxes), in no order.
|
||||
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
|
||||
}
|
||||
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
|
||||
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
|
||||
if entity.is_some_and(shows_box) {
|
||||
self.boxes.insert(pos);
|
||||
} else {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
@@ -248,12 +303,28 @@ impl BlockEntities {
|
||||
}
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
/// One of the same type rewrites it in place (a jukebox taking a disc,
|
||||
/// `/data merge block`), where vanilla keeps the object, so its
|
||||
/// attachments stay; any other replacement starts them over.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let same_type = |old: &BlockEntity| match (old, &entity) {
|
||||
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
|
||||
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
|
||||
};
|
||||
if !self.map.get(&pos).is_some_and(same_type) {
|
||||
self.attachments.remove(&pos);
|
||||
}
|
||||
let new_box = shows_box(&entity);
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_box {
|
||||
self.boxes.insert(pos);
|
||||
} else if old.as_ref().is_some_and(shows_box) {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
@@ -275,15 +346,20 @@ impl BlockEntities {
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if absent && shows_box(entity) {
|
||||
self.boxes.insert(pos);
|
||||
}
|
||||
entity
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.attachments.remove(pos);
|
||||
self.cancel(pos);
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
self.boxes.remove(pos);
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
@@ -301,6 +377,7 @@ impl BlockEntities {
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
self.note_box(pos, Some(&entity));
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -311,21 +388,30 @@ impl BlockEntities {
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
let (live, pending, freed, attachments) = (
|
||||
&mut self.live,
|
||||
&mut self.pending,
|
||||
&mut self.freed,
|
||||
&mut self.attachments,
|
||||
);
|
||||
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
attachments.remove(p);
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
boxes.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.attachments.clear();
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -342,7 +428,9 @@ impl BlockEntities {
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.attachments.remove(&p);
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
self.note_box(p, Some(&e));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
@@ -476,6 +564,52 @@ mod tests {
|
||||
b.clear();
|
||||
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn attachments_follow_block_entity_lifetime() {
|
||||
use horizon_world::attachments::AttachmentType;
|
||||
let ty = AttachmentType {
|
||||
namespace: "test",
|
||||
name: "value",
|
||||
default: || 0,
|
||||
codec: None,
|
||||
copy_on_death: false,
|
||||
};
|
||||
let p = (1, 64, 2);
|
||||
let mut entities = level();
|
||||
assert!(entities.attachments_mut(p).is_none());
|
||||
entities.insert(p, hopper());
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
let held = entities.take(&p).unwrap();
|
||||
entities.put(p, held);
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
let machine = |id: &str| {
|
||||
BlockEntity::Mod(Box::new(
|
||||
crate::chunk::block_entity::ModBlockEntityFields {
|
||||
id: id.into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
};
|
||||
entities.insert(p, machine("test:a"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
entities.insert(p, machine("test:a"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
entities.insert(p, machine("test:b"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
entities.remove(&p);
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
|
||||
entities.retain(|_, _| false);
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
@@ -560,4 +694,43 @@ mod tests {
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_list_exactly_the_structure_and_test_blocks() {
|
||||
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
|
||||
let listed = |b: &BlockEntities| {
|
||||
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut b = level();
|
||||
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
|
||||
b.insert(p, test_block());
|
||||
b.insert(q, hopper());
|
||||
assert_eq!(listed(&b), [p]);
|
||||
// Replaced by another kind, and back.
|
||||
b.insert(p, hopper());
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(q, test_block());
|
||||
b.get_or_insert_with(r, test_block);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
// Taken out: not shown until put back; put back as another kind: gone.
|
||||
let taken = b.take(&q).unwrap();
|
||||
assert_eq!(listed(&b), [r]);
|
||||
b.put(q, taken);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
let taken = b.take(&r).unwrap();
|
||||
drop(taken);
|
||||
b.put(r, hopper());
|
||||
assert_eq!(listed(&b), [q]);
|
||||
b.remove(&q);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
|
||||
assert_eq!(listed(&b), [p]);
|
||||
b.retain(|pos, _| *pos != p);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(r, test_block());
|
||||
b.clear();
|
||||
assert!(b.boxes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
|
||||
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
|
||||
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
|
||||
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
|
||||
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
|
||||
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
|
||||
//! atlas has it.
|
||||
//!
|
||||
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
|
||||
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
|
||||
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
|
||||
/// names for its vanilla parts.
|
||||
struct Target {
|
||||
key: &'static str,
|
||||
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
|
||||
/// apart shares (a trapped chest's is the chest's).
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
const CHEST: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("base", "bottom"),
|
||||
("lid", "lid"),
|
||||
("knob", "lock"),
|
||||
];
|
||||
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
|
||||
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
|
||||
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
|
||||
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
|
||||
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
|
||||
const PIGLIN: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("head", "head"),
|
||||
("left_ear", "left_ear"),
|
||||
("right_ear", "right_ear"),
|
||||
];
|
||||
const BELL: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("body", "bell_body"),
|
||||
("base", "bell_base"),
|
||||
];
|
||||
const BOOK: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("cover_right", "right_lid"),
|
||||
("cover_left", "left_lid"),
|
||||
("pages_right", "right_pages"),
|
||||
("pages_left", "left_pages"),
|
||||
("flipping_page_right", "flip_page1"),
|
||||
("flipping_page_left", "flip_page2"),
|
||||
("book_spine", "seam"),
|
||||
];
|
||||
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
|
||||
const POT_SIDES: &[(&str, &str)] = &[
|
||||
("front", "front"),
|
||||
("back", "back"),
|
||||
("left", "left"),
|
||||
("right", "right"),
|
||||
];
|
||||
|
||||
const fn model(
|
||||
key: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base: key,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
const fn copy(
|
||||
key: &'static str,
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// In order: a copy comes after its base.
|
||||
const TARGETS: &[Target] = &[
|
||||
model("chest", &["chest"], CHEST),
|
||||
model("double_chest_left", &["chest_left"], CHEST),
|
||||
model("double_chest_right", &["chest_right"], CHEST),
|
||||
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
|
||||
copy(
|
||||
"trapped_chest_left",
|
||||
"double_chest_left",
|
||||
&["trapped_chest_left"],
|
||||
CHEST,
|
||||
),
|
||||
copy(
|
||||
"trapped_chest_right",
|
||||
"double_chest_right",
|
||||
&["trapped_chest_right"],
|
||||
CHEST,
|
||||
),
|
||||
copy("ender_chest", "chest", &["ender_chest"], CHEST),
|
||||
model("shulker_box", &["shulker_box"], SHULKER),
|
||||
model("standing_banner", &["banner_base", "banner"], BANNER),
|
||||
model("wall_banner", &["banner_base", "banner"], BANNER),
|
||||
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("skeleton_skull", &["head_skeleton"], HEAD),
|
||||
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
|
||||
model("zombie_head", &["head_zombie"], HEAD),
|
||||
model("creeper_head", &["head_creeper"], HEAD),
|
||||
model("dragon_skull", &["head_dragon"], DRAGON),
|
||||
model("piglin_head", &["head_piglin"], PIGLIN),
|
||||
model("player_head", &["head_player"], HEAD),
|
||||
model("bell", &["bell"], BELL),
|
||||
model("book", &["enchanting_book", "book"], BOOK),
|
||||
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
|
||||
model("conduit_shell", &["conduit"], &[("base", "shell")]),
|
||||
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
|
||||
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
|
||||
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
|
||||
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
let (reshaped, unused) = reshape_all(root, vanilla)?;
|
||||
for (path, part) in unused {
|
||||
log::warn!("Horizon: custom entity model {path}: no part {part}");
|
||||
}
|
||||
Some(reshaped)
|
||||
}
|
||||
|
||||
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
|
||||
fn reshape_all(
|
||||
root: &Path,
|
||||
vanilla: &HashMap<&'static str, ModelPart>,
|
||||
) -> Option<(Reshaped, Vec<(String, String)>)> {
|
||||
let files = jem_files(root);
|
||||
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
|
||||
if !TARGETS.iter().any(named) {
|
||||
return None;
|
||||
}
|
||||
let mut models = vanilla.clone();
|
||||
let mut textures = HashMap::new();
|
||||
let mut reshaped = HashSet::new();
|
||||
// Each file parsed once; which of its entries found a part in some model.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
|
||||
for t in TARGETS {
|
||||
let Some(model) = vanilla.get(t.base) else {
|
||||
continue;
|
||||
};
|
||||
let found = t
|
||||
.names
|
||||
.iter()
|
||||
.flat_map(|name| files.get(*name).into_iter().flatten())
|
||||
.map(String::as_str)
|
||||
.find(|&path| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some(path) = found else {
|
||||
// A copy of a reshaped base that has no file of its own stays vanilla.
|
||||
if t.key != t.base && reshaped.contains(t.base) {
|
||||
models.insert(t.key, model.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut out = model.clone();
|
||||
let hits = apply(&mut out, jem, t.parts);
|
||||
// A file shared by several models (the conduit's, the pot's) paints only the models
|
||||
// it reshapes: vanilla geometry keeps its own texture's layout.
|
||||
let reshapes = hits.iter().any(|hit| *hit);
|
||||
let marks = used
|
||||
.entry(path)
|
||||
.or_insert_with(|| vec![false; jem.parts.len()]);
|
||||
for (mark, hit) in marks.iter_mut().zip(hits) {
|
||||
*mark |= hit;
|
||||
}
|
||||
models.insert(t.key, out);
|
||||
reshaped.insert(t.key);
|
||||
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
|
||||
textures.insert(t.key, texture);
|
||||
}
|
||||
}
|
||||
let mut unused = Vec::new();
|
||||
for (path, marks) in &used {
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
|
||||
unused.push(((*path).to_owned(), p.part.clone()));
|
||||
}
|
||||
}
|
||||
Some(((models, textures), unused))
|
||||
}
|
||||
|
||||
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
|
||||
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
|
||||
/// `cem#<n>`. Which entries found their part.
|
||||
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
|
||||
let mut count = 0;
|
||||
let mut hits = Vec::with_capacity(jem.parts.len());
|
||||
for p in &jem.parts {
|
||||
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
|
||||
let target = match mapped {
|
||||
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
|
||||
_ => p.part.as_str(),
|
||||
};
|
||||
let Some(node) = find_mut(model, target) else {
|
||||
hits.push(false);
|
||||
continue;
|
||||
};
|
||||
if !p.attach {
|
||||
node.polygons.clear();
|
||||
}
|
||||
let (name, mut bone) = part(&p.bone, &mut count);
|
||||
bone.pose[6..9].fill(p.scale);
|
||||
node.children.push((name, bone));
|
||||
hits.push(true);
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// The model itself for `root`, else its first part named `name`, depth first.
|
||||
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
part.children.iter_mut().find_map(|(n, c)| {
|
||||
if n.as_str() == name {
|
||||
Some(c)
|
||||
} else {
|
||||
child(c, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if name == "root" {
|
||||
return Some(model);
|
||||
}
|
||||
child(model, name)
|
||||
}
|
||||
|
||||
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
|
||||
/// change by name reaches it.
|
||||
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
|
||||
let name = format!("cem#{count}");
|
||||
*count += 1;
|
||||
let [x, y, z] = bone.pivot;
|
||||
let [rx, ry, rz] = bone.rotation;
|
||||
let out = ModelPart {
|
||||
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
|
||||
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count)).collect(),
|
||||
};
|
||||
(name, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::{collections::BTreeSet, fs};
|
||||
|
||||
/// A part with one face and `children`.
|
||||
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
|
||||
ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[[0.0; 5]; 4]],
|
||||
children: children
|
||||
.into_iter()
|
||||
.map(|(n, c)| (n.to_owned(), c))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn chest() -> ModelPart {
|
||||
let mut root = plane(vec![
|
||||
("bottom", plane(vec![])),
|
||||
("lid", plane(vec![])),
|
||||
("lock", plane(vec![])),
|
||||
]);
|
||||
root.polygons.clear();
|
||||
root
|
||||
}
|
||||
pub(crate) const BOX: &str =
|
||||
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
|
||||
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with one box.
|
||||
fn jem(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
|
||||
/// deep, so a part that took another entry's box shows.
|
||||
fn jem_sized(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let depth = i + 1;
|
||||
format!(
|
||||
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// How deep (z) the custom box under `model`'s part `name` is.
|
||||
fn depth(model: &ModelPart, name: &str) -> f32 {
|
||||
let part = model.child(name).unwrap();
|
||||
let zs = part
|
||||
.children
|
||||
.iter()
|
||||
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
|
||||
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
|
||||
hi - lo
|
||||
}
|
||||
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
|
||||
/// child of a box's six faces.
|
||||
fn replaced(model: &ModelPart, name: &str) -> bool {
|
||||
let part = model.child(name).unwrap();
|
||||
match part.children.as_slice() {
|
||||
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_optifine_part_name_lands_on_its_vanilla_part() {
|
||||
// File, OptiFine's part, the model it reshapes, vanilla's part there.
|
||||
let cases = [
|
||||
("chest", "base", "chest", "bottom"),
|
||||
("chest", "lid", "chest", "lid"),
|
||||
("chest", "knob", "chest", "lock"),
|
||||
("chest_left", "base", "double_chest_left", "bottom"),
|
||||
("chest_left", "lid", "double_chest_left", "lid"),
|
||||
("chest_left", "knob", "double_chest_left", "lock"),
|
||||
("chest_right", "base", "double_chest_right", "bottom"),
|
||||
("chest_right", "lid", "double_chest_right", "lid"),
|
||||
("chest_right", "knob", "double_chest_right", "lock"),
|
||||
("trapped_chest", "base", "trapped_chest", "bottom"),
|
||||
("trapped_chest", "knob", "trapped_chest", "lock"),
|
||||
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
|
||||
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
|
||||
(
|
||||
"trapped_chest_right",
|
||||
"base",
|
||||
"trapped_chest_right",
|
||||
"bottom",
|
||||
),
|
||||
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
|
||||
("ender_chest", "base", "ender_chest", "bottom"),
|
||||
("ender_chest", "knob", "ender_chest", "lock"),
|
||||
("shulker_box", "base", "shulker_box", "base"),
|
||||
("shulker_box", "lid", "shulker_box", "lid"),
|
||||
("banner", "stand", "standing_banner", "pole"),
|
||||
("banner", "top", "standing_banner", "bar"),
|
||||
("banner", "top", "wall_banner", "bar"),
|
||||
("banner", "slate", "standing_banner_flag", "flag"),
|
||||
("banner", "slate", "wall_banner_flag", "flag"),
|
||||
("head_skeleton", "head", "skeleton_skull", "head"),
|
||||
(
|
||||
"head_wither_skeleton",
|
||||
"head",
|
||||
"wither_skeleton_skull",
|
||||
"head",
|
||||
),
|
||||
("head_zombie", "head", "zombie_head", "head"),
|
||||
("head_creeper", "head", "creeper_head", "head"),
|
||||
("head_dragon", "head", "dragon_skull", "head"),
|
||||
("head_dragon", "jaw", "dragon_skull", "jaw"),
|
||||
("head_piglin", "head", "piglin_head", "head"),
|
||||
("head_piglin", "left_ear", "piglin_head", "left_ear"),
|
||||
("head_piglin", "right_ear", "piglin_head", "right_ear"),
|
||||
("head_player", "head", "player_head", "head"),
|
||||
("bell", "body", "bell", "bell_body"),
|
||||
("bell", "base", "bell", "bell_base"),
|
||||
("enchanting_book", "cover_right", "book", "right_lid"),
|
||||
("enchanting_book", "cover_left", "book", "left_lid"),
|
||||
("enchanting_book", "pages_right", "book", "right_pages"),
|
||||
("enchanting_book", "pages_left", "book", "left_pages"),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_right",
|
||||
"book",
|
||||
"flip_page1",
|
||||
),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_left",
|
||||
"book",
|
||||
"flip_page2",
|
||||
),
|
||||
("enchanting_book", "book_spine", "book", "seam"),
|
||||
("lectern_book", "cover_right", "lectern_book", "right_lid"),
|
||||
("lectern_book", "pages_left", "lectern_book", "left_pages"),
|
||||
(
|
||||
"lectern_book",
|
||||
"flipping_page_right",
|
||||
"lectern_book",
|
||||
"flip_page1",
|
||||
),
|
||||
("lectern_book", "book_spine", "lectern_book", "seam"),
|
||||
("conduit", "base", "conduit_shell", "shell"),
|
||||
("conduit", "cage", "conduit_cage", "shell"),
|
||||
("conduit", "wind", "conduit_wind", "wind"),
|
||||
("conduit", "eye", "conduit_eye", "eye"),
|
||||
("decorated_pot", "neck", "decorated_pot_base", "neck"),
|
||||
("decorated_pot", "top", "decorated_pot_base", "top"),
|
||||
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
|
||||
("decorated_pot", "front", "decorated_pot_sides", "front"),
|
||||
("decorated_pot", "back", "decorated_pot_sides", "back"),
|
||||
("decorated_pot", "left", "decorated_pot_sides", "left"),
|
||||
("decorated_pot", "right", "decorated_pot_sides", "right"),
|
||||
];
|
||||
// One file per name with each of its parts, and a stub of each model holding its parts
|
||||
// (chest models all of theirs, so a misplaced entry shows).
|
||||
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
files.entry(file).or_default().insert(part);
|
||||
trees.entry(key).or_default().insert(vanilla);
|
||||
}
|
||||
for key in ["chest", "double_chest_left", "double_chest_right"] {
|
||||
trees
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.extend(["bottom", "lid", "lock"]);
|
||||
}
|
||||
let files_by_name = files.clone();
|
||||
let files: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(file, parts)| {
|
||||
let parts: Vec<_> = parts.iter().copied().collect();
|
||||
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
|
||||
let root = pack("parts", &files);
|
||||
let vanilla: HashMap<&'static str, ModelPart> = trees
|
||||
.iter()
|
||||
.map(|(key, parts)| {
|
||||
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
|
||||
(*key, plane(parts))
|
||||
})
|
||||
.collect();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, _), unused) = result.unwrap();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
assert!(
|
||||
replaced(&models[key], vanilla),
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
// The box is this entry's own: swapped names (left and right, body and base) show.
|
||||
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
|
||||
assert_eq!(
|
||||
depth(&models[key], vanilla),
|
||||
own as f32,
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
}
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
|
||||
let root = pack(
|
||||
"chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
|
||||
{{"part": "lid", {BOX}}},
|
||||
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
|
||||
{{"part": "bottom", {BOX}}},
|
||||
{{"part": "tail", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
let chest = &models["chest"];
|
||||
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
|
||||
assert!(replaced(chest, "lid"));
|
||||
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
|
||||
assert!(replaced(chest, "bottom"));
|
||||
// Attached: the lock keeps its face beside the scaled box.
|
||||
let lock = chest.child("lock").unwrap();
|
||||
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
|
||||
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
|
||||
// `tail` names no chest part: the one warning.
|
||||
assert_eq!(
|
||||
unused,
|
||||
[(
|
||||
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
|
||||
"tail".to_owned()
|
||||
)]
|
||||
);
|
||||
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
|
||||
// only the chest takes the texture.
|
||||
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
|
||||
let lid = models[key].child("lid").unwrap();
|
||||
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
textures,
|
||||
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banners_split_one_file_and_books_fall_back() {
|
||||
let root = pack(
|
||||
"banner",
|
||||
&[
|
||||
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
|
||||
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
|
||||
],
|
||||
);
|
||||
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
|
||||
let wall = plane(vec![("bar", plane(vec![]))]);
|
||||
let flag = plane(vec![("flag", plane(vec![]))]);
|
||||
let book = plane(vec![("left_lid", plane(vec![]))]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
("standing_banner", banner),
|
||||
("wall_banner", wall),
|
||||
("wall_banner_flag", flag),
|
||||
("book", book),
|
||||
]
|
||||
.into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
assert!(replaced(&models["standing_banner"], "pole"));
|
||||
assert!(replaced(&models["standing_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner_flag"], "flag"));
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
|
||||
let lectern = models["lectern_book"].child("left_lid").unwrap();
|
||||
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
|
||||
assert!(textures.is_empty());
|
||||
// Each entry found its part in one of the models: no warning.
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
|
||||
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
|
||||
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, _) = result.unwrap();
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
assert!(replaced(&models["lectern_book"], "left_lid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_paints_only_the_models_it_reshapes() {
|
||||
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
|
||||
// vanilla geometry and so their own textures.
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
|
||||
);
|
||||
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
"conduit_shell",
|
||||
"conduit_cage",
|
||||
"conduit_wind",
|
||||
"conduit_eye",
|
||||
]
|
||||
.map(|key| {
|
||||
let part = if key == "conduit_eye" { "eye" } else { "shell" };
|
||||
(key, plane(vec![(part, plane(vec![]))]))
|
||||
})
|
||||
.into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, textures) = result.unwrap();
|
||||
assert!(replaced(&models["conduit_eye"], "eye"));
|
||||
let painted: BTreeSet<_> = textures.keys().copied().collect();
|
||||
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
|
||||
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -118,6 +118,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
quad: ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
@@ -158,6 +158,28 @@ pub struct Brewing {
|
||||
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
|
||||
/// recipe folder's read when another table holds it (the start loads them
|
||||
/// together), else read alone. A file that doesn't parse is left out.
|
||||
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
|
||||
let recipe = root.join("data/minecraft/recipe");
|
||||
let (folder, prefix) = match crate::json_folder::held(&recipe) {
|
||||
Some(folder) => (folder, "brewing/"),
|
||||
None => match crate::json_folder::read(&recipe.join("brewing")) {
|
||||
Ok(folder) => (folder, ""),
|
||||
Err(_) => return Vec::new(),
|
||||
},
|
||||
};
|
||||
folder
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
|
||||
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
|
||||
Some((format!("minecraft:brewing/{name}"), v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
@@ -172,23 +194,7 @@ impl Brewing {
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Value)> = brewing_sources(root);
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
|
||||
@@ -285,20 +285,122 @@ fn forget_shared() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
/// Packed light per block: sky light in the high nibble, block light in the
|
||||
/// low one. A section keeps only the kind of light that varies in it
|
||||
/// (`Light::packed`): most sections have no block light and only sky light
|
||||
/// that changes, or the reverse below ground.
|
||||
#[derive(Clone)]
|
||||
pub enum Light {
|
||||
Uniform(u8),
|
||||
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
|
||||
/// one in the low nibble, as a vanilla DataLayer), and one block light.
|
||||
Sky(Box<[u8; 2048]>, u8),
|
||||
/// Block light per block, as nibbles, and one sky light.
|
||||
Block(Box<[u8; 2048]>, u8),
|
||||
Full(Box<[u8; 4096]>),
|
||||
}
|
||||
#[inline]
|
||||
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
|
||||
layer[i >> 1] >> ((i & 1) << 2) & 15
|
||||
}
|
||||
impl Light {
|
||||
#[inline]
|
||||
pub fn get(&self, i: usize) -> u8 {
|
||||
match self {
|
||||
Self::Uniform(v) => *v,
|
||||
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
|
||||
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
|
||||
Self::Full(a) => a[i],
|
||||
}
|
||||
}
|
||||
/// The smallest form of packed light per block.
|
||||
pub fn packed(light: Box<[u8; 4096]>) -> Self {
|
||||
let first = light[0];
|
||||
let (mut sky_varies, mut block_varies) = (false, false);
|
||||
for &v in light.iter() {
|
||||
let d = v ^ first;
|
||||
sky_varies |= d & 0xF0 != 0;
|
||||
block_varies |= d & 15 != 0;
|
||||
}
|
||||
let layer = |shift: u32| {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
};
|
||||
match (sky_varies, block_varies) {
|
||||
(false, false) => Self::Uniform(first),
|
||||
(true, false) => Self::Sky(layer(4), first & 15),
|
||||
(false, true) => Self::Block(layer(0), first >> 4),
|
||||
(true, true) => Self::Full(light),
|
||||
}
|
||||
}
|
||||
/// The packed light of blocks `at..at + out.len()`, into `out`.
|
||||
#[inline]
|
||||
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
|
||||
match self {
|
||||
Self::Uniform(v) => out.fill(*v),
|
||||
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
|
||||
Self::Sky(layer, block) => {
|
||||
for (k, o) in out.iter_mut().enumerate() {
|
||||
*o = nibble(layer, at + k) << 4 | block;
|
||||
}
|
||||
}
|
||||
Self::Block(layer, sky) => {
|
||||
for (k, o) in out.iter_mut().enumerate() {
|
||||
*o = sky << 4 | nibble(layer, at + k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The packed light of every block, in index order.
|
||||
pub fn to_array(&self) -> Box<[u8; 4096]> {
|
||||
match self {
|
||||
Self::Full(a) => a.clone(),
|
||||
Self::Uniform(v) => Box::new([*v; 4096]),
|
||||
_ => Box::new(std::array::from_fn(|i| self.get(i))),
|
||||
}
|
||||
}
|
||||
/// Whether any block's packed light has a bit of `mask` set.
|
||||
pub fn any(&self, mask: u8) -> bool {
|
||||
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
|
||||
match self {
|
||||
Self::Uniform(v) => v & mask != 0,
|
||||
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
|
||||
Self::Block(layer, sky) => {
|
||||
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
|
||||
}
|
||||
Self::Full(a) => a.iter().any(|v| v & mask != 0),
|
||||
}
|
||||
}
|
||||
/// Whether any block's packed light differs from `v` in the bits of `mask`.
|
||||
pub fn any_not(&self, mask: u8, v: u8) -> bool {
|
||||
match self {
|
||||
Self::Uniform(u) => (u ^ v) & mask != 0,
|
||||
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
|
||||
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
|
||||
}
|
||||
}
|
||||
/// Heap bytes it holds.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::Uniform(_) => 0,
|
||||
Self::Sky(..) | Self::Block(..) => 2048,
|
||||
Self::Full(_) => 4096,
|
||||
}
|
||||
}
|
||||
/// Whether every block's light is the same as in `other`.
|
||||
pub fn same_as(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Uniform(a), Self::Uniform(b)) => a == b,
|
||||
(Self::Full(a), Self::Full(b)) => a == b,
|
||||
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
|
||||
x == y && a == b
|
||||
}
|
||||
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
@@ -347,14 +449,15 @@ impl Section {
|
||||
pub fn is_uniform(&self, v: u32) -> bool {
|
||||
self.blocks.is_uniform(v)
|
||||
}
|
||||
/// Whether every block, biome and light value is the same as `other`'s.
|
||||
pub fn same_as(&self, other: &Self) -> bool {
|
||||
self.blocks == other.blocks
|
||||
&& self.biomes == other.biomes
|
||||
&& self.light.same_as(&other.light)
|
||||
}
|
||||
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.blocks.heap_bytes()
|
||||
+ self.biomes.heap_bytes()
|
||||
+ match &self.light {
|
||||
Light::Uniform(_) => 0,
|
||||
Light::Full(_) => 4096,
|
||||
}
|
||||
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
|
||||
}
|
||||
/// Collapse storage that became uniform, and drop palette entries no
|
||||
/// block uses any more.
|
||||
@@ -366,13 +469,47 @@ impl Section {
|
||||
*container = PalettedContainer::new(container.strategy(), v);
|
||||
}
|
||||
}
|
||||
if let Light::Full(a) = &self.light
|
||||
&& a.iter().all(|&b| b == a[0])
|
||||
{
|
||||
self.light = Light::Uniform(a[0]);
|
||||
// Only the kind of light that varies is kept.
|
||||
if matches!(self.light, Light::Full(_)) {
|
||||
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
|
||||
unreachable!()
|
||||
};
|
||||
self.light = Light::packed(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
|
||||
/// `horizon-ctl memory`).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ColumnCensus {
|
||||
pub columns: usize,
|
||||
/// Section slots of every column, shared ones included.
|
||||
pub sections: usize,
|
||||
/// Distinct section allocations.
|
||||
pub unique_sections: usize,
|
||||
/// Distinct sections of a single block state (no data array).
|
||||
pub uniform_block_sections: usize,
|
||||
/// Distinct sections with light per block (not one value).
|
||||
pub light_arrays: usize,
|
||||
pub block_bytes: usize,
|
||||
pub biome_bytes: usize,
|
||||
pub light_bytes: usize,
|
||||
pub heightmap_bytes: usize,
|
||||
/// The section records themselves (`Arc<Section>`).
|
||||
pub section_record_bytes: usize,
|
||||
/// The column records and their lists of sections.
|
||||
pub column_bytes: usize,
|
||||
}
|
||||
impl ColumnCensus {
|
||||
pub fn total_bytes(&self) -> usize {
|
||||
self.block_bytes
|
||||
+ self.biome_bytes
|
||||
+ self.light_bytes
|
||||
+ self.heightmap_bytes
|
||||
+ self.section_record_bytes
|
||||
+ self.column_bytes
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
@@ -564,19 +701,61 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
match self.section_at(y) {
|
||||
Ok(section) => section.light(index(x, y, z)),
|
||||
Err(light) => light,
|
||||
}
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
/// The section holding block height `y`, or the light of the air outside the
|
||||
/// column's sections there: none below them, full sky above.
|
||||
#[inline]
|
||||
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
|
||||
match self.section_index(y) {
|
||||
Some(s) => Ok(&self.sections[s]),
|
||||
None if y < self.min_y => Err(0),
|
||||
None => Err(0xF0),
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
}
|
||||
/// Heap bytes held by the sections (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
|
||||
}
|
||||
/// Adds this column's heap bytes to `census` by what holds them. A
|
||||
/// section shared with another column (`share_uniform`, or a copy not yet
|
||||
/// written to) counts once: `seen` holds the sections counted so far.
|
||||
pub fn census(
|
||||
&self,
|
||||
census: &mut ColumnCensus,
|
||||
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
|
||||
) {
|
||||
census.columns += 1;
|
||||
census.sections += self.sections.len();
|
||||
census.column_bytes += std::mem::size_of::<Self>()
|
||||
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
|
||||
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
|
||||
.iter()
|
||||
.filter(|map| map.get().is_some())
|
||||
.count()
|
||||
* std::mem::size_of::<[i16; 256]>();
|
||||
for section in &self.sections {
|
||||
if !seen.insert(Arc::as_ptr(section)) {
|
||||
continue;
|
||||
}
|
||||
census.unique_sections += 1;
|
||||
// The `Arc`'s two counts and the section record.
|
||||
census.section_record_bytes +=
|
||||
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
|
||||
if section.blocks.bits() == 0 {
|
||||
census.uniform_block_sections += 1;
|
||||
}
|
||||
census.block_bytes += section.blocks.heap_bytes();
|
||||
census.biome_bytes += section.biomes.heap_bytes();
|
||||
if section.light.heap_bytes() != 0 {
|
||||
census.light_arrays += 1;
|
||||
census.light_bytes += section.light.heap_bytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
@@ -644,6 +823,10 @@ impl RolledLoot {
|
||||
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChunkExtras {
|
||||
/// Server-owned mod data attached to this chunk.
|
||||
pub attachments: horizon_world::attachments::Attachments,
|
||||
/// Attachment maps of the chunk's block entities, on a save snapshot.
|
||||
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
|
||||
/// Chunk root keys written back as loaded (structures, PostProcessing,
|
||||
/// InhabitedTime, blending data, ...).
|
||||
pub root: Compound,
|
||||
@@ -670,6 +853,33 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn each_light_form_keeps_every_value_and_run() {
|
||||
// Sky varies alone, block varies alone, both, neither.
|
||||
let cases: [(fn(usize) -> u8, &str); 4] = [
|
||||
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
|
||||
(|i| 0xA0 | (i / 256) as u8, "block"),
|
||||
(|i| (i % 251) as u8, "full"),
|
||||
(|_| 0x7C, "uniform"),
|
||||
];
|
||||
for (value, name) in cases {
|
||||
let light = Light::packed(Box::new(std::array::from_fn(value)));
|
||||
let form = match light {
|
||||
Light::Sky(..) => "sky",
|
||||
Light::Block(..) => "block",
|
||||
Light::Full(_) => "full",
|
||||
Light::Uniform(_) => "uniform",
|
||||
};
|
||||
assert_eq!(form, name);
|
||||
for at in [0, 17, 1000, 4078] {
|
||||
let mut run = [0u8; 18];
|
||||
light.copy_run(at, &mut run);
|
||||
let expected: Vec<u8> = (at..at + 18).map(value).collect();
|
||||
assert_eq!(run.as_slice(), expected, "{name} at {at}");
|
||||
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn highest_skips_only_sections_that_cannot_match() {
|
||||
let (air, cave_air, stone) = (0, 7, 1);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use crate::cit_glint::{Global, StackGlint};
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
@@ -16,7 +17,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
let props = read_properties(&root.join(&path))?;
|
||||
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
rules
|
||||
}
|
||||
|
||||
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
|
||||
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
|
||||
/// be read.
|
||||
pub fn read_properties(path: &Path) -> Option<Properties> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
Some(Properties::parse_equals_first(&text))
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
pub global: Global,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
@@ -56,6 +64,7 @@ pub struct CitRules {
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
Self {
|
||||
global: crate::cit_glint::load_global(root),
|
||||
..Self::new(load_rules(root))
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
@@ -106,6 +118,19 @@ impl CitRules {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
|
||||
/// match no stack, so vanilla's glint and the other rules of their layer stay.
|
||||
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
|
||||
self.any_item.retain(|&i| keep(i as usize));
|
||||
let named = &mut self.by_item[Kind::Enchantment as usize];
|
||||
for list in named.values_mut() {
|
||||
list.retain(|&i| keep(i as usize));
|
||||
}
|
||||
named.retain(|_, list| !list.is_empty());
|
||||
lock(&self.enchanted).clear();
|
||||
lock(&self.glints).clear();
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
@@ -123,13 +148,14 @@ impl CitRules {
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
|
||||
/// GUI), in priority order.
|
||||
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
@@ -138,7 +164,7 @@ impl CitRules {
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
@@ -147,24 +173,62 @@ impl CitRules {
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
|
||||
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
|
||||
/// pack changes no glint. `foil` is whether the stack draws a glint at all
|
||||
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
|
||||
pub fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>> {
|
||||
let no_rules =
|
||||
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
|
||||
// Before the cache, which `foil` is not part of.
|
||||
if !foil || (no_rules && self.global.use_glint) {
|
||||
return None;
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(2, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
if let Some(hit) = lock(&self.glints).get(&key) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
let matched = self.enchantments(stack, hand, max_damage);
|
||||
let glint = Arc::new(StackGlint::new(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
@@ -646,7 +710,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
@@ -659,4 +723,127 @@ mod tests {
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
|
||||
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
|
||||
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
|
||||
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
|
||||
}
|
||||
|
||||
/// `stack` with its `enchantment_glint_override` set to `on`.
|
||||
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
|
||||
let flag = (
|
||||
"minecraft:enchantment_glint_override".to_owned(),
|
||||
Tag::Byte(on.into()),
|
||||
);
|
||||
stack.data_mut().other.push(flag);
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_glint_reads_the_book_and_the_pack_file() {
|
||||
let mut cit = rules(&[
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
]);
|
||||
// An enchanted book's glint follows its stored enchantments.
|
||||
let mut book = new("enchanted_book");
|
||||
book.data_mut().stored_enchantments = vec![
|
||||
(Arc::from("minecraft:sharpness"), 3),
|
||||
(Arc::from("minecraft:mending"), 1),
|
||||
];
|
||||
let book = glint(&cit, &book, true).unwrap();
|
||||
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(weights, [0.75, 0.25]);
|
||||
assert!(book.vanilla);
|
||||
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
|
||||
assert_eq!(plain.layers.len(), 1);
|
||||
assert!(!plain.vanilla);
|
||||
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
|
||||
let mut bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
bare.global.use_glint = false;
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
cit.global.use_glint = false;
|
||||
assert!(
|
||||
glint(&cit, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stack_with_a_glint_gets_layers() {
|
||||
let cit = rules(&[("any", "type=enchantment")]);
|
||||
assert!(glint(&cit, &new("stick"), false).is_none());
|
||||
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
|
||||
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
|
||||
assert!(glint(&cit, &off, false).is_none());
|
||||
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
|
||||
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(
|
||||
weights,
|
||||
[1.0],
|
||||
"nothing to share: the rule at full strength"
|
||||
);
|
||||
// The same stack, asked again without its glint, is not answered from the cache.
|
||||
assert!(
|
||||
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_left_out_of_the_glints_matches_nothing() {
|
||||
let mut cit = rules(&[
|
||||
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
|
||||
("shown", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |cit: &CitRules| -> Vec<String> {
|
||||
let glint = glint(cit, &sword, false).unwrap();
|
||||
let stems = glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(cit, Some(l.rule)).unwrap());
|
||||
stems.map(str::to_owned).collect()
|
||||
};
|
||||
// The heavier rule wins the layer, until its texture is missing.
|
||||
assert_eq!(layers(&cit), ["broken"]);
|
||||
let broken = (0..cit.rules.len())
|
||||
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
|
||||
.unwrap();
|
||||
cit.keep_glints(|i| i != broken);
|
||||
assert_eq!(layers(&cit), ["shown"]);
|
||||
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
|
||||
// None left: vanilla's glint stays.
|
||||
cit.keep_glints(|_| false);
|
||||
assert!(glint(&cit, &sword, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_hand_layer_draws_only_in_the_off_hand() {
|
||||
let cit = rules(&[
|
||||
("left", "type=enchantment\nlayer=1\nhand=off"),
|
||||
("both", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |hand| -> Vec<&str> {
|
||||
let glint = cit.glint(&sword, hand, 0, true).unwrap();
|
||||
glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(&cit, Some(l.rule)).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(layers(Hand::Off), ["both", "left"]);
|
||||
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
|
||||
assert_eq!(layers(Hand::Main), ["both"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
|
||||
//! names its textures after the armour texture files of before 1.21.2
|
||||
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
|
||||
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
|
||||
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
|
||||
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
|
||||
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
|
||||
|
||||
use crate::cit_assets::{rule_sprites, sprite_id};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
|
||||
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
|
||||
/// overlay (`leather_overlay`) or the main texture.
|
||||
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
|
||||
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
|
||||
// The equipment assets renamed from their old textures; the rest kept their names.
|
||||
let old = match path {
|
||||
"turtle_scute" => "turtle",
|
||||
other => other,
|
||||
};
|
||||
let layer = if leggings { 2 } else { 1 };
|
||||
let suffix = if overlay { "_overlay" } else { "" };
|
||||
format!("{old}_layer_{layer}{suffix}")
|
||||
}
|
||||
|
||||
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
|
||||
/// else its `texture`.
|
||||
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
|
||||
rule.textures
|
||||
.iter()
|
||||
.find(|(name, _)| name == layer)
|
||||
.map(|(_, path)| path.as_str())
|
||||
.or(rule.texture.as_deref())
|
||||
}
|
||||
|
||||
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
|
||||
/// vanilla's sprites.
|
||||
fn atlas_name(resource: &str) -> Option<String> {
|
||||
let id = sprite_id(resource)?;
|
||||
Some(match id.strip_prefix("minecraft:") {
|
||||
Some(name) => name.to_owned(),
|
||||
None => id,
|
||||
})
|
||||
}
|
||||
|
||||
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
|
||||
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
|
||||
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
|
||||
atlas_name(armor_texture(
|
||||
rule,
|
||||
&armor_layer_name(asset, leggings, overlay),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The atlas sprite an elytra rule puts on the wings.
|
||||
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
|
||||
atlas_name(armor_texture(rule, "elytra")?)
|
||||
}
|
||||
|
||||
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
|
||||
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
|
||||
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
|
||||
/// a name the atlas lacks; nothing is allocated without a rule.
|
||||
pub fn worn_layer_sprite(
|
||||
rule: Option<&Rule>,
|
||||
asset: &str,
|
||||
kind: &str,
|
||||
vanilla: &str,
|
||||
) -> Option<String> {
|
||||
let leggings = kind == "humanoid_leggings";
|
||||
let overlay = vanilla.ends_with("_overlay");
|
||||
layer_sprite(rule?, asset, leggings, overlay)
|
||||
}
|
||||
|
||||
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
|
||||
/// cape, else the equipment asset's own wings.
|
||||
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
|
||||
elytra_sprite(rule?)
|
||||
}
|
||||
|
||||
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
|
||||
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
|
||||
pub fn worn_layer<R>(
|
||||
rule: Option<&Rule>,
|
||||
asset: &str,
|
||||
kind: &str,
|
||||
vanilla: &str,
|
||||
get: impl Fn(&str) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
worn_layer_sprite(rule, asset, kind, vanilla)
|
||||
.and_then(|name| get(&name))
|
||||
.or_else(|| get(vanilla))
|
||||
}
|
||||
|
||||
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
|
||||
/// them to the equipment asset's own wings.
|
||||
pub fn wings<R>(
|
||||
rule: Option<&Rule>,
|
||||
cape: Option<&str>,
|
||||
get: impl Fn(&str) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
wings_sprite(rule)
|
||||
.and_then(|name| get(&name))
|
||||
.or_else(|| cape.and_then(&get))
|
||||
}
|
||||
|
||||
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
|
||||
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
fn rule(path: &str, text: &str) -> Rule {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_take_their_old_texture_names() {
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:diamond", false, false),
|
||||
"diamond_layer_1"
|
||||
);
|
||||
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:leather", false, true),
|
||||
"leather_layer_1_overlay"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:leather", true, true),
|
||||
"leather_layer_2_overlay"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:gold", false, false),
|
||||
"gold_layer_1"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:chainmail", true, false),
|
||||
"chainmail_layer_2"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:turtle_scute", false, false),
|
||||
"turtle_layer_1"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:netherite", true, false),
|
||||
"netherite_layer_2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_layer_wins_over_the_generic_texture() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/armor/blue.properties",
|
||||
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
|
||||
texture.diamond_layer_2=blue_legs\n",
|
||||
);
|
||||
let dir = "assets/minecraft/optifine/cit/armor";
|
||||
assert_eq!(
|
||||
armor_texture(&r, "diamond_layer_2"),
|
||||
Some(format!("{dir}/blue_legs.png").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
armor_texture(&r, "diamond_layer_1"),
|
||||
Some(format!("{dir}/generic.png").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_generic_texture_other_layers_stay_vanilla() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/a.properties",
|
||||
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/dyed")
|
||||
);
|
||||
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
|
||||
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_texture_path_keeps_its_atlas_name() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
|
||||
);
|
||||
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worn_layers_pick_the_rules_texture_for_their_layer() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/dye.properties",
|
||||
"type=armor\nitems=leather_chestplate leather_leggings\n\
|
||||
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
|
||||
texture.leather_layer_2=legs\n",
|
||||
);
|
||||
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
|
||||
let pick = |kind: &str, vanilla: &str| {
|
||||
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
|
||||
};
|
||||
let body = "entity/equipment/humanoid/leather";
|
||||
assert_eq!(pick("humanoid", body), cit("body"));
|
||||
assert_eq!(
|
||||
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
|
||||
cit("body_dye")
|
||||
);
|
||||
assert_eq!(
|
||||
pick(
|
||||
"humanoid_leggings",
|
||||
"entity/equipment/humanoid_leggings/leather"
|
||||
),
|
||||
cit("legs")
|
||||
);
|
||||
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
|
||||
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
|
||||
assert_eq!(pick("humanoid_leggings", legs_dye), None);
|
||||
// No rule draws vanilla's.
|
||||
assert_eq!(
|
||||
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wings_take_the_rules_texture() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=wings\n",
|
||||
);
|
||||
assert_eq!(
|
||||
wings_sprite(Some(&r)).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/wings")
|
||||
);
|
||||
assert_eq!(wings_sprite(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_armor_and_elytra_rules_reach_the_atlas() {
|
||||
let rules = [
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/sword.properties",
|
||||
"items=diamond_sword\ntexture=sword\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/plate.properties",
|
||||
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
|
||||
texture.iron_layer_2=legs\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=wings\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/glint.properties",
|
||||
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
|
||||
),
|
||||
];
|
||||
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"horizon_cit:minecraft/optifine/cit/legs",
|
||||
"horizon_cit:minecraft/optifine/cit/plate",
|
||||
"horizon_cit:minecraft/optifine/cit/wings",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An atlas lookup that knows `names`.
|
||||
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
|
||||
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
|
||||
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
|
||||
let iron = rule(
|
||||
"assets/minecraft/optifine/cit/iron.properties",
|
||||
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
|
||||
);
|
||||
let plate = "horizon_cit:minecraft/optifine/cit/plate";
|
||||
let vanilla = "entity/equipment/humanoid/iron";
|
||||
let layer = |rule: Option<&Rule>, names: &[&str]| {
|
||||
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
|
||||
};
|
||||
assert_eq!(
|
||||
layer(Some(&iron), &[plate, vanilla]).as_deref(),
|
||||
Some(plate)
|
||||
);
|
||||
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
|
||||
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
|
||||
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
|
||||
let elytra = rule(
|
||||
"assets/minecraft/optifine/cit/w.properties",
|
||||
"type=elytra\ntexture=wings",
|
||||
);
|
||||
let w = "horizon_cit:minecraft/optifine/cit/wings";
|
||||
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
|
||||
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
|
||||
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
|
||||
assert_eq!(wings(None, None, atlas(&[w])), None);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@
|
||||
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
|
||||
//! `models/`) keeps its vanilla id, so it is not packed twice.
|
||||
|
||||
use horizon_assets::model::{CuboidModel, TextureSlot};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// The namespace of CIT files outside `textures/` and `models/`.
|
||||
pub const NAMESPACE: &str = "horizon_cit";
|
||||
@@ -55,12 +58,11 @@ pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
|
||||
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
|
||||
/// a warning.
|
||||
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
|
||||
/// values. A path no id can name is skipped with a warning.
|
||||
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
|
||||
for rule in rules.iter().filter(|r| keep(r.kind)) {
|
||||
let named = rule.textures.iter().map(|(_, path)| path);
|
||||
for path in rule.texture.iter().chain(named) {
|
||||
match sprite_id(path) {
|
||||
@@ -77,6 +79,122 @@ pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
|
||||
/// JSON models are [`model_sprites`].
|
||||
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
rule_sprites(rules, |kind| kind == Kind::Item)
|
||||
}
|
||||
|
||||
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
|
||||
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
|
||||
/// that would leave the root.
|
||||
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
|
||||
let mut rest = value
|
||||
.strip_prefix("./")
|
||||
.or_else(|| value.starts_with("../").then_some(value))?;
|
||||
let mut dir = file.rsplit_once('/')?.0;
|
||||
while let Some(up) = rest.strip_prefix("../") {
|
||||
dir = dir.rsplit_once('/')?.0;
|
||||
rest = up;
|
||||
}
|
||||
let rest = rest
|
||||
.strip_suffix(ext)
|
||||
.and_then(|r| r.strip_suffix('.'))
|
||||
.unwrap_or(rest);
|
||||
let path = format!("{dir}/{rest}.{ext}");
|
||||
horizon_optifine::paths::valid(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
|
||||
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
|
||||
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
|
||||
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
|
||||
let id = if ext == "png" {
|
||||
sprite_id(&path)
|
||||
} else {
|
||||
model_id(&path)
|
||||
}?;
|
||||
Some((id, path))
|
||||
}
|
||||
|
||||
/// Parents followed from a rule's model before the rest are left to the library.
|
||||
const MAX_PARENTS: usize = 16;
|
||||
|
||||
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
|
||||
/// with the parents they reach by relative path. Their `./x` texture and parent values become
|
||||
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
|
||||
/// library already. A file that does not read or parse is left out, and a rule's layer that
|
||||
/// names it (or a model whose parents reach it) keeps its vanilla model.
|
||||
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
|
||||
let named = rule.models.iter().map(|(_, path)| path);
|
||||
for path in rule.model.iter().chain(named) {
|
||||
load_model(root, path, 0, &mut seen, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn load_model(
|
||||
root: &Path,
|
||||
resource: &str,
|
||||
depth: usize,
|
||||
seen: &mut BTreeSet<String>,
|
||||
out: &mut BTreeMap<String, CuboidModel>,
|
||||
) {
|
||||
let Some(id) = model_id(resource) else {
|
||||
log::warn!("custom item textures: {resource} is not a valid model id");
|
||||
return;
|
||||
};
|
||||
let ours = id
|
||||
.strip_prefix(NAMESPACE)
|
||||
.is_some_and(|r| r.starts_with(':'));
|
||||
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
|
||||
return;
|
||||
}
|
||||
let model = fs::read_to_string(root.join(resource))
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
|
||||
let mut model = match model {
|
||||
Ok(model) => model,
|
||||
Err(e) => {
|
||||
log::warn!("custom item textures: skipped model {resource}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for slot in model.textures.values_mut() {
|
||||
if let TextureSlot::Material(m) = slot
|
||||
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
|
||||
{
|
||||
m.sprite = id;
|
||||
}
|
||||
}
|
||||
let parent = model
|
||||
.parent
|
||||
.as_deref()
|
||||
.and_then(|p| rebase(resource, p, "json"));
|
||||
if let Some((id, path)) = parent {
|
||||
model.parent = Some(id);
|
||||
load_model(root, &path, depth + 1, seen, out);
|
||||
}
|
||||
out.insert(id, model);
|
||||
}
|
||||
|
||||
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
|
||||
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
|
||||
let prefix = format!("{NAMESPACE}:");
|
||||
models
|
||||
.values()
|
||||
.flat_map(|m| m.textures.values())
|
||||
.filter_map(|slot| match slot {
|
||||
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -161,4 +279,64 @@ mod tests {
|
||||
root.join("assets/minecraft/textures/item/stick.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_models_load_with_their_relative_textures_and_parents() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
fs::create_dir_all(cit.join("swords")).unwrap();
|
||||
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
|
||||
write(
|
||||
"swords/flame.json",
|
||||
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
|
||||
);
|
||||
write(
|
||||
"base.json",
|
||||
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
|
||||
);
|
||||
let props =
|
||||
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
|
||||
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
|
||||
root.join(p).is_file()
|
||||
})
|
||||
.unwrap();
|
||||
let models = load_models(&root, &[rule]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
|
||||
assert_eq!(
|
||||
models.keys().cloned().collect::<Vec<_>>(),
|
||||
[cit("base"), cit("swords/flame")]
|
||||
);
|
||||
let flame = &models[&cit("swords/flame")];
|
||||
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
|
||||
assert_eq!(
|
||||
models[&cit("base")].parent.as_deref(),
|
||||
Some("minecraft:item/handheld")
|
||||
);
|
||||
assert_eq!(
|
||||
model_sprites(&models).into_iter().collect::<Vec<_>>(),
|
||||
[cit("glow"), cit("swords/flame")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_values_stay_in_the_root() {
|
||||
let file = "assets/minecraft/optifine/cit/a/m.json";
|
||||
let at = |v: &str| relative(file, v, "png");
|
||||
assert_eq!(
|
||||
at("./x").as_deref(),
|
||||
Some("assets/minecraft/optifine/cit/a/x.png")
|
||||
);
|
||||
assert_eq!(
|
||||
at("./../x.png").as_deref(),
|
||||
Some("assets/minecraft/optifine/cit/x.png")
|
||||
);
|
||||
assert_eq!(
|
||||
at("../../x").as_deref(),
|
||||
Some("assets/minecraft/optifine/x.png")
|
||||
);
|
||||
assert_eq!(at("item/x"), None);
|
||||
assert_eq!(at("./../../../../../x"), None);
|
||||
assert_eq!(at("./a/../x"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
|
||||
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
|
||||
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
|
||||
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
|
||||
//! cycle's fading follow it, except where noted.
|
||||
|
||||
pub use horizon_optifine::cit::{Global, Method};
|
||||
|
||||
use crate::cit::read_properties;
|
||||
use horizon_optifine::cit::Rule;
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's global `cit.properties` under the overlay resource root: the first of
|
||||
/// `Global::PATHS` that exists, else the defaults.
|
||||
pub fn load_global(root: &Path) -> Global {
|
||||
Global::PATHS
|
||||
.iter()
|
||||
.find_map(|path| read_properties(&root.join(path)))
|
||||
.map(|props| Global::parse(&props))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One glint layer a stack draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GlintLayer {
|
||||
/// The rule, an index into `CitRules::rules`.
|
||||
pub rule: usize,
|
||||
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
|
||||
pub weight: f32,
|
||||
/// Its `duration`, for `cycle`.
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
/// What a stack's glint is made of, worked out when the stack changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackGlint {
|
||||
/// Bottom first (`layer` ascending).
|
||||
pub layers: Vec<GlintLayer>,
|
||||
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
|
||||
pub vanilla: bool,
|
||||
/// `fade` when the layers take turns (`method=cycle`).
|
||||
cycle_fade: Option<f32>,
|
||||
}
|
||||
|
||||
impl StackGlint {
|
||||
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
|
||||
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn new(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> Self {
|
||||
// The highest priority rule of each layer.
|
||||
let mut layers: Vec<usize> = Vec::new();
|
||||
for &i in matched {
|
||||
let layer = rules[i].glint.layer;
|
||||
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
|
||||
layers.push(i);
|
||||
}
|
||||
}
|
||||
layers.sort_by_key(|&i| rules[i].glint.layer);
|
||||
let cycle = global.method == Method::Cycle;
|
||||
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
|
||||
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
|
||||
// the bottom layers, though, and caps cycles as well).
|
||||
if let Some(cap) = global.cap.filter(|_| !cycle) {
|
||||
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
|
||||
layers = layers.split_off(layers.len().saturating_sub(cap));
|
||||
}
|
||||
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
|
||||
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
|
||||
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
|
||||
let vanilla = global.use_glint
|
||||
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
|
||||
// CIT Resewn: shares are against every enchantment of the stack, not only those the
|
||||
// layers name.
|
||||
let levels = enchantments.iter().map(|(_, level)| *level as f32);
|
||||
let total = match global.method {
|
||||
Method::Average => levels.sum::<f32>(),
|
||||
Method::Layered => levels.fold(0.0, f32::max),
|
||||
Method::None | Method::Cycle => 0.0,
|
||||
};
|
||||
let shared = matches!(global.method, Method::Average | Method::Layered);
|
||||
let layers = layers
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let rule = &rules[i];
|
||||
let named = &rule.enchantments;
|
||||
let weight = if !shared || named.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
// The highest level among the enchantments the rule names whose level also
|
||||
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
|
||||
// stack has, whatever its level).
|
||||
let in_range = |level: u32| {
|
||||
let level = i32::try_from(level).unwrap_or(i32::MAX);
|
||||
rule.enchantment_levels
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.contains(level))
|
||||
};
|
||||
let level = enchantments
|
||||
.iter()
|
||||
.filter(|(id, level)| {
|
||||
let id: &str = id.as_ref();
|
||||
named.iter().any(|n| n == id) && in_range(*level)
|
||||
})
|
||||
.map(|(_, level)| *level as f32)
|
||||
.fold(0.0, f32::max);
|
||||
if total > 0.0 { level / total } else { 0.0 }
|
||||
};
|
||||
GlintLayer {
|
||||
rule: i,
|
||||
weight,
|
||||
duration: rule.glint.duration,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
layers,
|
||||
vanilla,
|
||||
cycle_fade: cycle.then_some(global.fade),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.layers.is_empty()
|
||||
}
|
||||
|
||||
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
|
||||
/// its turn.
|
||||
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
|
||||
let layer = self.layers[index];
|
||||
match self.cycle_fade {
|
||||
None => layer.weight,
|
||||
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
|
||||
/// fades into the next over `fade` seconds, the last into the first. With no time at all
|
||||
/// (every `duration` and `fade` 0) the bottom layer shows alone.
|
||||
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
|
||||
let n = layers.len();
|
||||
let fade = f64::from(fade);
|
||||
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
|
||||
if n == 1 || period <= 0.0 {
|
||||
return if index == 0 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let mut t = seconds.rem_euclid(period);
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let duration = f64::from(layer.duration);
|
||||
if t < duration {
|
||||
return if i == index { 1.0 } else { 0.0 };
|
||||
}
|
||||
t -= duration;
|
||||
// Defensive: the last fade also takes anything rounding might leave of the period
|
||||
// (none seen), ending fully on the first layer.
|
||||
if t < fade || i + 1 == n {
|
||||
let into = if fade > 0.0 {
|
||||
(t / fade).min(1.0) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
return if i == index {
|
||||
1.0 - into
|
||||
} else if (i + 1) % n == index {
|
||||
into
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
t -= fade;
|
||||
}
|
||||
// Not reached, as the last layer returns above; no layer rather than a panic.
|
||||
0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
const DIR: &str = "assets/minecraft/optifine/cit";
|
||||
|
||||
/// An enchantment rule named `name` with `text` after `type` and `texture`.
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let text = format!("type=enchantment\ntexture=glint\n{text}");
|
||||
let path = format!("{DIR}/{name}.properties");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
fn global(text: &str) -> Global {
|
||||
Global::parse(&Properties::parse_equals_first(text))
|
||||
}
|
||||
|
||||
fn rules_of(layers: &StackGlint) -> Vec<usize> {
|
||||
layers.layers.iter().map(|l| l.rule).collect()
|
||||
}
|
||||
|
||||
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
|
||||
|
||||
#[test]
|
||||
fn one_rule_per_layer_bottom_first() {
|
||||
let rules = [
|
||||
rule("a", "layer=2"),
|
||||
rule("b", "layer=0"),
|
||||
rule("c", "layer=2"),
|
||||
rule("d", "layer=-1"),
|
||||
];
|
||||
// Priority order a, b, c, d: c loses layer 2 to a.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_layered_shares() {
|
||||
let rules = [
|
||||
rule("sharp", "enchantments=sharpness smite"),
|
||||
rule("unb", "layer=1\nenchantments=unbreaking"),
|
||||
rule("any", "layer=2"),
|
||||
rule("fire", "layer=3\nenchantments=fire_aspect"),
|
||||
];
|
||||
let weights = |method: &str| {
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
|
||||
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
|
||||
};
|
||||
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
|
||||
// A rule naming no enchantment is at full strength, one the stack lacks at none.
|
||||
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=cycle"), [1.0; 4]);
|
||||
assert_eq!(weights("method=none"), [1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_is_one_the_rules_levels_allow() {
|
||||
let rules = [rule(
|
||||
"holy",
|
||||
"enchantments=sharpness smite\nenchantmentLevels=1-2",
|
||||
)];
|
||||
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
|
||||
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
|
||||
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
|
||||
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_keeps_the_topmost_layers() {
|
||||
let rules = [
|
||||
rule("a", "layer=0"),
|
||||
rule("b", "layer=1"),
|
||||
rule("c", "layer=2"),
|
||||
];
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
|
||||
assert_eq!(rules_of(&g), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_glint() {
|
||||
let plain = rule("plain", "");
|
||||
let keep = rule("keep", "layer=1\nuseGlint=true");
|
||||
let rules = [plain, keep];
|
||||
let vanilla = |matched: &[usize], text: &str| {
|
||||
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
|
||||
};
|
||||
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
|
||||
assert!(!vanilla(&[0], ""), "a rule replaces it");
|
||||
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
|
||||
assert!(!vanilla(&[], "useGlint=false"));
|
||||
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
|
||||
// Only the layers `cap` keeps count.
|
||||
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
|
||||
assert!(!g.vanilla);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_take_turns_and_fade() {
|
||||
let rules = [
|
||||
rule("a", "duration=2"),
|
||||
rule("b", "layer=1\nduration=1"),
|
||||
rule("c", "layer=2\nduration=1"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.5"),
|
||||
);
|
||||
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
|
||||
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
|
||||
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
|
||||
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
|
||||
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
|
||||
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
|
||||
// One layer always shows.
|
||||
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
|
||||
assert_eq!(one.intensity(0, 0.7), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_always_shows_a_whole_layer() {
|
||||
let rules = [
|
||||
rule("a", "duration=0.1"),
|
||||
rule("b", "layer=1\nduration=0.7"),
|
||||
rule("c", "layer=2\nduration=0.2"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.3"),
|
||||
);
|
||||
// Every millisecond of the first two seconds, and just under a period, the last fade's
|
||||
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
|
||||
// defensive.)
|
||||
let period: f64 = [0.1f32, 0.7, 0.2]
|
||||
.map(|d| f64::from(d) + f64::from(0.3f32))
|
||||
.iter()
|
||||
.sum();
|
||||
let last = f64::from_bits(period.to_bits() - 1);
|
||||
for t in (0..2000_i32)
|
||||
.map(|i| f64::from(i) * 0.001)
|
||||
.chain([last, period + last])
|
||||
{
|
||||
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
|
||||
}
|
||||
// No time at all: the bottom layer alone, never NaN.
|
||||
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
|
||||
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
|
||||
for t in [0.0, 0.5, 7.25] {
|
||||
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_global_file_wins() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(load_global(&root), Global::default());
|
||||
let write = |path: &str, text: &[u8]| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, text).unwrap();
|
||||
};
|
||||
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
|
||||
write(
|
||||
"assets/minecraft/optifine/cit/cit.properties",
|
||||
b"# caf\xE9\nmethod=cycle",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write(
|
||||
"assets/minecraft/optifine/cit.properties",
|
||||
b"method=layered",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Layered);
|
||||
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
|
||||
let g = load_global(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub enum Pick {
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// A tile drawn over a face, after its own (`overlay*` methods).
|
||||
@@ -387,7 +389,8 @@ impl ConnectedTextures {
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
|
||||
/// with `orient=texture`.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
@@ -396,6 +399,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
texture: impl Fn() -> Option<Axes>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
@@ -427,9 +431,58 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) =
|
||||
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
|
||||
&& let Some(pick) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
};
|
||||
if picked == layer {
|
||||
return pick;
|
||||
}
|
||||
let own = self.list(&self.by_state, state as usize);
|
||||
// Rules tried on the face's sprite already (those before this one) are not tried
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
j != i
|
||||
&& !(j < i && first.contains(&j))
|
||||
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
|
||||
});
|
||||
for (_, r) in again {
|
||||
// Joins by tile still compare the face's own sprite with its neighbours'.
|
||||
match self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
@@ -447,6 +500,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
@@ -487,7 +541,12 @@ impl ConnectedTextures {
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
// The quad's own texture directions, whatever face `symmetry` stands it for.
|
||||
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
|
||||
// the standard way.
|
||||
(Orient::Texture, _) if rule.method != Method::Overlay => {
|
||||
texture().unwrap_or(Axes::cube(face))
|
||||
}
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
@@ -649,6 +708,10 @@ mod tests {
|
||||
|name| match name {
|
||||
"minecraft:block/stone" => Some(7),
|
||||
"minecraft:block/glass" => Some(3),
|
||||
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -671,7 +734,7 @@ mod tests {
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
@@ -794,8 +857,26 @@ mod tests {
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
|
||||
let lower = ctm.pick(
|
||||
5,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 64, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
let upper = ctm.pick(
|
||||
6,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 65, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
@@ -809,7 +890,16 @@ mod tests {
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at = |y, biome: &'static str| {
|
||||
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::Up,
|
||||
[0, y, 0],
|
||||
|_| 0,
|
||||
|| Some(biome),
|
||||
|| None,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
@@ -844,7 +934,7 @@ mod tests {
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
@@ -925,6 +1015,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
at,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| out.push(o.layer),
|
||||
);
|
||||
(out, pick)
|
||||
@@ -962,6 +1053,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
|o| if o == [0, 0, -1] { 1 } else { 0 },
|
||||
|| None,
|
||||
|| None,
|
||||
|o| {
|
||||
tint = Some((o.tint, o.translucent));
|
||||
},
|
||||
@@ -999,6 +1091,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
door_north,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| got.push(o),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1039,6 +1132,99 @@ mod tests {
|
||||
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_tile_can_be_picked_again() {
|
||||
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
|
||||
// names that tile, so it draws in b0's place.
|
||||
let chain = |t: &str| {
|
||||
world(&[
|
||||
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
|
||||
("t.properties", t),
|
||||
])
|
||||
};
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
|
||||
// A chained rule keeping the tile (<default>) leaves b's.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// One that names another block doesn't chain on glass.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// An overlay tried on the face's own sprite and on the picked tile draws once.
|
||||
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
|
||||
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
|
||||
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
|
||||
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
|
||||
// tried: it chains.
|
||||
let ctm = world(&[
|
||||
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
|
||||
(
|
||||
"c.properties",
|
||||
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
world(&[(
|
||||
"glass.properties",
|
||||
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
|
||||
)])
|
||||
};
|
||||
// A mirrored north face (a vanilla pane's): its texture's right is east.
|
||||
let mirrored = || {
|
||||
Some(Axes {
|
||||
right: [1, 0, 0],
|
||||
up: [0, 1, 0],
|
||||
})
|
||||
};
|
||||
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
|
||||
let at = |ctm: &ConnectedTextures| {
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::North,
|
||||
[0, 64, 0],
|
||||
east,
|
||||
|| None,
|
||||
mirrored,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
// Glass to the east is to the texture's right: the row's left end.
|
||||
assert_eq!(at(&rule("texture")), tile(1000));
|
||||
// By the cube's directions it is to the left: the row's right end.
|
||||
assert_eq!(at(&rule("none")), tile(1002));
|
||||
// A plain overlay keeps the standard directions: glass north of a top face is above it
|
||||
// (tile 15) whatever the quad's texture says.
|
||||
let overlay = world(&[(
|
||||
"a.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
|
||||
)]);
|
||||
let turned = || {
|
||||
Some(Axes {
|
||||
right: [0, 0, 1],
|
||||
up: [1, 0, 0],
|
||||
})
|
||||
};
|
||||
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
|
||||
let mut got = Vec::new();
|
||||
overlay.pick(
|
||||
2,
|
||||
7,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
north,
|
||||
|| None,
|
||||
turned,
|
||||
|o| got.push(o.layer),
|
||||
);
|
||||
assert_eq!(got, [1015]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_panes_hide_the_strips_they_share() {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
|
||||
@@ -389,6 +389,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:jukebox" {
|
||||
return Some(Self::Jukebox(Jukebox::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
@@ -729,6 +732,20 @@ mod tests {
|
||||
assert_eq!(entity.contents().len(), 1);
|
||||
}
|
||||
|
||||
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
|
||||
/// so the item's block entity data lands somewhere and the box can hold a
|
||||
/// disc without a player putting one in.
|
||||
#[test]
|
||||
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
|
||||
assert_eq!(entity.id(), "minecraft:jukebox");
|
||||
assert!(matches!(entity, BlockEntity::Jukebox(_)));
|
||||
assert_eq!(entity.slots().len(), 1);
|
||||
assert!(entity.slots()[0].is_none());
|
||||
// Another block's name is not a jukebox.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
|
||||
/// `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -69,6 +72,8 @@ struct Out {
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
|
||||
@location(14) scene: f32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
|
||||
fn hdr_scene(block: f32, sky: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
@@ -328,8 +344,16 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
if in.tier == 0u || texel.a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
|
||||
if texel.a < 0.1 { discard; }
|
||||
// Fog hides a light source and the light on a surface as it hides everything else.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
// A layer that fades (the warden's) shines by what's left of it.
|
||||
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
@@ -348,7 +372,15 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
// no longer shines; fs_hdr_mark then marks the light sources it holds.
|
||||
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 1.0);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
|
||||
// sky, so they take its value, which their blending would otherwise cover.
|
||||
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
|
||||
if a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@@ -375,8 +407,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
let added = color.rgb * color.a;
|
||||
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
|
||||
// none (the End sky, the End flash) stays brightest.
|
||||
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
|
||||
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
let tier = select(in.tier, 4u, in.tier == 0u);
|
||||
let y = brightness(added);
|
||||
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
|
||||
// or it would come out darker than the sky around it.
|
||||
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
|
||||
@@ -120,10 +120,24 @@ pub struct Extras {
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
/// 2 top and bottom (`splitscreenTwo`).
|
||||
pub splitscreen_two: u8,
|
||||
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
|
||||
/// (`splitscreenThree`).
|
||||
pub splitscreen_three: u8,
|
||||
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
|
||||
pub splitscreen_names: bool,
|
||||
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
|
||||
/// on by default as vanilla); off, the wheel only scrolls the list.
|
||||
pub wheel_cycles_settings: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -160,8 +174,13 @@ impl Default for Extras {
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
wheel_cycles_settings: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,8 +259,13 @@ impl Extras {
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
|
||||
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +273,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -287,8 +311,13 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
bool_text(self.splitscreen_names),
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -402,6 +431,10 @@ impl Cps {
|
||||
self.clicks.len()
|
||||
}
|
||||
}
|
||||
/// Two players sharing a window (`splitscreen_two`): halves this way.
|
||||
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
|
||||
/// Three players sharing a window (`splitscreen_three`).
|
||||
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
|
||||
|
||||
/// The settings the Extras page shows, one per row.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -425,8 +458,11 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -451,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -469,10 +507,12 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -490,10 +530,12 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -522,8 +564,10 @@ impl Item {
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -535,8 +579,8 @@ impl Item {
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -588,6 +632,12 @@ impl Item {
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -603,7 +653,8 @@ impl Item {
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -623,8 +674,10 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -655,8 +708,10 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -680,7 +735,7 @@ mod tests {
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(d.discord_presence);
|
||||
assert!(d.discord_presence && d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -721,6 +776,8 @@ mod tests {
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
Item::WheelCyclesSettings.cycle(&mut e, false);
|
||||
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -947,6 +947,16 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence a component built
|
||||
/// with `FormattedCharSequence.forward`: every character is a glyph, a
|
||||
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
|
||||
pub fn width_drawn(&self, t: &Text) -> f32 {
|
||||
crate::bidi::visual(t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
|
||||
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
|
||||
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
|
||||
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
|
||||
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
|
||||
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
|
||||
//!
|
||||
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
|
||||
//! turned about x, or turned with `uvlock`, join a quarter turn off.
|
||||
//!
|
||||
//! The format is `horizon_optifine::fusion`'s.
|
||||
|
||||
use crate::connected::{Overlay, Pick, TileLayer};
|
||||
use crate::models::StateModels;
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::cem::json::Json;
|
||||
use horizon_optifine::ctm::connect::{self, Axes, Face};
|
||||
use horizon_optifine::fusion::{
|
||||
Connection, Layout, Tiles,
|
||||
model::{Connections, ModelType},
|
||||
predicate::Query,
|
||||
texture::{Kind, Meta},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
layout: Layout,
|
||||
tiles: Vec<Option<TileLayer>>,
|
||||
}
|
||||
|
||||
/// A sheet and what it joins by, blocks as sorted state ids.
|
||||
struct Rule {
|
||||
sheet: usize,
|
||||
connection: Connection<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FusionTextures {
|
||||
sheets: Vec<Sheet>,
|
||||
/// By [`crate::models::BakedFace::fusion`].
|
||||
rules: Vec<Rule>,
|
||||
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
|
||||
blocks: Vec<u32>,
|
||||
}
|
||||
|
||||
/// A model's reference to a texture or another model (`block/glass`), namespaced.
|
||||
fn namespaced(reference: &str) -> String {
|
||||
horizon_optifine::matchers::namespaced(reference)
|
||||
}
|
||||
|
||||
fn read_model(root: &Path, name: &str) -> Option<Json> {
|
||||
let (ns, path) = name.split_once(':')?;
|
||||
let text = fs::read_to_string(root.join(format!("assets/{ns}/models/{path}.json"))).ok()?;
|
||||
Json::parse(&text).ok()
|
||||
}
|
||||
|
||||
/// What a model's face draws and joins by: its sprite, its side in the model, and its
|
||||
/// predicates (`None`: the texture's own).
|
||||
type ModelFace = (String, Face, Option<Connection>);
|
||||
|
||||
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
|
||||
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
|
||||
let Some(model) = read_model(root, name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if ModelType::of(&model) != Some(ModelType::Connecting) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
|
||||
// levels is a loop.
|
||||
let mut chain = vec![model];
|
||||
while chain.len() < 16
|
||||
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
|
||||
&& let Some(parent) = read_model(root, &namespaced(parent))
|
||||
{
|
||||
chain.push(parent);
|
||||
}
|
||||
// Parents first, so a child's keys replace theirs.
|
||||
let mut connections = Connections::Textures;
|
||||
let mut textures: HashMap<&str, &str> = HashMap::new();
|
||||
for model in chain.iter().rev() {
|
||||
match Connections::of(model) {
|
||||
Ok(own) => connections = own.inherit(&connections),
|
||||
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
|
||||
}
|
||||
for (key, value) in model
|
||||
.get("textures")
|
||||
.and_then(Json::as_object)
|
||||
.unwrap_or(&[])
|
||||
{
|
||||
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
|
||||
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
|
||||
if let Some(value) = value {
|
||||
textures.insert(key.as_str(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `#key` as vanilla resolves it.
|
||||
let resolve = |reference: &str| {
|
||||
let mut value = reference;
|
||||
for _ in 0..16 {
|
||||
match value.strip_prefix('#') {
|
||||
Some(key) => value = textures.get(key)?,
|
||||
None => return Some(namespaced(value)),
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
// The elements are the nearest model's that has some.
|
||||
let elements = chain
|
||||
.iter()
|
||||
.find_map(|m| m.get("elements")?.as_array())
|
||||
.unwrap_or(&[]);
|
||||
let mut out: Vec<ModelFace> = Vec::new();
|
||||
let faces = elements
|
||||
.iter()
|
||||
.filter_map(|e| e.get("faces")?.as_object())
|
||||
.flatten();
|
||||
for (side, face) in faces {
|
||||
let side = match side.as_str() {
|
||||
"down" => Face::Down,
|
||||
"up" => Face::Up,
|
||||
"north" => Face::North,
|
||||
"south" => Face::South,
|
||||
"west" => Face::West,
|
||||
"east" => Face::East,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(sprite) = resolve(texture) else {
|
||||
continue;
|
||||
};
|
||||
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
|
||||
let key = face.get("connections").and_then(Json::as_str);
|
||||
out.push((sprite, side, connections.for_face(key, texture).cloned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
|
||||
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
|
||||
let mut n = face.normal();
|
||||
for _ in 0..x % 4 {
|
||||
// x=90 takes up to north.
|
||||
n = [n[0], n[2], -n[1]];
|
||||
}
|
||||
for _ in 0..y % 4 {
|
||||
// y=90 takes north to east.
|
||||
n = [-n[2], n[1], n[0]];
|
||||
}
|
||||
Face::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.normal() == n)
|
||||
.expect("a quarter turn maps faces onto faces")
|
||||
}
|
||||
|
||||
/// The connecting textures under `root`, by sprite name.
|
||||
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
|
||||
fn sheet(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
name: &str,
|
||||
meta: Meta,
|
||||
) -> Option<(Sheet, Connection)> {
|
||||
let Kind::Connecting {
|
||||
layout,
|
||||
connections,
|
||||
per_tile_animation,
|
||||
..
|
||||
} = meta.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let tiles = sprites
|
||||
.insert_tiles(root, name, layout.grid(), per_tile_animation)
|
||||
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
|
||||
.ok()?;
|
||||
// A layer past the mesher's `u16` leaves the sheet to vanilla.
|
||||
let tiles = tiles
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
None => Some(None),
|
||||
Some(l) => Some(Some(TileLayer::Layer {
|
||||
layer: u16::try_from(l).ok()?,
|
||||
translucent: sprites.translucent[l as usize],
|
||||
opaque: sprites.opaque[l as usize],
|
||||
})),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((Sheet { layout, tiles }, connections))
|
||||
}
|
||||
|
||||
impl FusionTextures {
|
||||
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
|
||||
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
|
||||
/// shows one gets its rule.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
catalog: &mut StateModels,
|
||||
) -> Self {
|
||||
let mut textures = connecting_textures(root);
|
||||
if textures.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The layers models show the sheets as: only their models are read.
|
||||
let shown: std::collections::HashSet<u32> = textures
|
||||
.keys()
|
||||
.filter_map(|name| sprites.layers.get(name).copied())
|
||||
.collect();
|
||||
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
|
||||
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
|
||||
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
|
||||
let mut rules: Vec<(usize, Connection)> = Vec::new();
|
||||
for (variant, model) in catalog.models_mut() {
|
||||
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
|
||||
continue;
|
||||
}
|
||||
let found = models
|
||||
.entry(namespaced(&variant.model))
|
||||
.or_insert_with_key(|name| connecting_model(root, name));
|
||||
for face in &mut model.faces {
|
||||
let sprite = &face.quad.material.sprite;
|
||||
// The model's face on the side this one turned to, else any with its sprite.
|
||||
let side = Face::ALL[face.quad.facing as usize];
|
||||
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
|
||||
let Some((_, _, connection)) = drawing
|
||||
.clone()
|
||||
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
|
||||
.or_else(|| drawing.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The sheet, cut into tiles on first use.
|
||||
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
|
||||
let meta = textures.remove(sprite)?;
|
||||
sheets.push(sheet(root, sprites, sprite, meta)?);
|
||||
Some(sheets.len() - 1)
|
||||
});
|
||||
let Some(sheet) = sheet else {
|
||||
continue;
|
||||
};
|
||||
let connection = connection
|
||||
.clone()
|
||||
.unwrap_or_else(|| sheets[sheet].1.clone());
|
||||
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
|
||||
Some(rule) => rule,
|
||||
None => {
|
||||
rules.push((sheet, connection));
|
||||
rules.len() - 1
|
||||
}
|
||||
};
|
||||
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
|
||||
}
|
||||
}
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
|
||||
Self::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
|
||||
fn build<'a>(
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
) -> Self {
|
||||
let states: Vec<_> = states.collect();
|
||||
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
let mut names = HashMap::new();
|
||||
let blocks = (0..states.len() as u32)
|
||||
.map(|id| match states[id as usize] {
|
||||
Some((name, _)) => {
|
||||
block_states.entry(name).or_default().push(id);
|
||||
let next = names.len() as u32;
|
||||
*names.entry(name).or_insert(next)
|
||||
}
|
||||
None => u32::MAX,
|
||||
})
|
||||
.collect();
|
||||
// Block lists to the states they match.
|
||||
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
|
||||
let mut ids: Vec<u32> = list
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
let candidates = block_states
|
||||
.get(m.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
candidates.iter().copied().filter(|&id| {
|
||||
states[id as usize].is_some_and(|(name, props)| {
|
||||
m.matches(name, |k| props.get(k).map(String::as_str))
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
};
|
||||
let rules = rules
|
||||
.into_iter()
|
||||
.map(|(sheet, connection)| Rule {
|
||||
sheet,
|
||||
connection: connection.map_blocks(&mut resolve),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sheets,
|
||||
rules,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
|
||||
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
|
||||
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
|
||||
/// pieces, tinted as the quad, go to `overlay`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn pick(
|
||||
&self,
|
||||
rule: NonZeroU16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
tint: i32,
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
covers: impl Fn(u32) -> bool,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = self.rules.get(usize::from(rule.get() - 1))?;
|
||||
let normal = face.normal();
|
||||
let offsets = connect::offsets(Axes::cube(face));
|
||||
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
|
||||
let mask = rule.connection.mask(|bit, query| {
|
||||
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
|
||||
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
|
||||
match query {
|
||||
Query::SameBlock => {
|
||||
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
|
||||
}
|
||||
Query::SameState => neighbour([x, y, z]) == state,
|
||||
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
|
||||
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
|
||||
Query::FaceVisible => !covers(in_front()),
|
||||
}
|
||||
});
|
||||
let sheet = &self.sheets[rule.sheet];
|
||||
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
|
||||
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
|
||||
// never need.
|
||||
Some(match sheet.layout.tiles(mask) {
|
||||
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
|
||||
Tiles::Quarters(q) => match q.map(tile) {
|
||||
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
|
||||
_ => Pick::Nothing,
|
||||
},
|
||||
Tiles::Overlay(mask) => {
|
||||
for t in Layout::overlay_tiles(mask) {
|
||||
if let Some(TileLayer::Layer {
|
||||
layer, translucent, ..
|
||||
}) = tile(t)
|
||||
{
|
||||
overlay(Overlay {
|
||||
layer,
|
||||
translucent,
|
||||
tint: (tint >= 0).then_some((tint, None)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Pick::Nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
|
||||
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
|
||||
let (w, h) = layout.grid();
|
||||
Sheet {
|
||||
layout,
|
||||
tiles: (0..(w * h) as u16)
|
||||
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn predicate(json: &str) -> Connection {
|
||||
Connection::parse(&Json::parse(json).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn layer(layer: u16) -> TileLayer {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookups over blocks named `names` (a state each).
|
||||
fn world(
|
||||
names: &[&str],
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
) -> FusionTextures {
|
||||
let none = BlockProperties::default();
|
||||
let states = names.iter().map(|n| Some((*n, &none)));
|
||||
FusionTextures::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
|
||||
/// `opaque`; with the overlays it hands out.
|
||||
fn pick_in(
|
||||
fusion: &FusionTextures,
|
||||
rule: u16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
opaque: &[u32],
|
||||
) -> (Option<Pick>, Vec<u16>) {
|
||||
// A face's rule counts from 1.
|
||||
let rule = NonZeroU16::new(rule + 1).unwrap();
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
let mut overlays = Vec::new();
|
||||
let pick = fusion.pick(
|
||||
rule,
|
||||
state,
|
||||
face,
|
||||
-1,
|
||||
at,
|
||||
|s| opaque.contains(&s),
|
||||
|o| overlays.push(o.layer),
|
||||
);
|
||||
(pick, overlays)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_pieces_join_the_same_block() {
|
||||
// Glass twice (two states of one block), then stone.
|
||||
let fusion = world(
|
||||
&[
|
||||
"minecraft:air",
|
||||
"minecraft:glass",
|
||||
"minecraft:glass",
|
||||
"minecraft:stone",
|
||||
],
|
||||
vec![sheet(Layout::Pieced, 100, &[])],
|
||||
vec![(0, Connection::SameBlock)],
|
||||
);
|
||||
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
|
||||
// Alone: every quarter the lone tile.
|
||||
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
|
||||
// A north face's right is west: glass there (in its other state) joins the right
|
||||
// quarters, the horizontal piece. Stone doesn't.
|
||||
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
|
||||
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
|
||||
assert_eq!(
|
||||
at(&[([-1, 0, 0], 3)]),
|
||||
Some(Pick::Quarters([layer(100); 4]))
|
||||
);
|
||||
// A rule that doesn't exist draws as vanilla.
|
||||
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_reach_toward_visible_sand() {
|
||||
let sand = predicate(
|
||||
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
|
||||
);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
|
||||
vec![sheet(Layout::Overlay, 200, &[7])],
|
||||
vec![(0, sand)],
|
||||
);
|
||||
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
|
||||
// over a face that draws nothing itself.
|
||||
let north = [([0, 0, -1], 2)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
|
||||
(Some(Pick::Nothing), vec![213])
|
||||
);
|
||||
// Stone over that sand hides its top: no edge.
|
||||
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
|
||||
(Some(Pick::Nothing), vec![])
|
||||
);
|
||||
// Sand all round: the ring, (4, 1).
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
|
||||
.filter(|(o, _)| *o != [0, 0, 0])
|
||||
.collect();
|
||||
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
|
||||
// Its pieces take the quad's tint index.
|
||||
let mut tints = Vec::new();
|
||||
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
|
||||
let rule = NonZeroU16::new(1).unwrap();
|
||||
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
|
||||
assert_eq!(tints, [Some((0, None))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_front_and_empty_tiles() {
|
||||
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
|
||||
// lone tile empty.
|
||||
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
|
||||
vec![sheet(Layout::Full, 300, &[0])],
|
||||
vec![(0, front)],
|
||||
);
|
||||
// Sand on the block east of the top face: joined right, the full template's (1, 0).
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
|
||||
Some(Pick::Tile(layer(301)))
|
||||
);
|
||||
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
|
||||
Some(Pick::Nothing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_name_the_predicates_their_faces_draw() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// A pane arm over a template whose `particle` names the pane sheet but draws no face
|
||||
// with it (as Fusion Connected Glass's posts do): only the faces count.
|
||||
fs::write(
|
||||
models.join("template.json"),
|
||||
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
|
||||
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("arm.json"),
|
||||
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
|
||||
"connections": {"edge": {"type": "true"}},
|
||||
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("plain.json"),
|
||||
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let arm = connecting_model(&root, "demo:block/arm");
|
||||
let plain = connecting_model(&root, "demo:block/plain");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// `edge` has the model's predicate; `pane` none, so the texture's own.
|
||||
assert_eq!(
|
||||
arm,
|
||||
[
|
||||
(
|
||||
"demo:block/edge".to_owned(),
|
||||
Face::Up,
|
||||
Some(Connection::Const(true))
|
||||
),
|
||||
("demo:block/glass".to_owned(), Face::West, None),
|
||||
]
|
||||
);
|
||||
// Not a Fusion model: vanilla.
|
||||
assert!(plain.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_sprite_join_by_their_own_keys() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// The wiki's example: four keys on one texture, two of them joining by `blue`.
|
||||
fs::write(
|
||||
models.join("tiles.json"),
|
||||
r##"{"loader": "fusion:model", "type": "connecting",
|
||||
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
|
||||
"connections": {"blue": {"type": "match_block", "block": "ice"},
|
||||
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
|
||||
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
|
||||
"up": {"texture": "#ice", "connections": "#blue"},
|
||||
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let faces = connecting_model(&root, "demo:block/tiles");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
|
||||
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
|
||||
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
|
||||
assert_eq!(joins(Face::South), Some(Connection::SameState));
|
||||
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
|
||||
// the east.
|
||||
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
|
||||
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
|
||||
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -545,14 +545,17 @@ impl Arena {
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
}
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range.
|
||||
pub fn trim(&mut self) {
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
|
||||
/// the buffers of the pages it emptied, for a [`Graveyard`].
|
||||
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
|
||||
let mut released = Vec::new();
|
||||
for page in &mut self.pages {
|
||||
if let Some(p) = page
|
||||
&& p.free.0.len() == 1
|
||||
&& p.free.0.get(&0) == Some(&p.capacity)
|
||||
if page
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
|
||||
&& let Some(p) = page.take()
|
||||
{
|
||||
*page = None;
|
||||
released.push(p.buffer);
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
@@ -562,13 +565,67 @@ impl Arena {
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
}
|
||||
released
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
/// Drops every page; gives back their buffers, for a [`Graveyard`].
|
||||
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
|
||||
self.closing = None;
|
||||
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
|
||||
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
|
||||
/// thread holds its last reference.
|
||||
const GRAVE_FRAMES: usize = 4;
|
||||
|
||||
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
|
||||
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
|
||||
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
|
||||
pub struct Graveyard<T> {
|
||||
graves: VecDeque<Vec<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Graveyard<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
graves: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Graveyard<T> {
|
||||
/// Released this frame.
|
||||
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
|
||||
if self.graves.is_empty() {
|
||||
self.graves.push_back(Vec::new());
|
||||
}
|
||||
if let Some(grave) = self.graves.back_mut() {
|
||||
grave.extend(items);
|
||||
}
|
||||
}
|
||||
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
|
||||
pub fn advance(&mut self) -> Vec<T> {
|
||||
self.graves.push_back(Vec::new());
|
||||
if self.graves.len() > GRAVE_FRAMES {
|
||||
self.graves.pop_front().unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
|
||||
/// unloaded chunks; should the thread not start, they drop here.
|
||||
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Buffer freeing".into())
|
||||
.spawn(move || drop(items));
|
||||
}
|
||||
|
||||
/// `create_buffer` with an out-of-memory failure returned instead of raised
|
||||
/// (raised errors are fatal).
|
||||
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
|
||||
@@ -587,6 +644,24 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
|
||||
#[test]
|
||||
fn graveyard_frees_after_its_frames() {
|
||||
let mut graves = Graveyard::default();
|
||||
graves.add([1, 2]);
|
||||
let mut freed = Vec::new();
|
||||
for frame in 0..GRAVE_FRAMES + 2 {
|
||||
if frame == 1 {
|
||||
graves.add([3]);
|
||||
}
|
||||
freed.push(graves.advance());
|
||||
}
|
||||
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
|
||||
expected[GRAVE_FRAMES - 1] = vec![1, 2];
|
||||
expected[GRAVE_FRAMES] = vec![3];
|
||||
assert_eq!(freed, expected);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
device_with(wgpu::Features::empty())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
|
||||
//! model's parts and pushes every vertex of every polygon, transformed by its
|
||||
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
|
||||
//! and uploaded each frame. Here each model's vertices are uploaded once, in
|
||||
//! model space, and a frame only sends each drawn part's matrices and one job
|
||||
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
|
||||
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
|
||||
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
|
||||
//! then draws them as before.
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
|
||||
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
|
||||
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub axis: u32,
|
||||
pub part: u32,
|
||||
}
|
||||
|
||||
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
|
||||
/// normal matrix's columns normalized when the part's scale is not uniform.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinPart {
|
||||
pub matrix: [f32; 16],
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinJob {
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
pub part: u32,
|
||||
pub dst: u32,
|
||||
pub rect: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
|
||||
|
||||
/// What the game hands the renderer for one frame.
|
||||
#[derive(Default)]
|
||||
pub struct SkinFrame {
|
||||
/// The model store restarted (models are registered again from 0).
|
||||
pub reset: bool,
|
||||
/// The store's size before `new_model`: the renderer takes the frame only
|
||||
/// when its own store is that large (it missed none of the earlier ones).
|
||||
pub base: u32,
|
||||
/// Model vertices registered since the last frame, at the store's end.
|
||||
pub new_model: Vec<SkinVertex>,
|
||||
pub parts: Vec<SkinPart>,
|
||||
pub jobs: Vec<SkinJob>,
|
||||
/// Vertices the jobs write: a whole number of quads.
|
||||
pub vertices: u32,
|
||||
}
|
||||
|
||||
impl SkinFrame {
|
||||
/// Starts a frame's draws; models not yet handed to the renderer stay.
|
||||
pub fn clear_draws(&mut self) {
|
||||
self.parts.clear();
|
||||
self.jobs.clear();
|
||||
self.vertices = 0;
|
||||
}
|
||||
|
||||
/// The renderer took the new models.
|
||||
pub fn handed(&mut self) {
|
||||
self.base += self.new_model.len() as u32;
|
||||
self.new_model.clear();
|
||||
self.reset = false;
|
||||
}
|
||||
|
||||
/// Where the draws stand, to take back a mob's (`truncate`).
|
||||
pub fn mark(&self) -> (usize, usize, u32) {
|
||||
(self.jobs.len(), self.parts.len(), self.vertices)
|
||||
}
|
||||
|
||||
/// Takes back the draws since `mark` (a mob culled after drawing).
|
||||
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
|
||||
self.jobs.truncate(jobs);
|
||||
self.parts.truncate(parts);
|
||||
self.vertices = vertices;
|
||||
}
|
||||
|
||||
/// Whether the renderer has anything to take.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
|
||||
const ROW: u32 = 32_768;
|
||||
|
||||
pub(super) struct EntitySkin {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
model: Vec<SkinVertex>,
|
||||
model_buffer: Option<wgpu::Buffer>,
|
||||
parts: Grow,
|
||||
jobs: Grow,
|
||||
/// The jobs with `dst` moved to the frame's gap, as uploaded.
|
||||
staged: Vec<SkinJob>,
|
||||
/// The bind group and the buffers it binds (buffers compare by identity).
|
||||
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
|
||||
}
|
||||
|
||||
/// A storage buffer that grows to the next power of two.
|
||||
struct Grow {
|
||||
label: &'static str,
|
||||
buffer: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl Grow {
|
||||
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
|
||||
let bytes = bytes.max(256).next_power_of_two();
|
||||
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
|
||||
return false;
|
||||
}
|
||||
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(self.label),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl EntitySkin {
|
||||
/// The entity vertex buffer's usage: the jobs write into it.
|
||||
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
|
||||
if skinning {
|
||||
usage | wgpu::BufferUsages::STORAGE
|
||||
} else {
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The compute pipeline, or `None` without compute shaders (the CPU path
|
||||
/// then draws every model).
|
||||
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
|
||||
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
|
||||
if !compute
|
||||
|| device.limits().max_storage_buffers_per_shader_stage < 4
|
||||
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("skin"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
layout,
|
||||
model: Vec::new(),
|
||||
model_buffer: None,
|
||||
parts: Grow {
|
||||
label: "Entity skinning parts",
|
||||
buffer: None,
|
||||
},
|
||||
jobs: Grow {
|
||||
label: "Entity skinning jobs",
|
||||
buffer: None,
|
||||
},
|
||||
staged: Vec::new(),
|
||||
group: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The model vertices stored (`SkinFrame::base` of the next frame).
|
||||
pub(super) fn stored(&self) -> u32 {
|
||||
self.model.len() as u32
|
||||
}
|
||||
|
||||
/// Takes the frame's new models; whether its jobs can run (the store
|
||||
/// holds every model they read).
|
||||
pub(super) fn register(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
) -> bool {
|
||||
if frame.reset {
|
||||
self.model.clear();
|
||||
self.model_buffer = None;
|
||||
}
|
||||
if self.model.len() as u32 != frame.base {
|
||||
// Out of step with the game's store: it restarts it.
|
||||
return false;
|
||||
}
|
||||
if frame.new_model.is_empty() {
|
||||
return self.model_buffer.is_some() || frame.jobs.is_empty();
|
||||
}
|
||||
let old = self.model.len();
|
||||
self.model.extend_from_slice(&frame.new_model);
|
||||
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
|
||||
if self
|
||||
.model_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.size() >= bytes)
|
||||
{
|
||||
let buffer = self.model_buffer.as_ref().expect("checked");
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
(old * std::mem::size_of::<SkinVertex>()) as u64,
|
||||
bytemuck::cast_slice(&frame.new_model),
|
||||
);
|
||||
} else {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity skinning models"),
|
||||
size: bytes.max(256).next_power_of_two(),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
|
||||
self.model_buffer = Some(buffer);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records the frame's jobs: they write `frame.vertices` vertices into
|
||||
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
out: &wgpu::Buffer,
|
||||
base: u32,
|
||||
) {
|
||||
if frame.jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(model) = &self.model_buffer else {
|
||||
return;
|
||||
};
|
||||
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
|
||||
let count = frame.jobs.len() as u32;
|
||||
let rows = count.div_ceil(ROW);
|
||||
let padded = if rows > 1 {
|
||||
(rows * ROW) as usize
|
||||
} else {
|
||||
frame.jobs.len()
|
||||
};
|
||||
self.staged.clear();
|
||||
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
|
||||
dst: job.dst + base,
|
||||
..*job
|
||||
}));
|
||||
self.staged.resize(padded, SkinJob::default());
|
||||
self.parts.ensure(
|
||||
device,
|
||||
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
|
||||
);
|
||||
self.jobs
|
||||
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
|
||||
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
|
||||
return;
|
||||
};
|
||||
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
|
||||
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
|
||||
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
|
||||
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
|
||||
let entries: Vec<_> = key
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
self.group = Some((group, key));
|
||||
}
|
||||
let Some((group, _)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(count.min(ROW), rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
|
||||
/// the same way: the reference its tests hold the shader and the game's walk
|
||||
/// (`mobs::skin`) to. `model` is the whole store.
|
||||
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
|
||||
for job in &frame.jobs {
|
||||
for k in 0..job.count {
|
||||
let v = model[(job.first + k) as usize];
|
||||
let part = &frame.parts[(job.part + v.part) as usize];
|
||||
let m = glam::Mat4::from_cols_array(&part.matrix);
|
||||
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
|
||||
let c = (v.axis % 3) as usize * 3;
|
||||
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
|
||||
let n = if v.axis >= 3 { -column } else { column };
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let u = if job.scroll == 0.0 {
|
||||
v.uv[0]
|
||||
} else {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A cube's 24 vertices for part `part`, its faces on every axis.
|
||||
fn cube(part: u32) -> Vec<SkinVertex> {
|
||||
let mut out = Vec::new();
|
||||
for axis in 0..6u32 {
|
||||
for corner in 0..4u32 {
|
||||
let a = (axis % 3) as usize;
|
||||
let sign = if axis >= 3 { -0.25 } else { 0.25 };
|
||||
let mut pos = [0.0f32; 3];
|
||||
pos[a] = sign;
|
||||
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
|
||||
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
|
||||
axis,
|
||||
part,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
|
||||
SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: normal.to_cols_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Some(mut skin) = EntitySkin::new(&device, true) else {
|
||||
eprintln!("no compute shaders: skipped");
|
||||
return;
|
||||
};
|
||||
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
|
||||
let turn =
|
||||
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
|
||||
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
|
||||
let child = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
|
||||
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
|
||||
let normals = glam::Mat3::from_cols(
|
||||
normals.x_axis.normalize_or_zero(),
|
||||
normals.y_axis.normalize_or_zero(),
|
||||
normals.z_axis.normalize_or_zero(),
|
||||
);
|
||||
let frame = SkinFrame {
|
||||
reset: false,
|
||||
base: 0,
|
||||
new_model: model.clone(),
|
||||
parts: vec![
|
||||
part(base, glam::Mat3::from_mat4(turn)),
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 48,
|
||||
part: 0,
|
||||
dst: 0,
|
||||
rect: [0.25, 0.5, 0.375, 0.5625],
|
||||
scroll: 0.0,
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
first: 24,
|
||||
count: 24,
|
||||
part: 2,
|
||||
dst: 48,
|
||||
rect: [0.0, 0.0, 0.0625, 0.0625],
|
||||
scroll: 0.73,
|
||||
color: u32::MAX,
|
||||
light: 0xf0f0,
|
||||
overlay: 36,
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
let before = 5u32;
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(before + frame.vertices) * 44,
|
||||
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sentinel = vec![0xabu8; before as usize * 44];
|
||||
queue.write_buffer(&out, 0, &sentinel);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
|
||||
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: out.size(),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
|
||||
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
|
||||
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(got),
|
||||
bytemuck::bytes_of(want),
|
||||
"vertex {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
|
||||
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
|
||||
// would push, in the same order, into the entity vertex buffer. One workgroup
|
||||
// per job; its invocations walk the model's vertices.
|
||||
//
|
||||
// Words, not structs: every record is read and written as u32 words, so the
|
||||
// layouts are the Rust ones byte for byte.
|
||||
|
||||
// A model vertex (7 words): position / 16, model UV, the face's normal as an
|
||||
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
|
||||
const MODEL_WORDS: u32 = 7u;
|
||||
// A posed part (25 words): the geometry matrix (16, by columns), then the
|
||||
// normal matrix (9, by columns), its columns already normalized when the
|
||||
// part's scale is not uniform (`ModelNormals.trusted` false).
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
const VERTEX_WORDS: u32 = 11u;
|
||||
|
||||
@group(0) @binding(0) var<storage, read> model: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> parts: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
|
||||
|
||||
fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
|
||||
// `mobs::render::scroll_u`.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
fn pack(n: vec3<f32>) -> u32 {
|
||||
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
|
||||
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
|
||||
| (u32(-127 & 0xff) << 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn skin(
|
||||
@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(num_workgroups) groups: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32,
|
||||
) {
|
||||
let job = group.y * groups.x + group.x;
|
||||
if job >= arrayLength(&jobs) / JOB_WORDS {
|
||||
return;
|
||||
}
|
||||
let j = job * JOB_WORDS;
|
||||
let first = jobs[j];
|
||||
let count = jobs[j + 1u];
|
||||
let first_part = jobs[j + 2u];
|
||||
let dst = jobs[j + 3u];
|
||||
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
|
||||
let scroll = f(jobs[j + 8u]);
|
||||
let color = jobs[j + 9u];
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
} else {
|
||||
out[o + 9u] = 0u;
|
||||
out[o + 10u] = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,15 @@ struct PageTable {
|
||||
group: Option<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// Writes the fragment's alpha over the frame's and leaves its colour.
|
||||
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
};
|
||||
/// A run of the frame's index list drawn from one arena page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
@@ -74,6 +83,7 @@ pub struct Flat {
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub translucent_mark: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
@@ -418,6 +428,14 @@ impl Flat {
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
),
|
||||
translucent_mark: pipeline(
|
||||
&layout,
|
||||
"Terrain translucent HDR mark (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_translucent_mark",
|
||||
Some(ALPHA_ONLY),
|
||||
back,
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
|
||||
@@ -149,6 +149,9 @@ pub struct Hdr {
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -293,6 +296,7 @@ impl Hdr {
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -441,7 +445,8 @@ impl Hdr {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Where the hand's marks go: the kept world's alpha, which HDR output reads for it too.
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
@@ -538,6 +543,11 @@ impl Hdr {
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
@@ -617,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
@@ -629,6 +645,7 @@ mod tests {
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
include_str!("../hdr_stats.wgsl"),
|
||||
include_str!("stars.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
@@ -669,3 +686,18 @@ mod tests {
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lighting_tests {
|
||||
use super::scene_gain;
|
||||
|
||||
#[test]
|
||||
fn the_lighting_scales_with_the_headroom() {
|
||||
// Enough headroom: the full strength, whatever the paper white.
|
||||
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
|
||||
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
|
||||
// Little headroom, and none.
|
||||
assert_eq!(scene_gain(600.0, 400.0), 2.625);
|
||||
assert_eq!(scene_gain(200.0, 480.0), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ struct HorizonMaterialOut {
|
||||
@location(8) @interpolate(flat) face: u32,
|
||||
@location(9) @interpolate(flat) fluid_quad: u32,
|
||||
@location(10) @interpolate(flat) emission: f32,
|
||||
// terrain.wgsl's `emission`, for HDR output's mark.
|
||||
@location(11) @interpolate(flat) hdr_code: u32,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
out.fluid_quad = u32(fluid);
|
||||
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
|
||||
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
|
||||
let tier = 15u - (flags & 15u);
|
||||
out.hdr_code = 15u - (flags & 15u);
|
||||
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
|
||||
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
|
||||
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
|
||||
return out;
|
||||
}
|
||||
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position))).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
let y = horizon_terrain_brightness(texel.rgb);
|
||||
if whole || y >= t.r {
|
||||
return horizon_terrain_hdr_mark(tier, y);
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
let mark = horizon_material_mark(in, texel);
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ pub struct NativePack {
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// `[[lights]]`, for the colour of held and dropped items' light.
|
||||
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -133,6 +137,8 @@ impl NativePack {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
light_colors: report.manifest.lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
|
||||
@@ -6,7 +6,23 @@
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated;
|
||||
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
|
||||
/// Install trait mod renderers in dispatch order, once per process.
|
||||
pub fn install(
|
||||
renderers: Box<[&'static dyn RenderHooks]>,
|
||||
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
|
||||
RENDERERS.set(renderers)
|
||||
}
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -18,12 +34,16 @@ pub fn set_mods_built_in(on: bool) {
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
|| RENDERERS
|
||||
.get()
|
||||
.is_some_and(|renderers| !renderers.is_empty())
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
|
||||
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawn<'a> {
|
||||
pub device: &'a wgpu::Device,
|
||||
pub queue: &'a wgpu::Queue,
|
||||
@@ -59,6 +79,7 @@ pub struct WorldDrawn<'a> {
|
||||
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
|
||||
/// needs and leaves the rest, so several can ask.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeeds {
|
||||
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
|
||||
/// their type's name, block entity runs their block state.
|
||||
@@ -70,13 +91,13 @@ pub struct RenderNeeds {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
#[inline(never)]
|
||||
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
let _ = world;
|
||||
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
|
||||
}
|
||||
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
#[inline(never)]
|
||||
pub fn native_render_needs(needs: &mut RenderNeeds) {
|
||||
let _ = needs;
|
||||
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
|
||||
}
|
||||
|
||||
/// What the mods need this frame ([`native_render_needs`]).
|
||||
@@ -85,3 +106,34 @@ pub fn render_needs() -> RenderNeeds {
|
||||
native_render_needs(&mut needs);
|
||||
needs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Depth;
|
||||
impl RenderHooks for Depth {
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
event.needs.world_depth = true;
|
||||
}
|
||||
}
|
||||
struct Spans;
|
||||
impl RenderHooks for Spans {
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
event.needs.entity_spans = event.needs.world_depth;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
|
||||
assert_eq!(
|
||||
needs,
|
||||
RenderNeeds {
|
||||
world_depth: true,
|
||||
entity_spans: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @generated by cargo run -p xtask -- mod-api; do not edit.
|
||||
use super::*;
|
||||
pub trait RenderHooks: Send + Sync {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
///
|
||||
/// Every mod gets `event.needs` in order and may add, change or remove needs.
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawnEvent<'a, 'b> {
|
||||
pub world: &'a mut WorldDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
|
||||
let mut event = WorldDrawnEvent { world };
|
||||
for mod_hook in mods {
|
||||
mod_hook.world_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeedsEvent<'a> {
|
||||
pub needs: &'a mut RenderNeeds,
|
||||
}
|
||||
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
|
||||
let mut event = RenderNeedsEvent { needs };
|
||||
for mod_hook in mods {
|
||||
mod_hook.render_needs(&mut event);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
|
||||
pub sprite: u32,
|
||||
/// Layers per sprite array.
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -57,6 +59,7 @@ impl PackAtlas {
|
||||
columns,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
@@ -179,7 +182,12 @@ impl PackAtlas {
|
||||
];
|
||||
let columns = self.columns;
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
"hz_v_color.a"
|
||||
} else {
|
||||
"1.0"
|
||||
};
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
@@ -189,7 +197,7 @@ impl PackAtlas {
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
|
||||
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
|
||||
scale[0], scale[1],
|
||||
@@ -313,4 +321,50 @@ mod tests {
|
||||
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut atlas = PackAtlas {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_default
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
|
||||
);
|
||||
|
||||
atlas.separate_ao = true;
|
||||
let inputs_separate = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_separate
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,7 @@ impl PackEntities {
|
||||
entities: &[Vertex],
|
||||
spans: &[EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
crack: ([f32; 4], [u32; 2]),
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
) {
|
||||
@@ -330,7 +331,9 @@ impl PackEntities {
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run = &entities[range];
|
||||
let program = program_of(&span).filter(|_| plain(run));
|
||||
let program = program_of(&span).filter(|&p| {
|
||||
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
});
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
@@ -341,6 +344,9 @@ impl PackEntities {
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
self.scratch.extend_from_slice(run);
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
|
||||
pub const BLOCK: usize = 1;
|
||||
pub const TRANSLUCENT: usize = 2;
|
||||
pub const PARTICLE: usize = 3;
|
||||
pub const PROGRAMS: usize = 4;
|
||||
pub const DESTROY: usize = 4;
|
||||
pub const PROGRAMS: usize = 5;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
BLOCK
|
||||
} else if span.stage == render_stage("PARTICLES") {
|
||||
PARTICLE
|
||||
} else if span.stage == render_stage("DESTROY") {
|
||||
DESTROY
|
||||
} else {
|
||||
return None;
|
||||
})
|
||||
@@ -461,6 +470,16 @@ pub fn runs(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
|
||||
let destroy = EntitySpan {
|
||||
stage: render_stage("DESTROY"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
|
||||
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_split_the_list_between_pack_and_horizon() {
|
||||
let v = |layer| Vertex {
|
||||
@@ -483,15 +502,21 @@ mod tests {
|
||||
let all = [true; PROGRAMS];
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
|
||||
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
let shadow = EntitySpan {
|
||||
translucent: true,
|
||||
..spans[0]
|
||||
};
|
||||
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
|
||||
// A beacon beam's quads stay Horizon's.
|
||||
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
|
||||
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
|
||||
@@ -523,3 +548,53 @@ mod tests {
|
||||
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
|
||||
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
|
||||
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
|
||||
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
|
||||
for quad in vertices.as_chunks_mut::<4>().0 {
|
||||
let origin = [
|
||||
(quad[0].light & 65535) as f32 / 65535.0,
|
||||
(quad[0].light >> 16) as f32 / 65535.0,
|
||||
];
|
||||
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
|
||||
let min = [0, 1]
|
||||
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
|
||||
for v in quad {
|
||||
v.uv =
|
||||
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
|
||||
v.layer = 0;
|
||||
v.light = 0x0000_f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crack_projection_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
|
||||
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
|
||||
.into_iter()
|
||||
.map(|uv| Vertex {
|
||||
uv,
|
||||
layer: 11,
|
||||
light: crate::breaking::stage_light([0.25, 0.5]),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
project_cracks(
|
||||
&mut vertices,
|
||||
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
|
||||
[4096, 9216],
|
||||
);
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
|
||||
);
|
||||
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
|
||||
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
enum Source {
|
||||
Color(usize),
|
||||
Depth,
|
||||
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
|
||||
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
|
||||
Depth1,
|
||||
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
|
||||
Depth2,
|
||||
Noise,
|
||||
/// A 1×1 stand-in for names this slice does not provide yet.
|
||||
Dummy,
|
||||
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
|
||||
Normals,
|
||||
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
|
||||
Specular,
|
||||
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
|
||||
/// `.mcmeta` says.
|
||||
Custom {
|
||||
@@ -81,10 +87,18 @@ enum SamplerKind {
|
||||
impl SamplerKind {
|
||||
fn of(source: Source, filtering: bool) -> Self {
|
||||
match source {
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
if !filtering =>
|
||||
{
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels.
|
||||
@@ -97,9 +111,12 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
| Source::Lightmap => Self::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +404,8 @@ struct Targets {
|
||||
depth_texture: wgpu::Texture,
|
||||
/// `depthtex1`, when a program after translucent terrain reads it.
|
||||
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `depthtex2`, when a program after the hand reads it without the hand.
|
||||
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
|
||||
mips: [Option<[Mips; 2]>; BUFFERS],
|
||||
/// Where a pass that both samples and stores a buffer samples it from.
|
||||
@@ -408,6 +427,7 @@ pub(super) struct World<'a> {
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
pub gui_size: [u32; 2],
|
||||
/// Set 1 of the clouds' draw: their offset and palette.
|
||||
pub clouds: Option<&'a wgpu::BindGroupLayout>,
|
||||
/// The game can hand its rain and snow to the pack.
|
||||
@@ -458,6 +478,7 @@ struct ShadowCacheKey {
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
setup_pending: bool,
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
@@ -492,6 +513,8 @@ pub struct PackPost {
|
||||
hand: Option<(Pass, super::pack_hand::PackHand)>,
|
||||
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
depth2: bool,
|
||||
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
@@ -510,6 +533,7 @@ pub struct PackPost {
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
@@ -533,6 +557,8 @@ pub struct PackPost {
|
||||
targets: Option<Targets>,
|
||||
noise: wgpu::TextureView,
|
||||
dummy: wgpu::TextureView,
|
||||
dummy_black: wgpu::TextureView,
|
||||
dummy_normal: wgpu::TextureView,
|
||||
dummy_3d: wgpu::TextureView,
|
||||
dummy_1d: wgpu::TextureView,
|
||||
/// `image.*` and `bufferObject.*`.
|
||||
@@ -647,11 +673,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
}
|
||||
match name {
|
||||
"depthtex0" | "gdepthtex" => Source::Depth,
|
||||
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
|
||||
"depthtex1" | "depthtex2" => Source::Depth1,
|
||||
"depthtex1" => Source::Depth1,
|
||||
"depthtex2" => Source::Depth2,
|
||||
"noisetex" => Source::Noise,
|
||||
"texture" | "gtexture" | "tex" => Source::Atlas,
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
@@ -1073,7 +1101,7 @@ fn bindings(
|
||||
Source::Atlas => geometry.is_none(),
|
||||
Source::Depth => geometry.is_some(),
|
||||
// Translucent terrain reads the copy; opaque terrain would read itself.
|
||||
Source::Depth1 => group == Group::Gbuffers,
|
||||
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
|
||||
Source::Color(n) => in_place & 1 << n != 0,
|
||||
_ => false,
|
||||
};
|
||||
@@ -1084,7 +1112,7 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
|
||||
@@ -1341,11 +1369,16 @@ fn build_pass(
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
let format = formats[buffer as usize];
|
||||
let mut state = target(format);
|
||||
if let (Some(t), Some(b)) = (&mut state, p.blend)
|
||||
&& format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
if let (Some(t), Some(b)) = (
|
||||
&mut state,
|
||||
p.buffer_blends
|
||||
.iter()
|
||||
.find(|(n, _)| *n == buffer)
|
||||
.map_or(p.blend, |(_, b)| *b),
|
||||
) && format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
@@ -1433,7 +1466,15 @@ fn build_pass(
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
@@ -1893,8 +1934,15 @@ impl PackPost {
|
||||
post.block,
|
||||
post.entities_translucent,
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
];
|
||||
let late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
];
|
||||
let late = [false, false, post.translucent_late, post.particles_late];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
let entity_vertices = (programs.iter().chain(&shadow_programs))
|
||||
@@ -1910,8 +1958,21 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let entity_passes = programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
let decal = Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
depth_write: false,
|
||||
atlas: g.atlas.clone(),
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&decal))
|
||||
} else {
|
||||
build(p, Some(g))
|
||||
}
|
||||
})
|
||||
});
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
@@ -2045,13 +2106,20 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
}
|
||||
let depth1 = water.is_some()
|
||||
let depth1 = (water.is_some() || hand.is_some())
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
|
||||
let depth2 = hand.is_some()
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
|
||||
let every_pass = || {
|
||||
(passes
|
||||
.iter()
|
||||
@@ -2126,7 +2194,7 @@ impl PackPost {
|
||||
|| passes.iter().any(|p| {
|
||||
p.samplers
|
||||
.iter()
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
|
||||
});
|
||||
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
|
||||
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
|
||||
@@ -2215,8 +2283,11 @@ impl PackPost {
|
||||
});
|
||||
}
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
water,
|
||||
@@ -2233,6 +2304,7 @@ impl PackPost {
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
shadow_water,
|
||||
@@ -2243,6 +2315,7 @@ impl PackPost {
|
||||
draws,
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
gui_size: world.gui_size,
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
@@ -2266,6 +2339,14 @@ impl PackPost {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
|
||||
dummy_normal: texture(
|
||||
device,
|
||||
queue,
|
||||
"Shader pack normal stand-in",
|
||||
1,
|
||||
&flat_normal,
|
||||
),
|
||||
images: {
|
||||
let mut images = images;
|
||||
for (k, image) in images.iter_mut().enumerate() {
|
||||
@@ -2471,6 +2552,27 @@ impl PackPost {
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
Source::Depth2 => match &targets.depth2 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => match &targets.depth1 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
},
|
||||
Source::Noise => &self.noise,
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
|
||||
&self.dummy_3d
|
||||
@@ -2478,6 +2580,8 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals => &self.dummy_normal,
|
||||
Source::Specular => &self.dummy_black,
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
@@ -2649,16 +2753,6 @@ impl PackPost {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
if list == TerrainList::Translucent
|
||||
&& let Some(t) = &self.targets
|
||||
&& let Some((copy, _)) = &t.depth1
|
||||
{
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
let mut rp = self.begin(encoder, &pass);
|
||||
@@ -2868,6 +2962,7 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
entities: &[super::Vertex],
|
||||
spans: &[super::pack_draw::EntitySpan],
|
||||
crumbling: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
) {
|
||||
let shadow = self.shadow_casters();
|
||||
@@ -2884,6 +2979,7 @@ impl PackPost {
|
||||
entities,
|
||||
spans,
|
||||
drawn,
|
||||
(crumbling, self.gui_size),
|
||||
shadow,
|
||||
rest,
|
||||
);
|
||||
@@ -2934,6 +3030,22 @@ impl PackPost {
|
||||
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
|
||||
self.draw_entities(device, queue, encoder, size, false);
|
||||
self.draw_clouds(device, queue, encoder, size);
|
||||
if let Some(t) = &self.targets {
|
||||
if let Some((copy, _)) = &t.depth1 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
if let Some((copy, _)) = &t.depth2 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_hand(device, queue, encoder, size, frame);
|
||||
}
|
||||
|
||||
@@ -3228,6 +3340,7 @@ impl PackPost {
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
self.setup_pending = true;
|
||||
let sizes: [[u32; 2]; BUFFERS] =
|
||||
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
|
||||
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
|
||||
@@ -3324,6 +3437,11 @@ impl PackPost {
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let depth2 = self.depth2.then(|| {
|
||||
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
@@ -3357,6 +3475,7 @@ impl PackPost {
|
||||
depth: depth_texture.create_view(&Default::default()),
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -3655,7 +3774,6 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3785,8 +3903,17 @@ impl PackPost {
|
||||
.any(|(p, late)| p.is_some() && late)
|
||||
});
|
||||
let mut water_drawn = self.water.is_none() && self.weather.is_none() && !late_entities;
|
||||
let mut shadow_drawn = false;
|
||||
for pass in &mut passes {
|
||||
if !terrain_drawn && pass.group != Group::Prepare {
|
||||
if pass.group == Group::Setup && !self.setup_pending {
|
||||
continue;
|
||||
}
|
||||
let early = matches!(pass.group, Group::Setup | Group::Begin);
|
||||
if !early && !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
shadow_drawn = true;
|
||||
}
|
||||
if !terrain_drawn && !early && pass.group != Group::Prepare {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
@@ -3865,6 +3992,10 @@ impl PackPost {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.passes = passes;
|
||||
self.setup_pending = false;
|
||||
if !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
}
|
||||
if !terrain_drawn {
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
@@ -4040,7 +4171,10 @@ mod tests {
|
||||
let main = |n| source(n, false, false, false);
|
||||
assert!(main("gaux1") == Source::Color(4));
|
||||
assert!(main("gdepthtex") == Source::Depth);
|
||||
assert!(main("depthtex2") == Source::Depth1);
|
||||
assert!(main("depthtex1") == Source::Depth1);
|
||||
assert!(main("depthtex2") == Source::Depth2);
|
||||
assert!(main("normals") == Source::Normals);
|
||||
assert!(main("specular") == Source::Specular);
|
||||
assert!(main("noisetex") == Source::Noise);
|
||||
// Without the shadow pass, the shadow buffers are stand-ins.
|
||||
assert!(main("shadowcolor0") == Source::Dummy);
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn upload(
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
|
||||
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -60,8 +60,8 @@ pub(super) fn upload(
|
||||
Image::Atlas
|
||||
} else {
|
||||
notes.push(format!(
|
||||
"texture.{}.{}: `{location}` is not provided yet",
|
||||
t.stage, t.name
|
||||
"{}: `{location}` is not provided yet",
|
||||
key(&t.stage, &t.name)
|
||||
));
|
||||
continue;
|
||||
};
|
||||
@@ -86,6 +86,8 @@ pub(super) fn upload(
|
||||
fn stage(group: Group) -> &'static str {
|
||||
match group {
|
||||
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
|
||||
Group::Setup => "setup",
|
||||
Group::Begin => "begin",
|
||||
Group::Prepare => "prepare",
|
||||
Group::Deferred => "deferred",
|
||||
Group::Composite | Group::Final => "composite",
|
||||
@@ -95,7 +97,8 @@ fn stage(group: Group) -> &'static str {
|
||||
|
||||
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
|
||||
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
|
||||
/// may hold one texture per dimension.
|
||||
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
|
||||
/// dimension, which every program reads.
|
||||
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
|
||||
let dimension = if ty.contains("1D") {
|
||||
wgpu::TextureViewDimension::D1
|
||||
@@ -105,14 +108,27 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
textures.iter().position(|t| {
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The `shaders.properties` key a texture came from, for notes.
|
||||
fn key(stage: &str, name: &str) -> String {
|
||||
match stage {
|
||||
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
|
||||
_ => format!("texture.{stage}.{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `texture.noise`, if the pack has one.
|
||||
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
|
||||
textures
|
||||
@@ -316,6 +332,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
|
||||
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
|
||||
#[test]
|
||||
fn custom_textures_reach_every_program() {
|
||||
use wgpu::TextureViewDimension as D;
|
||||
let every = horizon_shaders::EVERY_STAGE;
|
||||
let textures = [
|
||||
lightmap(every, "texClouds", D::D3),
|
||||
lightmap("composite", "texClouds", D::D3),
|
||||
lightmap(every, "colortex4", D::D2),
|
||||
];
|
||||
for group in [
|
||||
Group::Gbuffers,
|
||||
Group::Translucent,
|
||||
Group::Shadow,
|
||||
Group::Prepare,
|
||||
Group::Deferred,
|
||||
Group::ShadowComp,
|
||||
] {
|
||||
assert_eq!(
|
||||
find(&textures, group, "sampler3D", "texClouds"),
|
||||
Some(0),
|
||||
"{group:?}"
|
||||
);
|
||||
}
|
||||
// `final` reads the `composite` stage's textures too.
|
||||
for group in [Group::Composite, Group::Final] {
|
||||
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
|
||||
}
|
||||
assert_eq!(
|
||||
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
|
||||
None
|
||||
);
|
||||
// Only its own name: no other name of the same buffer.
|
||||
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
|
||||
assert_eq!(
|
||||
find(&textures, Group::Final, "sampler2D", "colortex4"),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texels_pack_into_their_format() {
|
||||
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
|
||||
|
||||
@@ -404,9 +404,9 @@ impl FrameUniforms {
|
||||
frame.depth_far,
|
||||
);
|
||||
let camera = frame.camera.as_vec3();
|
||||
let first_frame = self.previous.is_none();
|
||||
let (previous_exact, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous.unwrap_or((frame.camera, view, projection));
|
||||
let previous_camera = previous_exact.as_vec3();
|
||||
self.previous = Some((frame.camera, view, projection));
|
||||
// The camera's whole blocks and the rest, exact however far from the origin.
|
||||
@@ -436,6 +436,10 @@ impl FrameUniforms {
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
if first_frame {
|
||||
self.wetness = inputs.rain;
|
||||
self.eye_smooth = eye;
|
||||
}
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
@@ -849,3 +853,151 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod initial_history_tests {
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
|
||||
match u.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
f.camera = DVec3::new(1024.25, -3.5, 11.75);
|
||||
f.yaw = 21.0;
|
||||
f.pitch = -16.0;
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
for (prior, current) in [
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
] {
|
||||
assert_eq!(float(&u, prior), float(&u, current));
|
||||
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
|
||||
}
|
||||
let camera = float(&u, "cameraPosition");
|
||||
let view = float(&u, "gbufferModelView");
|
||||
let projection = float(&u, "gbufferProjection");
|
||||
f.camera = DVec3::new(-100.0, 20.0, 40.0);
|
||||
f.yaw = 90.0;
|
||||
f.pitch = 50.0;
|
||||
f.fov_degrees = 100.0;
|
||||
u.update(&f, &PackInputs::default(), [1000, 500]);
|
||||
assert_eq!(float(&u, "previousCameraPosition"), camera);
|
||||
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
|
||||
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
|
||||
assert_ne!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
rain: 0.75,
|
||||
eye_light: [3, 14],
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert_eq!(float(&u, "wetness")[0], 0.75);
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
input.rain = 0.0;
|
||||
input.eye_light = [0, 0];
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!(float(&u, "wetness")[0] > 0.0);
|
||||
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
|
||||
}
|
||||
fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
first_person: true,
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
water_plane: None,
|
||||
weather: &[],
|
||||
weather_time: crate::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: crate::environment::Sky::default(),
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
#[cfg(test)]
|
||||
use super::Frustum;
|
||||
use super::{Cull, SectionKey};
|
||||
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
streaming::in_view_distance,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
/// One chunk column's resident sections.
|
||||
@@ -55,6 +58,22 @@ impl<T> Resident<T> {
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
/// Heap bytes of its own lists and maps, not counting what a `T` points
|
||||
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
|
||||
/// control byte.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
use std::mem::size_of;
|
||||
self.items.capacity() * size_of::<(SectionKey, T)>()
|
||||
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
|
||||
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
|
||||
+ self.columns.capacity() * size_of::<Column>()
|
||||
+ self
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
|
||||
.sum::<usize>()
|
||||
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
|
||||
}
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
self.slots.contains_key(&key)
|
||||
}
|
||||
@@ -125,6 +144,24 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Removes every section of the `gone` columns, the others keeping their order: one
|
||||
/// pass over the store, where `remove` moves a slot and looks its column up for each.
|
||||
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
|
||||
let items = std::mem::take(&mut self.items);
|
||||
let bounds = std::mem::take(&mut self.bounds);
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
let mut removed = Vec::new();
|
||||
for ((key, value), bounds) in items.into_iter().zip(bounds) {
|
||||
if gone.contains(&(key.0, key.2)) {
|
||||
removed.push(value);
|
||||
} else {
|
||||
self.insert_with_bounds(key, value, bounds);
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
|
||||
let slot = self.slots.remove(&key)?;
|
||||
let c = self.column_index[&(key.0, key.2)] as usize;
|
||||
@@ -496,7 +533,19 @@ mod tests {
|
||||
((seed >> 4) % 6) as i32 - 1,
|
||||
((seed >> 8) % 5) as i32 - 2,
|
||||
);
|
||||
if (seed >> 12).is_multiple_of(3) {
|
||||
if (seed >> 12).is_multiple_of(97) {
|
||||
// Whole columns at once: the column of `key` and the next one along x.
|
||||
let gone: FxHashSet<(i32, i32)> =
|
||||
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
|
||||
let mut removed = r.remove_columns(&gone);
|
||||
let mut expected: Vec<u32> = model
|
||||
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
|
||||
.map(|(_, v)| v)
|
||||
.collect();
|
||||
removed.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key));
|
||||
} else {
|
||||
r.insert(key, step);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Pipelines kept between the renderers built on one device. The start builds
|
||||
//! three (the boot screen's, the title artwork's, the world's) and a resource
|
||||
//! reload or a graphics option builds another; each compiled every shader
|
||||
//! module and pipeline again, though most come out identical (the boot and
|
||||
//! title renderers' entirely, the world's entity pipelines). On a driver that
|
||||
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
|
||||
//! of the main thread before the title showed.
|
||||
//!
|
||||
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
|
||||
//! labels, sources, constants, formats and states, and the handles a descriptor
|
||||
//! names (shader modules, layouts) by their ids, so a descriptor that names a
|
||||
//! kept handle matches only the same handle. Kept objects stay alive, so their
|
||||
//! ids are never reused while they are keys.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, PoisonError},
|
||||
};
|
||||
|
||||
/// One map of kept objects with the generation each was last asked for in.
|
||||
struct Kept<T> {
|
||||
map: Mutex<HashMap<String, (T, u32)>>,
|
||||
}
|
||||
impl<T> Default for Kept<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Clone> Kept<T> {
|
||||
/// The object kept under `key`, else `make`'s, kept. The lock is not
|
||||
/// held while `make` compiles.
|
||||
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
|
||||
if let Some((object, used)) = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_mut(&key)
|
||||
{
|
||||
*used = generation;
|
||||
return object.clone();
|
||||
}
|
||||
let object = make();
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(key, (object.clone(), generation));
|
||||
object
|
||||
}
|
||||
fn keep_only(&self, generation: u32) {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.retain(|_, (_, used)| *used == generation);
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader modules, layouts and pipelines of the device's renderers.
|
||||
#[derive(Default)]
|
||||
pub struct Reuse {
|
||||
generation: std::sync::atomic::AtomicU32,
|
||||
shaders: Kept<wgpu::ShaderModule>,
|
||||
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
|
||||
pipeline_layouts: Kept<wgpu::PipelineLayout>,
|
||||
render: Kept<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Reuse {
|
||||
/// A renderer starts building: what it asks for is kept, the rest is let
|
||||
/// go by [`Reuse::finish`].
|
||||
pub fn begin(&self) -> u32 {
|
||||
self.generation
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.wrapping_add(1)
|
||||
}
|
||||
/// The renderer of `generation` is built: objects only older renderers
|
||||
/// used are dropped here (the renderers still alive hold their own).
|
||||
pub fn finish(&self, generation: u32) {
|
||||
self.shaders.keep_only(generation);
|
||||
self.bind_group_layouts.keep_only(generation);
|
||||
self.pipeline_layouts.keep_only(generation);
|
||||
self.render.keep_only(generation);
|
||||
}
|
||||
/// How many objects are kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.shaders.len()
|
||||
+ self.bind_group_layouts.len()
|
||||
+ self.pipeline_layouts.len()
|
||||
+ self.render.len()
|
||||
}
|
||||
fn current(&self) -> u32 {
|
||||
self.generation.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `device`'s `create_*` calls through this cache.
|
||||
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
|
||||
On {
|
||||
reuse: self,
|
||||
device,
|
||||
generation: self.current(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A device whose shader modules, layouts and render pipelines come from a
|
||||
/// [`Reuse`] when one with the same descriptor is kept.
|
||||
pub struct On<'a> {
|
||||
reuse: &'a Reuse,
|
||||
device: &'a wgpu::Device,
|
||||
generation: u32,
|
||||
}
|
||||
impl On<'_> {
|
||||
pub fn create_shader_module(
|
||||
&self,
|
||||
descriptor: wgpu::ShaderModuleDescriptor<'_>,
|
||||
) -> wgpu::ShaderModule {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.shaders.get(key, self.generation, || {
|
||||
self.device.create_shader_module(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_bind_group_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
|
||||
) -> wgpu::BindGroupLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.bind_group_layouts.get(key, self.generation, || {
|
||||
self.device.create_bind_group_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_pipeline_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
|
||||
) -> wgpu::PipelineLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.pipeline_layouts.get(key, self.generation, || {
|
||||
self.device.create_pipeline_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_render_pipeline(
|
||||
&self,
|
||||
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.render.get(key, self.generation, || {
|
||||
self.device.create_render_pipeline(descriptor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Kept;
|
||||
|
||||
/// A key asked for again gets the kept object without making one; what
|
||||
/// the latest generation did not ask for is let go when it finishes.
|
||||
#[test]
|
||||
fn kept_objects_are_shared_until_a_build_stops_asking() {
|
||||
let kept = Kept::<u32>::default();
|
||||
let made = std::cell::Cell::new(0);
|
||||
let make = |value| {
|
||||
made.set(made.get() + 1);
|
||||
value
|
||||
};
|
||||
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
|
||||
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
|
||||
kept.keep_only(1);
|
||||
assert_eq!(kept.len(), 2);
|
||||
// The next build asks for "a" only.
|
||||
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
|
||||
kept.keep_only(2);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
|
||||
assert_eq!(made.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -132,14 +132,15 @@ impl Stars {
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one).
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
|
||||
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::OVER,
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
|
||||
@@ -11,6 +11,10 @@ struct Stars {
|
||||
return stars.clip_from_sky * vec4(position, 1.0);
|
||||
}
|
||||
|
||||
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
|
||||
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
|
||||
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
return stars.color;
|
||||
let b = stars.color.a;
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
|
||||
//! submissions keep the order they had on one thread.
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
|
||||
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
|
||||
@@ -50,6 +51,9 @@ pub struct Queue {
|
||||
/// A frame went to the submit thread and has not been waited for.
|
||||
pending: AtomicBool,
|
||||
landing: Mutex<Option<Landing>>,
|
||||
/// Nanoseconds the window's thread waited for the submit thread since the last
|
||||
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
|
||||
waited: AtomicU64,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
@@ -60,6 +64,7 @@ impl Queue {
|
||||
thread: None,
|
||||
pending: AtomicBool::new(false),
|
||||
landing: Mutex::new(None),
|
||||
waited: AtomicU64::new(0),
|
||||
};
|
||||
if !threaded {
|
||||
return this;
|
||||
@@ -130,15 +135,24 @@ impl Queue {
|
||||
return;
|
||||
}
|
||||
if let Some(l) = landing.as_mut() {
|
||||
let started = Instant::now();
|
||||
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
|
||||
let submission = l
|
||||
.done
|
||||
.recv()
|
||||
.expect("Horizon renderer: the submit thread stopped");
|
||||
l.landed = Some(submission);
|
||||
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
self.waited.fetch_add(nanos, Ordering::Relaxed);
|
||||
}
|
||||
self.pending.store(false, Ordering::Release);
|
||||
}
|
||||
/// How long the window's thread waited for the submit thread since the last call: in a
|
||||
/// GPU-bound view, the previous frame's submission and present hold up every use of the
|
||||
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
|
||||
pub fn take_waited(&self) -> Duration {
|
||||
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
|
||||
}
|
||||
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
|
||||
/// made; `None` if it was taken already.
|
||||
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The least a world is scaled to when memory runs out.
|
||||
const MIN_SCALE: f32 = 0.25;
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
@@ -322,12 +324,65 @@ impl Post {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
// The old target goes first: a slider drag would otherwise hold the old and the new
|
||||
// targets together, and the sum is what runs a small GPU out of memory.
|
||||
loop {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.target = None;
|
||||
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
|
||||
// that failed is invalid, so its view fails validation too: catch that as well.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let target = self.build_target(device, size, history_size);
|
||||
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
|
||||
if error.is_none() {
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(target);
|
||||
break;
|
||||
}
|
||||
drop(target);
|
||||
if self.scale <= MIN_SCALE {
|
||||
log::warn!(
|
||||
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = 1.0;
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let reduced = (self.scale * 0.75).max(MIN_SCALE);
|
||||
log::warn!(
|
||||
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
|
||||
self.scale,
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = reduced;
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
|
||||
/// is set.
|
||||
fn build_target(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
history_size: Option<[u32; 2]>,
|
||||
) -> WorldTarget {
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -403,19 +458,14 @@ impl Post {
|
||||
show,
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
|
||||
@@ -19,20 +19,28 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The display's darkest black, in hundredths of a nit.
|
||||
pub black: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
let (paper_white, peak, black) =
|
||||
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
REPORTED[2].store(black, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported {
|
||||
paper_white,
|
||||
peak,
|
||||
black,
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
@@ -546,6 +554,20 @@ pub struct Graphics {
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Cache settled shulker-box models.
|
||||
pub cache_shulker_boxes: bool,
|
||||
/// Cache sign-text geometry.
|
||||
pub cache_signs: bool,
|
||||
/// Cache decorated pots outside their wobble animation.
|
||||
pub cache_decorated_pots: bool,
|
||||
/// Cache bells while they are not ringing.
|
||||
pub cache_bells: bool,
|
||||
/// Cache closed chests, including copper variants.
|
||||
pub cache_chests: bool,
|
||||
/// Cache banner poles while leaving their flags animated.
|
||||
pub cache_banners: bool,
|
||||
/// Cache copper-golem statue models.
|
||||
pub cache_copper_golem_statues: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -585,8 +607,9 @@ pub struct Graphics {
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
|
||||
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
|
||||
/// metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
@@ -625,6 +648,14 @@ impl Default for Graphics {
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
cache_shulker_boxes: false,
|
||||
// Sign text has used its renderer cache independently of the new model caches.
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: false,
|
||||
cache_bells: false,
|
||||
cache_chests: false,
|
||||
cache_banners: false,
|
||||
cache_copper_golem_statues: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -710,6 +741,29 @@ impl Graphics {
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"blockEntityCache" => {
|
||||
let enabled = flag(false);
|
||||
g.cache_shulker_boxes = enabled;
|
||||
g.cache_signs = enabled;
|
||||
g.cache_decorated_pots = enabled;
|
||||
g.cache_bells = enabled;
|
||||
g.cache_chests = enabled;
|
||||
g.cache_banners = enabled;
|
||||
g.cache_copper_golem_statues = enabled;
|
||||
}
|
||||
"blockEntityCacheShulkerBoxes" => {
|
||||
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
|
||||
}
|
||||
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
|
||||
"blockEntityCacheDecoratedPots" => {
|
||||
g.cache_decorated_pots = flag(g.cache_decorated_pots)
|
||||
}
|
||||
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
|
||||
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
|
||||
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
|
||||
"blockEntityCacheCopperGolemStatues" => {
|
||||
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
|
||||
}
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -776,13 +830,20 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
bool_text(self.cache_signs),
|
||||
bool_text(self.cache_decorated_pots),
|
||||
bool_text(self.cache_bells),
|
||||
bool_text(self.cache_chests),
|
||||
bool_text(self.cache_banners),
|
||||
bool_text(self.cache_copper_golem_statues),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -850,6 +911,14 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
let hundredths = match self.hdr_black_level {
|
||||
0 => reported().map_or(0, |r| r.black),
|
||||
n => n,
|
||||
};
|
||||
hundredths as f32 / 100.0
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -949,6 +1018,13 @@ mod tests {
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
cache_shulker_boxes: true,
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: true,
|
||||
cache_bells: true,
|
||||
cache_chests: true,
|
||||
cache_banners: true,
|
||||
cache_copper_golem_statues: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
|
||||
@@ -33,6 +33,13 @@ pub enum H {
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
CacheShulkerBoxes,
|
||||
CacheSigns,
|
||||
CacheDecoratedPots,
|
||||
CacheBells,
|
||||
CacheChests,
|
||||
CacheBanners,
|
||||
CacheCopperGolemStatues,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -219,13 +226,49 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Hides entities behind opaque blocks.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
),
|
||||
Row::Header("Block Entities"),
|
||||
h(
|
||||
H::CacheShulkerBoxes,
|
||||
Saves,
|
||||
"Reuses settled boxes; lid movement stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheSigns,
|
||||
Saves,
|
||||
"Reuses sign text until text, light, or font changes.",
|
||||
),
|
||||
h(
|
||||
H::CacheDecoratedPots,
|
||||
Saves,
|
||||
"Reuses still pots; break wobble and sherd changes redraw.",
|
||||
),
|
||||
h(
|
||||
H::CacheBells,
|
||||
Saves,
|
||||
"Reuses still bells; ringing stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheChests,
|
||||
Saves,
|
||||
"Reuses closed chests; moving lids stay animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheBanners,
|
||||
Saves,
|
||||
"Reuses poles; flags and pattern layers still update.",
|
||||
),
|
||||
h(
|
||||
H::CacheCopperGolemStatues,
|
||||
Saves,
|
||||
"Reuses statue geometry until its block state changes.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
Opt::SmoothLighting,
|
||||
@@ -484,6 +527,11 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::EntityCulling), _) => match s.horizon.entity_culling {
|
||||
EntityCullingMode::Off => "No entity occlusion culling.",
|
||||
EntityCullingMode::Synchronous => "CPU occlusion test runs this frame.",
|
||||
EntityCullingMode::Asynchronous => "Occlusion checks run on a worker thread.",
|
||||
},
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
@@ -570,6 +618,15 @@ impl H {
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
|
||||
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
|
||||
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
|
||||
H::CacheBells => labelled("Bells", on_off(g.cache_bells)),
|
||||
H::CacheChests => labelled("Chests", on_off(g.cache_chests)),
|
||||
H::CacheBanners => labelled("Banners", on_off(g.cache_banners)),
|
||||
H::CacheCopperGolemStatues => {
|
||||
labelled("Copper Golem Statues", on_off(g.cache_copper_golem_statues))
|
||||
}
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -625,24 +682,21 @@ impl H {
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
|
||||
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
|
||||
),
|
||||
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
|
||||
n => labelled("Paper White", n.to_string()),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
|
||||
),
|
||||
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
|
||||
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
lang::tr(
|
||||
"%s nits",
|
||||
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
|
||||
H::HdrBlackLevel => match g.hdr_black_level {
|
||||
0 => labelled(
|
||||
"Black Level",
|
||||
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
|
||||
),
|
||||
),
|
||||
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
|
||||
},
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
@@ -719,6 +773,15 @@ impl H {
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
|
||||
H::CacheSigns => g.cache_signs = !g.cache_signs,
|
||||
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
|
||||
H::CacheBells => g.cache_bells = !g.cache_bells,
|
||||
H::CacheChests => g.cache_chests = !g.cache_chests,
|
||||
H::CacheBanners => g.cache_banners = !g.cache_banners,
|
||||
H::CacheCopperGolemStatues => {
|
||||
g.cache_copper_golem_statues = !g.cache_copper_golem_statues
|
||||
}
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -817,6 +880,13 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
|
||||
Item::Horizon(H::CacheBells) => g.cache_bells,
|
||||
Item::Horizon(H::CacheChests) => g.cache_chests,
|
||||
Item::Horizon(H::CacheBanners) => g.cache_banners,
|
||||
Item::Horizon(H::CacheCopperGolemStatues) => g.cache_copper_golem_statues,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
@@ -1319,6 +1389,11 @@ impl GraphicsUi {
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Action> {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !s.extras.wheel_cycles_settings {
|
||||
self.scroll(level, s, h, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
// Horizon improvement: focused slider/cycle intercepts wheel first.
|
||||
if amount != 0.0
|
||||
&& self.can_change_value
|
||||
@@ -1961,8 +2036,8 @@ mod tests {
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
// The culling group is followed by the separate block-entity cache
|
||||
// group, then Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
@@ -1982,6 +2057,27 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Block Entities"))
|
||||
));
|
||||
for (offset, cache) in [
|
||||
H::CacheShulkerBoxes,
|
||||
H::CacheSigns,
|
||||
H::CacheDecoratedPots,
|
||||
H::CacheBells,
|
||||
H::CacheChests,
|
||||
H::CacheBanners,
|
||||
H::CacheCopperGolemStatues,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(matches!(
|
||||
rows.get(culling + 5 + offset),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(cache)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
rows.get(culling + 12),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
@@ -2213,6 +2309,15 @@ mod tests {
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
|
||||
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let scroll_before = ui.scroll.amount;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
assert!(ui.scroll.amount > scroll_before);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.scroll.amount = scroll_before;
|
||||
|
||||
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
|
||||
s.horizon.chunk_updates = 16;
|
||||
@@ -2280,4 +2385,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Horizon's graphics captions are English sentences that the language table keys by
|
||||
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
|
||||
/// English in every language. Every text the three pages show, with each setting cycled
|
||||
/// through its values (#1927).
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for _ in 0..8 {
|
||||
for row in extra_rows(level, &s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
lang::get(name);
|
||||
}
|
||||
Row::Setting(row) => {
|
||||
row.item.label(&s);
|
||||
let text = description(row.item, &s, row.text);
|
||||
lang::get(text);
|
||||
lang::get(row.cost.label());
|
||||
if let Some(why) = row.item.blocked(&s) {
|
||||
lang::get(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in extra_rows(level, &s) {
|
||||
if let Row::Setting(Setting {
|
||||
item: Item::Horizon(h),
|
||||
..
|
||||
}) = row
|
||||
{
|
||||
h.cycle(&mut s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for button in ["Advanced...", "Simple...", "Done"] {
|
||||
lang::get(button);
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
// a name.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
|
||||
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
}
|
||||
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
|
||||
#[test]
|
||||
fn newer_graphics_settings_are_translated() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.leaf_culling = LeafCullingMode::Hollow;
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text) in [
|
||||
(
|
||||
"de_de",
|
||||
"Laub-Culling: Hohl",
|
||||
"Objekt-Culling: Asynchron",
|
||||
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
),
|
||||
(
|
||||
"pl_pl",
|
||||
"Pomijanie liści: Puste",
|
||||
"Pomijanie bytów: Asynchroniczne",
|
||||
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
),
|
||||
(
|
||||
"uk_ua",
|
||||
"Відсікання листя: Порожнє",
|
||||
"Відсік. сутностей: Асинхронно",
|
||||
"Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
),
|
||||
(
|
||||
"zh_cn",
|
||||
"树叶剔除: 镂空",
|
||||
"实体剔除: 异步",
|
||||
"省略树叶之间的面:更快,但缝隙会透视。",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"Occlusion feuillage: Creux",
|
||||
"Occlusion des entités: Asynchrone",
|
||||
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
),
|
||||
] {
|
||||
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
let item = |h| Item::Horizon(h);
|
||||
(
|
||||
item(H::LeafCulling).label(&s).plain(),
|
||||
item(H::EntityCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::LeafCulling), &s, "")),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
shown,
|
||||
(leaf.into(), entity.into(), leaf_text.into()),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return true;
|
||||
}
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
|
||||
&& (hand || (!covered && shows_world(c, w.rgb))) {
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
|
||||
@@ -49,7 +49,7 @@ pub struct Calibration {
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
auto: [Option<f32>; 2],
|
||||
auto: [Option<f32>; 3],
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
@@ -125,11 +125,12 @@ impl Calibration {
|
||||
Self {
|
||||
step: 0,
|
||||
values: [
|
||||
g.hdr_black_level as f32 / 100.0,
|
||||
g.black_level_nits(),
|
||||
g.peak_nits() as f32,
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
@@ -146,15 +147,11 @@ impl Calibration {
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
let step = self.current();
|
||||
let nits = match step {
|
||||
let value = match step {
|
||||
Step::Black => format!("{v:.2}"),
|
||||
_ => format!("{v:.0}"),
|
||||
};
|
||||
Text::literal(format!(
|
||||
"{}: {}",
|
||||
step.name(),
|
||||
lang::tr("%s nits", &[&nits])
|
||||
))
|
||||
Text::literal(format!("{}: {}", step.name(), value))
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
@@ -284,11 +281,12 @@ impl Calibration {
|
||||
/// Writes what the player landed on into Horizon's settings.
|
||||
pub fn save(&self, s: &mut Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
|
||||
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
|
||||
let scale = [100.0, 1.0, 1.0];
|
||||
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
|
||||
Some(auto) if auto == self.values[i] => 0,
|
||||
_ => self.values[i].round() as u32,
|
||||
_ => (self.values[i] * scale[i]).round() as u32,
|
||||
});
|
||||
g.hdr_black_level = black;
|
||||
g.hdr_peak = peak;
|
||||
g.hdr_paper_white = paper_white;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub mod cit_glint;
|
||||
mod pack_text;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
@@ -57,7 +59,18 @@ impl TextureFlags {
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
/// Identity of this packed atlas, used to invalidate cached model UV data
|
||||
/// even when a resource reload places sprites at the same coordinates.
|
||||
pub revision: u64,
|
||||
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
|
||||
/// compressed them (read them through `pixels()`).
|
||||
pub pixels: Vec<u8>,
|
||||
/// `pixels` compressed (lz4) on a thread of its own once the renderer
|
||||
/// has uploaded them: only rebuilding a renderer reads them again.
|
||||
packed: Option<Arc<OnceLock<Vec<u8>>>>,
|
||||
/// The width each Standard Galactic letter uses in `font/ascii_sga`'s
|
||||
/// cell, by ASCII code (the enchanting table's text), read when built.
|
||||
pub galactic_widths: [u16; 128],
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
@@ -247,16 +260,23 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
|
||||
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
|
||||
/// They are item textures, so an animated strip keeps its first frame. None without
|
||||
/// `optifine/cit` rules.
|
||||
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
|
||||
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
|
||||
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
|
||||
/// first frame; a still texture is packed whole. None without `optifine/cit` rules.
|
||||
fn cit_sprites(
|
||||
root: &Path,
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
|
||||
let rules = crate::cit::load_rules(root);
|
||||
let mut ids = crate::cit_assets::item_sprites(&rules);
|
||||
if !rules.is_empty() {
|
||||
let models = crate::cit_assets::load_models(root, &rules);
|
||||
ids.extend(crate::cit_assets::model_sprites(&models));
|
||||
ids.extend(crate::cit_armor::armor_sprites(&rules));
|
||||
}
|
||||
let ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
|
||||
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
|
||||
@@ -475,7 +495,80 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
|
||||
pub const MISSING_SPRITE: &str = "horizon/missing";
|
||||
|
||||
/// The used width of each Standard Galactic letter (A-Z, a-z) in the
|
||||
/// `font/ascii_sga` sheet: one past its last column with a texel that isn't
|
||||
/// transparent.
|
||||
fn galactic_widths(
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> [u16; 128] {
|
||||
let mut widths = [0u16; 128];
|
||||
let Some(sheet) = sprites.get("font/ascii_sga") else {
|
||||
return widths;
|
||||
};
|
||||
let cell = sheet.w / 16;
|
||||
let (px, py) = (
|
||||
(sheet.u0 * width as f32) as u32,
|
||||
(sheet.v0 * height as f32) as u32,
|
||||
);
|
||||
for code in (0x41..=0x5Au32).chain(0x61..=0x7A) {
|
||||
let (cx, cy) = (px + code % 16 * cell, py + code / 16 * cell);
|
||||
let alpha = |x: u32, y: u32| {
|
||||
pixels
|
||||
.get(((y * width + x) * 4 + 3) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
widths[code as usize] = (0..cell)
|
||||
.rev()
|
||||
.find(|&dx| (0..cell).any(|dy| alpha(cx + dx, cy + dy) != 0))
|
||||
.map_or(0, |dx| (dx + 1).min(u32::from(u16::MAX)) as u16);
|
||||
}
|
||||
widths
|
||||
}
|
||||
impl HudAtlas {
|
||||
fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// The atlas's pixels: as built, or uncompressed again after
|
||||
/// `pack_pixels` (a renderer being rebuilt).
|
||||
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
let Some(packed) = self.packed.as_ref().filter(|_| self.pixels.is_empty()) else {
|
||||
return std::borrow::Cow::Borrowed(&self.pixels);
|
||||
};
|
||||
let size = (self.width * self.height * 4) as usize;
|
||||
std::borrow::Cow::Owned(
|
||||
lz4_flex::decompress_size_prepended(packed.wait())
|
||||
.ok()
|
||||
.filter(|p| p.len() == size)
|
||||
.unwrap_or_else(|| vec![0; size]),
|
||||
)
|
||||
}
|
||||
/// Keep the pixels compressed from now on, once a renderer has them: the
|
||||
/// window's GPU holds the atlas, and a copy as built only serves a
|
||||
/// renderer made again (another window, a lost device, another graphics
|
||||
/// API). They are compressed on a thread of their own; `pixels()` waits
|
||||
/// for it.
|
||||
pub fn pack_pixels(&mut self) {
|
||||
if self.pixels.is_empty() || self.packed.is_some() {
|
||||
return;
|
||||
}
|
||||
let raw = Arc::new(std::mem::take(&mut self.pixels));
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let (job_raw, job_slot) = (raw.clone(), slot.clone());
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Atlas packing".into())
|
||||
.spawn(move || {
|
||||
let _ = job_slot.set(lz4_flex::compress_prepend_size(&job_raw));
|
||||
});
|
||||
if spawned.is_err() {
|
||||
let _ = slot.set(lz4_flex::compress_prepend_size(&raw));
|
||||
}
|
||||
self.packed = Some(slot);
|
||||
}
|
||||
/// A sprite, or the missing one when the atlas has no such sprite (an
|
||||
/// effect without its icon), as a stitched atlas answers.
|
||||
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
|
||||
@@ -614,6 +707,7 @@ impl HudAtlas {
|
||||
);
|
||||
let white = sprites["horizon/white"];
|
||||
Self {
|
||||
revision: Self::next_revision(),
|
||||
font: crate::font::Font::load(
|
||||
Path::new("horizon-boot"),
|
||||
&sprites,
|
||||
@@ -622,7 +716,9 @@ impl HudAtlas {
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -664,25 +760,21 @@ impl HudAtlas {
|
||||
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
|
||||
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
|
||||
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
|
||||
/// A width where the plain shelves (each image after the last) leave too
|
||||
/// much unused is tried again with each image in the first shelf with
|
||||
/// room: a pack of thousands of mixed sizes (server packs with custom
|
||||
/// items) fits where the shelves' ragged ends would not.
|
||||
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
let mut width = 4096.min(max);
|
||||
loop {
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let wide_enough = images.iter().all(|i| i.1 < width);
|
||||
if wide_enough && glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
if wide_enough {
|
||||
for first_fit in [false, true] {
|
||||
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
|
||||
if glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
}
|
||||
}
|
||||
}
|
||||
if width >= max {
|
||||
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
|
||||
@@ -695,6 +787,33 @@ impl HudAtlas {
|
||||
width = (width * 2).min(max);
|
||||
}
|
||||
}
|
||||
/// `layout`'s shelves at one width: the placements and the first row below
|
||||
/// them. `first_fit` puts an image in the first shelf that is tall enough
|
||||
/// and has room, instead of only the last one.
|
||||
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
|
||||
// Each shelf: its top row, its height (its first image's) and the next free column.
|
||||
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
for (_, w, h, _) in images {
|
||||
let open = if first_fit {
|
||||
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
|
||||
} else {
|
||||
shelves
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
|
||||
};
|
||||
let i = open.unwrap_or_else(|| {
|
||||
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
|
||||
shelves.push((top, *h, 0));
|
||||
shelves.len() - 1
|
||||
});
|
||||
placements.push((shelves[i].2, shelves[i].0));
|
||||
shelves[i].2 += w + 1;
|
||||
}
|
||||
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
|
||||
(placements, glyph_region)
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
@@ -1032,7 +1151,10 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -1155,6 +1277,9 @@ pub enum Blend {
|
||||
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
|
||||
/// projected from the screen (`HudBatch::end_portal` sets the textures).
|
||||
EndPortal,
|
||||
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
|
||||
/// `GlintTextures::blends` says.
|
||||
CitGlint(u8),
|
||||
}
|
||||
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
|
||||
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
|
||||
@@ -1889,7 +2014,11 @@ pub struct HudRenderer {
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
/// The pack's CIT enchantment glints, when it has any.
|
||||
cit_glint: Option<cit_glint::CitGlintPass>,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
@@ -1918,6 +2047,12 @@ impl HudRenderer {
|
||||
label: Some("HUD"),
|
||||
..Default::default()
|
||||
});
|
||||
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("HUD linear"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
@@ -1968,6 +2103,12 @@ impl HudRenderer {
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 5,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -1994,6 +2135,10 @@ impl HudRenderer {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(glint_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&linear_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -2096,7 +2241,7 @@ impl HudRenderer {
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
"fs_vignette",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
@@ -2122,7 +2267,9 @@ impl HudRenderer {
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
group,
|
||||
cit_glint: None,
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
@@ -2132,6 +2279,18 @@ impl HudRenderer {
|
||||
zoom: 1.0,
|
||||
})
|
||||
}
|
||||
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
|
||||
/// textures draws none.
|
||||
pub fn set_cit_glints(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
textures: Option<&cit_glint::GlintTextures>,
|
||||
) {
|
||||
self.cit_glint = textures.and_then(|t| {
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
|
||||
});
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
pub fn set_text_shader(
|
||||
&mut self,
|
||||
@@ -2367,6 +2526,12 @@ impl HudRenderer {
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
if let Some(cit) = &self.cit_glint {
|
||||
cit.draw(&mut pass, *blend, range.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
@@ -2723,6 +2888,18 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn packed_pixels_come_back_the_same() {
|
||||
let root = animated_root();
|
||||
let mut atlas = HudAtlas::build(&root).unwrap();
|
||||
let built = atlas.pixels.clone();
|
||||
atlas.pack_pixels();
|
||||
assert!(atlas.pixels.is_empty());
|
||||
assert!(atlas.pixels() == built.as_slice());
|
||||
// Packing again keeps the same image.
|
||||
atlas.pack_pixels();
|
||||
assert!(atlas.pixels() == built.as_slice());
|
||||
}
|
||||
#[test]
|
||||
fn a_strip_frames_size_comes_from_its_mcmeta() {
|
||||
let root = animated_root();
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
@@ -2827,6 +3004,25 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_cit_models_relative_texture_is_packed() {
|
||||
let root = cit_root(
|
||||
"model",
|
||||
&[
|
||||
("a.properties", "items=stick\nmodel=sword"),
|
||||
(
|
||||
"sword.json",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
|
||||
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_still_cit_texture_is_packed_whole() {
|
||||
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
|
||||
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
@@ -2862,7 +3058,7 @@ mod tests {
|
||||
assert_eq!(animations[0].1.frame_count(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn a_texture_only_an_armor_rule_names_is_not_packed() {
|
||||
fn an_armor_rules_texture_is_packed_too() {
|
||||
let root = cit_root(
|
||||
"armor",
|
||||
&[(
|
||||
@@ -2872,11 +3068,15 @@ mod tests {
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
// The rule loads; only its kind keeps the texture out.
|
||||
assert_eq!(crate::cit::load_rules(&root).len(), 1);
|
||||
let rules = crate::cit::load_rules(&root);
|
||||
assert_eq!(rules.len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
|
||||
// Worn armour draws from the same atlas, under the name the layer looks up.
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
|
||||
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
|
||||
assert!(atlas.sprites.contains_key(&layer.unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
|
||||
@@ -3104,6 +3304,19 @@ texture=wide",
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn mixed_sizes_fill_the_ends_of_the_shelves() {
|
||||
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
|
||||
// 4000-wide ones (same height) come after them, and on shelves of their
|
||||
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
|
||||
// Each in a gap, it is 15.
|
||||
let mut images = sized(15, 9000, 1000);
|
||||
images.extend(sized(15, 4000, 1000));
|
||||
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
|
||||
assert_eq!(width, 16384);
|
||||
assert!(free + GLYPH_ROWS <= 16384);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
|
||||
@@ -10,6 +10,7 @@ struct Hud {
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var glint_sampler: sampler;
|
||||
@group(0) @binding(5) var linear_sampler: sampler;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -41,11 +42,46 @@ struct Out {
|
||||
if c.a < 0.004 { discard; }
|
||||
return c;
|
||||
}
|
||||
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
|
||||
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
|
||||
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
|
||||
struct CitSlot {
|
||||
rgba: vec4<f32>,
|
||||
// x: linear filtering (`blur`).
|
||||
blur: vec4<f32>,
|
||||
}
|
||||
struct CitSlots {
|
||||
slots: array<CitSlot, 256>,
|
||||
}
|
||||
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
|
||||
@group(1) @binding(1) var cit_blur: sampler;
|
||||
@group(1) @binding(2) var cit_nearest: sampler;
|
||||
@group(1) @binding(3) var<uniform> cit: CitSlots;
|
||||
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
|
||||
let index = u32(round(in.color.r * 255.0));
|
||||
let slot = cit.slots[index];
|
||||
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
|
||||
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
|
||||
if mask < 0.1 { discard; }
|
||||
// The layer's colour times its intensity (the glint strength option included) in alpha,
|
||||
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
|
||||
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
|
||||
t.a *= in.color.a;
|
||||
if t.a < 0.1 { discard; }
|
||||
return t;
|
||||
}
|
||||
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
if c.a == 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
// RenderPipelines.VIGNETTE (WinScreen's credits vignette): bilinear sample of
|
||||
// credits_vignette.png (which has blur: true in its .mcmeta and a wrapped border in the atlas).
|
||||
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSample(atlas, linear_sampler, in.uv) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
|
||||
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
|
||||
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
|
||||
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
|
||||
pub const MAX_SLOTS: usize = 256;
|
||||
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
|
||||
const MAX_SIDE: u32 = 1024;
|
||||
/// The most the texture array takes, in bytes: past it the shared size is halved.
|
||||
const MAX_BYTES: u64 = 64 << 20;
|
||||
|
||||
/// One slot's constants.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Slot {
|
||||
/// The rule's `r`, `g`, `b`, `a`.
|
||||
pub rgba: [f32; 4],
|
||||
/// Linear filtering (`blur`), else nearest.
|
||||
pub blur: bool,
|
||||
/// An index into `GlintTextures::blends`.
|
||||
pub blend: u8,
|
||||
}
|
||||
|
||||
/// The enchantment rules' textures, resized to one size.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GlintTextures {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
|
||||
/// split-screen player's renderer builds its own array from it.
|
||||
pub pixels: Vec<u8>,
|
||||
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
|
||||
/// textures that did not load and rules past `MAX_SLOTS`.
|
||||
pub slot_of: Vec<Option<u8>>,
|
||||
pub slots: Vec<Slot>,
|
||||
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
|
||||
pub blends: Vec<Option<Blend>>,
|
||||
}
|
||||
|
||||
impl GlintTextures {
|
||||
/// The textures of `rules` under the overlay resource root.
|
||||
pub fn load(root: &Path, rules: &[Rule]) -> Self {
|
||||
let mut images = Vec::new();
|
||||
let mut past = 0;
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let Some(path) = rule.texture.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if rule.kind != Kind::Enchantment {
|
||||
continue;
|
||||
}
|
||||
if images.len() == MAX_SLOTS {
|
||||
past += 1;
|
||||
continue;
|
||||
}
|
||||
match super::decode(&root.join(path)) {
|
||||
Ok(image) => images.push((i, image)),
|
||||
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
|
||||
}
|
||||
}
|
||||
if past > 0 {
|
||||
log::warn!(
|
||||
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
|
||||
);
|
||||
}
|
||||
Self::new(rules, images, MAX_BYTES)
|
||||
}
|
||||
|
||||
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
|
||||
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
|
||||
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
|
||||
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
|
||||
return Self::default();
|
||||
};
|
||||
let mut out = Self {
|
||||
width,
|
||||
height,
|
||||
slot_of: vec![None; rules.len()],
|
||||
..Self::default()
|
||||
};
|
||||
for (rule, (w, h, rgba)) in images {
|
||||
let glint = &rules[rule].glint;
|
||||
out.pixels
|
||||
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
|
||||
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.blends.push(glint.blend);
|
||||
out.blends.len() - 1
|
||||
}
|
||||
};
|
||||
out.slot_of[rule] = Some(out.slots.len() as u8);
|
||||
out.slots.push(Slot {
|
||||
rgba: glint.rgba,
|
||||
blur: glint.blur,
|
||||
blend: blend as u8,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The size every slot takes: the largest width and height of `sizes`, each at most
|
||||
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
|
||||
fn shared_size(
|
||||
sizes: impl Iterator<Item = (u32, u32)>,
|
||||
layers: usize,
|
||||
budget: u64,
|
||||
) -> Option<(u32, u32)> {
|
||||
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
|
||||
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
|
||||
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
|
||||
})?;
|
||||
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
|
||||
while bytes(w, h) > budget && (w > 1 || h > 1) {
|
||||
(w, h) = ((w / 2).max(1), (h / 2).max(1));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
|
||||
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
|
||||
/// the glint repeats over the item.
|
||||
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
|
||||
if from == to {
|
||||
return rgba.to_vec();
|
||||
}
|
||||
let ((w, h), (to_w, to_h)) = (from, to);
|
||||
let texel = |x: i64, y: i64| {
|
||||
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
|
||||
let at = (y as usize * w as usize + x as usize) * 4;
|
||||
&rgba[at..at + 4]
|
||||
};
|
||||
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
|
||||
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
|
||||
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
|
||||
for y in 0..to_h {
|
||||
let sy = centre(y, to_h, h);
|
||||
for x in 0..to_w {
|
||||
let sx = centre(x, to_w, w);
|
||||
if !smooth {
|
||||
let pick = |i: u32, n: u32, size: u32| {
|
||||
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
|
||||
};
|
||||
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
|
||||
continue;
|
||||
}
|
||||
let (fx, fy) = (sx - 0.5, sy - 0.5);
|
||||
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
|
||||
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
|
||||
let [a, b, c, d] = [
|
||||
texel(x0, y0),
|
||||
texel(x0 + 1, y0),
|
||||
texel(x0, y0 + 1),
|
||||
texel(x0 + 1, y0 + 1),
|
||||
];
|
||||
let lerp =
|
||||
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
|
||||
out.extend((0..4).map(|k| {
|
||||
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
|
||||
(top * (1.0 - ty) + bottom * ty).round() as u8
|
||||
}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
|
||||
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
|
||||
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
|
||||
/// degrees, as OptiFine's doc has it.
|
||||
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
|
||||
let clock = f64::from(millis) * f64::from(speed);
|
||||
let x = (clock % 110_000.0 / 110_000.0) as f32;
|
||||
let y = (clock % 30_000.0 / 30_000.0) as f32;
|
||||
let (sin, cos) = rotation.to_radians().sin_cos();
|
||||
let (u, v) = (model[0] * scale, model[1] * scale);
|
||||
[u * cos - v * sin - x, u * sin + v * cos + y]
|
||||
}
|
||||
|
||||
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
|
||||
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
|
||||
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
super::rgba(slot, 0, 0, alpha)
|
||||
}
|
||||
|
||||
fn factor(f: Factor) -> wgpu::BlendFactor {
|
||||
use wgpu::BlendFactor as W;
|
||||
match f {
|
||||
Factor::Zero => W::Zero,
|
||||
Factor::One => W::One,
|
||||
Factor::SrcColor => W::Src,
|
||||
Factor::OneMinusSrcColor => W::OneMinusSrc,
|
||||
Factor::SrcAlpha => W::SrcAlpha,
|
||||
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
|
||||
Factor::DstAlpha => W::DstAlpha,
|
||||
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
|
||||
Factor::DstColor => W::Dst,
|
||||
Factor::OneMinusDstColor => W::OneMinusDst,
|
||||
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
|
||||
}
|
||||
}
|
||||
|
||||
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
|
||||
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
|
||||
let Some(b) = blend else {
|
||||
return wgpu::BlendState::REPLACE;
|
||||
};
|
||||
let component = |src, dst| wgpu::BlendComponent {
|
||||
src_factor: factor(src),
|
||||
dst_factor: factor(dst),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
wgpu::BlendState {
|
||||
color: component(b.src, b.dst),
|
||||
alpha: component(b.src_alpha, b.dst_alpha),
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
|
||||
/// pipeline per blend.
|
||||
pub(super) struct CitGlintPass {
|
||||
group: wgpu::BindGroup,
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl CitGlintPass {
|
||||
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
hud: &wgpu::BindGroupLayout,
|
||||
textures: &GlintTextures,
|
||||
) -> Option<Self> {
|
||||
use wgpu::util::DeviceExt;
|
||||
if textures.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
|
||||
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
|
||||
// layers kept sample the last one.
|
||||
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
|
||||
let max = device.limits().max_texture_array_layers.max(1);
|
||||
let slots = textures.slots.len() as u32;
|
||||
let mut kept = slots.min(max);
|
||||
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
|
||||
kept -= 1;
|
||||
}
|
||||
if kept < slots {
|
||||
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
|
||||
}
|
||||
let layers = crate::gfx::array_layers(kept, gl).min(max);
|
||||
let layer_bytes = textures.width as usize * textures.height as usize * 4;
|
||||
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
|
||||
if layers != slots {
|
||||
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
|
||||
padded.resize(layer_bytes * layers as usize, 0);
|
||||
data = std::borrow::Cow::Owned(padded);
|
||||
}
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("CIT glints"),
|
||||
size: wgpu::Extent3d {
|
||||
width: textures.width,
|
||||
height: textures.height,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
});
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
|
||||
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
|
||||
// hud.wgsl's `CitSlot`: rgba, then blur in x.
|
||||
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
|
||||
for (row, slot) in table.iter_mut().zip(&textures.slots) {
|
||||
row[..4].copy_from_slice(&slot.rgba);
|
||||
row[4] = if slot.blur { 1.0 } else { 0.0 };
|
||||
}
|
||||
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("CIT glint slots"),
|
||||
contents: bytemuck::cast_slice(&table),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let fragment = wgpu::ShaderStages::FRAGMENT;
|
||||
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: fragment,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("CIT glint bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
0,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
),
|
||||
entry(1, filtering),
|
||||
entry(2, filtering),
|
||||
entry(
|
||||
3,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("CIT glint resources"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: table.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HUD (CIT glints)"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("CIT glint layout"),
|
||||
bind_group_layouts: &[Some(hud), Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let pipelines = textures
|
||||
.blends
|
||||
.iter()
|
||||
.map(|&blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("CIT glint"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<HudVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_cit_glint"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(blend_state(blend)),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self { group, pipelines })
|
||||
}
|
||||
|
||||
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
|
||||
pub(super) fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
blend: u8,
|
||||
vertices: std::ops::Range<u32>,
|
||||
) {
|
||||
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.draw(vertices, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
|
||||
let text = format!("texture=glint\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_share_one_size_and_their_blends() {
|
||||
let rules = [
|
||||
rule("a", "type=enchantment\nr=0.5"),
|
||||
rule("item", "items=stick"),
|
||||
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
|
||||
rule("c", "type=enchantment"),
|
||||
];
|
||||
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
|
||||
let red = vec![255, 0, 0, 255];
|
||||
let quad: Vec<u8> = (0..16).collect();
|
||||
let t = GlintTextures::new(
|
||||
&rules,
|
||||
vec![
|
||||
(0, (1, 1, red)),
|
||||
(2, (2, 2, quad.clone())),
|
||||
(3, (2, 2, quad.clone())),
|
||||
],
|
||||
MAX_BYTES,
|
||||
);
|
||||
assert_eq!((t.width, t.height), (2, 2));
|
||||
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
|
||||
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
|
||||
assert_eq!(&t.pixels[16..32], &quad[..]);
|
||||
assert_eq!(
|
||||
t.blends,
|
||||
[
|
||||
parse_blend("add").unwrap(),
|
||||
parse_blend("multiply").unwrap()
|
||||
]
|
||||
);
|
||||
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
|
||||
assert_eq!(blends, [0, 1, 0]);
|
||||
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
|
||||
assert!(!t.slots[1].blur && t.slots[2].blur);
|
||||
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_glints_are_scaled_down_to_the_largest_side() {
|
||||
let rules = [rule("wide", "type=enchantment\nblur=false")];
|
||||
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
|
||||
let wide: Vec<u8> = (0..2048u32)
|
||||
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
|
||||
.collect();
|
||||
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
|
||||
assert_eq!((t.width, t.height), (1024, 1));
|
||||
for x in [0u32, 1, 300, 1023] {
|
||||
let at = x as usize * 4;
|
||||
let source = 2 * x;
|
||||
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
|
||||
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_array_is_halved_until_it_fits() {
|
||||
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
|
||||
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
|
||||
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
|
||||
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
|
||||
// The largest of each side, never past MAX_SIDE.
|
||||
let mixed = [(16, 4096), (64, 32)].into_iter();
|
||||
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
|
||||
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
|
||||
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
|
||||
let row = [0, 0, 0, 255, 255, 255, 255, 255];
|
||||
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
|
||||
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
|
||||
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_vertex_carries_its_slot_and_intensity() {
|
||||
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
|
||||
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
|
||||
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
|
||||
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_layer_is_scaled_and_turned() {
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
|
||||
// No speed: no scroll whatever the clock.
|
||||
assert!(close(
|
||||
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
|
||||
[2.0, 4.0]
|
||||
));
|
||||
// A quarter turn takes (1, 0) to (0, 1).
|
||||
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
|
||||
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
|
||||
assert!(close(
|
||||
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
|
||||
[-3.0 / 110.0, 0.1]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_blends_take_their_gl_factors() {
|
||||
let multiply = blend_state(parse_blend("multiply").unwrap());
|
||||
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
|
||||
assert_eq!(
|
||||
multiply.color.dst_factor,
|
||||
wgpu::BlendFactor::OneMinusSrcAlpha
|
||||
);
|
||||
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
|
||||
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
|
||||
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
|
||||
assert_eq!(
|
||||
blend_state(parse_blend("replace").unwrap()),
|
||||
wgpu::BlendState::REPLACE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
@@ -22,7 +23,9 @@ pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod cit_armor;
|
||||
pub mod cit_assets;
|
||||
pub mod cit_glint;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
@@ -37,6 +40,7 @@ pub mod extras;
|
||||
pub mod fast_hash;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
@@ -48,6 +52,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -90,7 +95,9 @@ pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
pub mod split_map;
|
||||
pub mod split_profiles;
|
||||
pub mod split_ui;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -562,11 +562,7 @@ fn pack_lighting(
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.iter().all(|&v| v == data[0]) {
|
||||
Light::Uniform(data[0])
|
||||
} else {
|
||||
Light::Full(data)
|
||||
}
|
||||
Light::packed(data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
|
||||
return;
|
||||
};
|
||||
let slot = &mut self.slots[slot];
|
||||
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
|
||||
Light::Uniform(v) => Box::new([*v; 4096]),
|
||||
Light::Full(a) => a.clone(),
|
||||
});
|
||||
let light = slot
|
||||
.light
|
||||
.get_or_insert_with(|| slot.section.light.to_array());
|
||||
let i = index(x, y, z);
|
||||
light[i] = f(light[i]);
|
||||
}
|
||||
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
|
||||
drop(slot.section);
|
||||
let (cx, s, cz) = slot.key;
|
||||
self.world
|
||||
.set_section_light(cx, s as usize, cz, Light::Full(light));
|
||||
.set_section_light(cx, s as usize, cz, Light::packed(light));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1624,21 +1619,20 @@ mod tests {
|
||||
current.set(8, MIN_Y, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(¤t), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
let Light::Full(data) = &lights[0] else {
|
||||
panic!("torch light varies");
|
||||
let storage = |light: &Light| match light {
|
||||
Light::Full(data) => data.as_ptr(),
|
||||
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
|
||||
Light::Uniform(_) => panic!("torch light varies"),
|
||||
};
|
||||
let result_storage = data.as_ptr();
|
||||
let result_storage = storage(&lights[0]);
|
||||
let snapshot = current.clone();
|
||||
current.set(8, MIN_Y, 8, STONE);
|
||||
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
|
||||
let edited_snapshot = current.clone();
|
||||
apply_lighting(&mut current, lights);
|
||||
let Light::Full(data) = ¤t.sections[0].light else {
|
||||
panic!("torch light varies");
|
||||
};
|
||||
assert_eq!(
|
||||
result_storage,
|
||||
data.as_ptr(),
|
||||
storage(¤t.sections[0].light),
|
||||
"returned light storage moves without cloning"
|
||||
);
|
||||
current.lit = true;
|
||||
|
||||
@@ -214,7 +214,12 @@ impl Mesher {
|
||||
Paint {
|
||||
layer: face.layer,
|
||||
light: span.light,
|
||||
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
|
||||
// The HDR tier, as the section mesher writes it, so a light source
|
||||
// keeps shining across the LOD boundary.
|
||||
color: crate::mesher::emitting(
|
||||
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
|
||||
context.tables.hdr_tier(span.state),
|
||||
),
|
||||
translucent: face.translucent,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
|
||||
//! frame's own (gamma) values, as vanilla's main target does, and needs a
|
||||
//! swapchain that can be copied from.
|
||||
//!
|
||||
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
|
||||
//! width and height with half the radius: the same blur, a quarter of the
|
||||
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
|
||||
//! every blurred menu ran at 3 FPS (#5185).
|
||||
|
||||
/// `blur.json`: the passes' directions, ping-ponging between the copied
|
||||
/// frame and a swap texture; the last one writes the frame.
|
||||
@@ -27,15 +32,20 @@ pub struct MenuBlur {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// One per pass, then the half-size copy's.
|
||||
uniforms: Vec<wgpu::Buffer>,
|
||||
/// The copied frame (`minecraft:main`) and `swap`.
|
||||
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
|
||||
/// frame copied at full size.
|
||||
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
/// Blur at half size (`new`'s `software`).
|
||||
half: bool,
|
||||
}
|
||||
impl MenuBlur {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
|
||||
/// the adapter renders on the CPU, so the blur runs at half size.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
|
||||
let format = format.remove_srgb_suffix();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu blur"),
|
||||
@@ -111,8 +121,7 @@ impl MenuBlur {
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = PASSES
|
||||
.iter()
|
||||
let uniforms = (0..=PASSES.len())
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
@@ -130,6 +139,15 @@ impl MenuBlur {
|
||||
targets: Vec::new(),
|
||||
size: [0, 0],
|
||||
format,
|
||||
half: software,
|
||||
}
|
||||
}
|
||||
/// The size the blur's rounds run at for a frame of `size`.
|
||||
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
|
||||
if self.half {
|
||||
size.map(|s| s.div_ceil(2))
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
@@ -137,8 +155,15 @@ impl MenuBlur {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
self.targets = (0..2)
|
||||
.map(|_| {
|
||||
let work = self.work_size(size);
|
||||
let sizes = if self.half {
|
||||
vec![work, work, size]
|
||||
} else {
|
||||
vec![work, work]
|
||||
};
|
||||
self.targets = sizes
|
||||
.into_iter()
|
||||
.map(|size| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu blur target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -180,65 +205,112 @@ impl MenuBlur {
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[0].0.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
// corner: their mean.
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
|
||||
radius: [0.0; 4],
|
||||
};
|
||||
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
PASSES.len(),
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
None,
|
||||
);
|
||||
}
|
||||
for (i, dir) in PASSES.iter().enumerate() {
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
|
||||
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
|
||||
radius: [radius, 0.0, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
|
||||
let source = &self.targets[i % 2].1;
|
||||
// The last pass writes the frame; from half size it samples up as it blurs.
|
||||
let output = if i + 1 == PASSES.len() {
|
||||
target
|
||||
} else {
|
||||
&self.targets[(i + 1) % 2].1
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[i].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
i,
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: usize,
|
||||
data: Uniform,
|
||||
source: &wgpu::TextureView,
|
||||
output: &wgpu::TextureView,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[slot].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -318,7 +390,7 @@ mod tests {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
@@ -327,6 +399,44 @@ mod tests {
|
||||
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
|
||||
}
|
||||
|
||||
/// On a software adapter the blur runs at half size and still blurs the
|
||||
/// frame much as at full size: stripes two pixels wide turn to an even grey.
|
||||
#[test]
|
||||
fn the_half_size_blur_blurs_like_the_full_size_one() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let size = [64, 8];
|
||||
let full = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, false),
|
||||
size,
|
||||
);
|
||||
let half = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, true),
|
||||
size,
|
||||
);
|
||||
// Away from the edges, both are within a few levels of the stripes' mean grey.
|
||||
for row in 0..size[1] as usize {
|
||||
for x in 8..56 {
|
||||
let at = (row * size[0] as usize + x) * 4;
|
||||
for (name, frame) in [("full", &full), ("half", &half)] {
|
||||
let v = frame[at];
|
||||
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
|
||||
|
||||
@@ -10,7 +10,7 @@ mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
|
||||
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
|
||||
pub color: u32,
|
||||
}
|
||||
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
|
||||
/// baseline instruction set makes of `f32::round`: clamped first (the same
|
||||
/// result), the fraction is exact below 2^23, and every float above is whole.
|
||||
#[inline]
|
||||
fn round_to_u16(v: f32) -> u16 {
|
||||
let v = v.clamp(0.0, f32::from(u16::MAX));
|
||||
let whole = v as u32;
|
||||
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
|
||||
}
|
||||
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
|
||||
pub const UV_STEPS: f32 = 32768.0;
|
||||
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
|
||||
@@ -82,7 +91,7 @@ impl TerrainVertex {
|
||||
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -93,7 +102,7 @@ impl TerrainVertex {
|
||||
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -116,6 +125,30 @@ mod vertex_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uv_rounding_matches_f32_round() {
|
||||
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
0.5,
|
||||
1.5,
|
||||
65534.5,
|
||||
1e9,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(9973) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in 0..=140_000 {
|
||||
values.push(i as f32 * 0.5 - 2.0);
|
||||
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn other_uvs_round_within_a_step() {
|
||||
// A flowing fluid's rotated UV.
|
||||
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
|
||||
@@ -283,7 +316,7 @@ impl SectionMesh {
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
|
||||
let mut rows = Vec::new();
|
||||
solid_rows(pad, &solid, &mut rows);
|
||||
visibility_rows(&open_rows(&rows))
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
let mut opaque_count = 0;
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let row = pi(0, y, z);
|
||||
for x in 0..16 {
|
||||
let opaque = info[pad.blocks[row + x] as usize].solid;
|
||||
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
|
||||
opaque_count += usize::from(opaque);
|
||||
}
|
||||
}
|
||||
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
|
||||
/// x), from the padded rows of `MeshScratch::solid_rows`.
|
||||
fn open_rows(solid: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
|
||||
})
|
||||
}
|
||||
/// The cells of `open` connected to `seeds` (a subset) within the row: an
|
||||
/// occluded fill each way, doubling its reach at each step.
|
||||
#[inline]
|
||||
fn fill_row(seeds: u16, open: u16) -> u16 {
|
||||
let (mut up, mut through_up) = (seeds, open);
|
||||
let (mut down, mut through_down) = (seeds, open);
|
||||
for step in [1, 2, 4, 8] {
|
||||
up |= through_up & up << step;
|
||||
through_up &= through_up << step;
|
||||
down |= through_down & down >> step;
|
||||
through_down &= through_down >> step;
|
||||
}
|
||||
if opaque_count == 0 {
|
||||
up | down
|
||||
}
|
||||
/// `visibility` on bit rows, one region at a time: a region grows a row at
|
||||
/// a time (each row filled along its open runs at once) by sweeps over the
|
||||
/// rows it can reach, until a sweep adds nothing.
|
||||
fn visibility_rows(open: &[u16; 256]) -> u64 {
|
||||
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
|
||||
if open_count == 4096 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
if opaque_count == 4096 {
|
||||
if open_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut unvisited = *open;
|
||||
let mut region = [0u16; 256];
|
||||
let mut result = 0u64;
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut faces = 0u8;
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(c) = stack.pop() {
|
||||
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
|
||||
if y == 0 {
|
||||
faces |= 1 << 0;
|
||||
for start in 0..256 {
|
||||
while unvisited[start] != 0 {
|
||||
let seed = unvisited[start] & unvisited[start].wrapping_neg();
|
||||
region[start] = fill_row(seed, unvisited[start]);
|
||||
let (mut lo, mut hi) = (start, start);
|
||||
loop {
|
||||
let mut grown = false;
|
||||
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
|
||||
for r in (first..=last).chain((first..=last).rev()) {
|
||||
let mut near = region[r];
|
||||
if r >= 16 {
|
||||
near |= region[r - 16];
|
||||
}
|
||||
if r + 16 < 256 {
|
||||
near |= region[r + 16];
|
||||
}
|
||||
if r & 15 != 0 {
|
||||
near |= region[r - 1];
|
||||
}
|
||||
if r & 15 != 15 {
|
||||
near |= region[r + 1];
|
||||
}
|
||||
let reached = near & unvisited[r];
|
||||
if reached & !region[r] != 0 {
|
||||
region[r] = fill_row(reached, unvisited[r]);
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if y == 15 {
|
||||
faces |= 1 << 1;
|
||||
}
|
||||
if z == 0 {
|
||||
faces |= 1 << 2;
|
||||
}
|
||||
if z == 15 {
|
||||
faces |= 1 << 3;
|
||||
}
|
||||
if x == 0 {
|
||||
faces |= 1 << 4;
|
||||
}
|
||||
if x == 15 {
|
||||
faces |= 1 << 5;
|
||||
let mut faces = 0u8;
|
||||
for r in lo..=hi {
|
||||
let row = std::mem::take(&mut region[r]);
|
||||
if row == 0 {
|
||||
continue;
|
||||
}
|
||||
unvisited[r] &= !row;
|
||||
let (y, z) = (r >> 4, r & 15);
|
||||
faces |= u8::from(y == 0)
|
||||
| u8::from(y == 15) << 1
|
||||
| u8::from(z == 0) << 2
|
||||
| u8::from(z == 15) << 3
|
||||
| u8::from(row & 1 != 0) << 4
|
||||
| u8::from(row & 0x8000 != 0) << 5;
|
||||
}
|
||||
// A single connected region touching all six faces sets every
|
||||
// pair (including the diagonal); unvisited cavities cannot add more.
|
||||
if faces == 0b11_1111 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
|
||||
continue;
|
||||
}
|
||||
let n = (ny << 8 | nz << 4 | nx) as usize;
|
||||
if !seen[n] {
|
||||
seen[n] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -505,6 +556,13 @@ pub struct MeshFacts {
|
||||
}
|
||||
pub struct MeshTables {
|
||||
info: Vec<Info>,
|
||||
/// `Info::solid` by state, packed apart: the opacity mask of a section
|
||||
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
|
||||
/// does not.
|
||||
solid: Vec<bool>,
|
||||
/// By state, bit `d` set where its occlusion face toward direction `d` is the
|
||||
/// full block (`Info::face` 1): a face looking at it never shows.
|
||||
full_faces: Vec<u8>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -673,6 +731,11 @@ impl MeshTables {
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
full_faces: info
|
||||
.iter()
|
||||
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
|
||||
.collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -690,6 +753,10 @@ impl MeshTables {
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// HDR output's tier of a state (`hdr_tier`), for what meshes it elsewhere (the LOD tiles).
|
||||
pub fn hdr_tier(&self, state: BlockStateId) -> u8 {
|
||||
self.info.get(state.0 as usize).map_or(0, |i| i.tier)
|
||||
}
|
||||
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
|
||||
let i = self.info.get(state.0 as usize)?;
|
||||
Some(MeshFacts {
|
||||
@@ -842,63 +909,137 @@ impl Padded {
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
// Where each chunk column's cells of this layer come from, found once.
|
||||
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| match region[rz][rx] {
|
||||
None => Source::Missing,
|
||||
Some(c) => match c.section_at(wy) {
|
||||
Ok(section) => Source::Section(section),
|
||||
Err(light) => Source::Outside(light),
|
||||
},
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
let i = pi(x, y, z);
|
||||
if let Some(c) = chunk {
|
||||
blocks[i] =
|
||||
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
{
|
||||
own[(y << 8 | z << 4 | x) as usize]
|
||||
} else {
|
||||
c.get(x & 15, wy, z & 15)
|
||||
};
|
||||
light[i] = c.light(x & 15, wy, z & 15);
|
||||
} else if let Some(c) = region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
blocks[i] = c.get(ex, wy, ez);
|
||||
light[i] = c.light(ex, wy, ez);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
}
|
||||
}
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
|
||||
let (middle, east) = rest.split_at_mut(16);
|
||||
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
|
||||
let (middle_light, east_light) = rest.split_at_mut(16);
|
||||
let own_row = (rz == 1 && (0..16).contains(&y))
|
||||
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
|
||||
let at = |x: i32| crate::chunk::index(x, wy, z);
|
||||
let [w, m, e] = &sources[rz];
|
||||
let edge = (center, wy, z);
|
||||
fill_run(west, west_light, w, None, at(-B), -B, edge);
|
||||
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
|
||||
fill_run(east, east_light, e, None, at(16), 16, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
|
||||
enum Source<'a> {
|
||||
/// A neighbour not loaded.
|
||||
Missing,
|
||||
/// Above or below the column's sections: air with this light.
|
||||
Outside(u8),
|
||||
Section(&'a crate::chunk::Section),
|
||||
}
|
||||
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
|
||||
/// at section index `at`; `own` is the centre section's row, already unpacked.
|
||||
#[inline(always)]
|
||||
fn fill_run(
|
||||
blocks: &mut [u32],
|
||||
light: &mut [u8],
|
||||
source: &Source,
|
||||
own: Option<&[u32]>,
|
||||
at: usize,
|
||||
x0: i32,
|
||||
(center, wy, z): (Option<&Chunk>, i32, i32),
|
||||
) {
|
||||
match source {
|
||||
Source::Section(section) => {
|
||||
match own {
|
||||
Some(own) => blocks.copy_from_slice(own),
|
||||
None => section.blocks.get_run(at, blocks),
|
||||
}
|
||||
section.light.copy_run(at, light);
|
||||
}
|
||||
Source::Outside(v) => {
|
||||
blocks.fill(0);
|
||||
light.fill(*v);
|
||||
}
|
||||
// A neighbour not loaded continues this chunk's edge column. As lit
|
||||
// air, the outermost chunks' water drew a wall toward it (fluids show
|
||||
// both sides), a light sheet in the water where far terrain carries
|
||||
// on. Vanilla draws no chunk without its neighbours.
|
||||
Source::Missing => match center {
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
|
||||
let ex = (x0 + k as i32).clamp(0, 15);
|
||||
*block = c.get(ex, wy, ez);
|
||||
*light = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
|
||||
/// is `isSolidRender` (`MeshTables::solid`).
|
||||
solid_rows: Vec<u32>,
|
||||
}
|
||||
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
|
||||
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
bits | u32::from(solid[id as usize]) << x
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
|
||||
/// are all `isSolidRender`: none of its faces can show.
|
||||
#[inline]
|
||||
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
|
||||
let r = row(y, z);
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
|
||||
around >> (x + B) & 1 != 0
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
#[cfg(test)]
|
||||
mod tint_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -990,6 +1131,12 @@ enum Resolver {
|
||||
/// layer plus the blend radius around it, and the blended colours per column.
|
||||
struct Layer {
|
||||
zoomed: Vec<u16>,
|
||||
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
|
||||
/// a cell is in the blend square of up to `(2r+1)^2` columns.
|
||||
cells: [Vec<u32>; 4],
|
||||
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
|
||||
/// their offset from the first: each is shared by up to 16 cells of the layer.
|
||||
jitter: Vec<Option<[[f64; 3]; 8]>>,
|
||||
colors: [[u32; 256]; 4],
|
||||
}
|
||||
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
|
||||
@@ -1005,6 +1152,8 @@ struct Tints<'a> {
|
||||
/// Chunk x, chunk z and the section's lowest block y.
|
||||
origin: [i32; 3],
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1022,6 +1171,7 @@ impl<'a> Tints<'a> {
|
||||
plains: biomes.id("minecraft:plains"),
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
@@ -1041,6 +1191,16 @@ impl<'a> Tints<'a> {
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
Self::quart_biome(region, plains, [cx, cz], q)
|
||||
}
|
||||
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
|
||||
/// there is none.
|
||||
fn quart_biome(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz]: [i32; 2],
|
||||
q: [i32; 3],
|
||||
) -> u16 {
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
@@ -1050,13 +1210,59 @@ impl<'a> Tints<'a> {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The biome every block of the section's blend squares zooms to, when they
|
||||
/// all do: `quart_at_block` picks one of the eight quart cells at its base
|
||||
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
|
||||
/// holds one biome, every zoomed cell is that biome.
|
||||
fn uniform_biome(&mut self) -> Option<u16> {
|
||||
if let Some(uniform) = self.uniform {
|
||||
return uniform;
|
||||
}
|
||||
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
|
||||
// The base cells of blocks lo..=hi, and the cell past the last.
|
||||
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
|
||||
let (xs, ys, zs) = (
|
||||
span(cx * 16 - r, cx * 16 + 15 + r),
|
||||
span(base_y, base_y + 15),
|
||||
span(cz * 16 - r, cz * 16 + 15 + r),
|
||||
);
|
||||
let first = Self::quart_biome(
|
||||
self.region,
|
||||
self.plains,
|
||||
[cx, cz],
|
||||
[*xs.start(), *ys.start(), *zs.start()],
|
||||
);
|
||||
let uniform = ys
|
||||
.flat_map(|y| zs.clone().map(move |z| (y, z)))
|
||||
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
|
||||
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
|
||||
.then_some(first);
|
||||
self.uniform = Some(uniform);
|
||||
uniform
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
// One biome over every blend square: the mean of (2r+1)^2 equal
|
||||
// colours is that colour, unless it varies by position (swamp grass).
|
||||
if let Some(biome) = self.uniform_biome() {
|
||||
let info = self.biomes.get(biome);
|
||||
match resolver {
|
||||
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
|
||||
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
|
||||
}
|
||||
Resolver::Grass => {}
|
||||
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
|
||||
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
|
||||
Resolver::Water => return info.water & 0xFF_FFFF,
|
||||
}
|
||||
}
|
||||
let r = self.radius;
|
||||
let dim = (16 + 2 * r) as usize;
|
||||
let layer = self.layers[y as usize].get_or_insert_with(|| {
|
||||
Box::new(Layer {
|
||||
zoomed: vec![NO_BIOME; dim * dim],
|
||||
cells: Default::default(),
|
||||
jitter: Vec::new(),
|
||||
colors: [[UNSET; 256]; 4],
|
||||
})
|
||||
});
|
||||
@@ -1065,21 +1271,45 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
let cells = &mut layer.cells[resolver as usize];
|
||||
if cells.is_empty() {
|
||||
cells.resize(dim * dim, UNSET);
|
||||
}
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let known = cells[cell];
|
||||
if known != UNSET {
|
||||
red += known >> 16 & 255;
|
||||
green += known >> 8 & 255;
|
||||
blue += known & 255;
|
||||
continue;
|
||||
}
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none): `quart_at_block`, its cell's
|
||||
// corner jitter mixed once per layer.
|
||||
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
|
||||
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let span = dim / 4 + 2;
|
||||
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
|
||||
if layer.jitter.is_empty() {
|
||||
layer.jitter.resize(span * span, None);
|
||||
}
|
||||
let jitter =
|
||||
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
|
||||
let corner = BiomeZoom::select_corner(block, jitter);
|
||||
let q = [
|
||||
base[0].wrapping_add((corner >> 2 & 1) as i32),
|
||||
base[1].wrapping_add((corner >> 1 & 1) as i32),
|
||||
base[2].wrapping_add((corner & 1) as i32),
|
||||
];
|
||||
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1091,6 +1321,7 @@ impl<'a> Tints<'a> {
|
||||
Resolver::Dry => self.biomes.get(biome).dry_foliage,
|
||||
Resolver::Water => self.biomes.get(biome).water,
|
||||
};
|
||||
cells[cell] = c;
|
||||
red += c >> 16 & 255;
|
||||
green += c >> 8 & 255;
|
||||
blue += c & 255;
|
||||
@@ -1116,6 +1347,8 @@ pub struct Mesher<'a> {
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
pub assets: &'a Assets,
|
||||
pub render: &'a RenderAssets,
|
||||
pub tables: &'a MeshTables,
|
||||
@@ -1144,7 +1377,9 @@ impl Mesher<'_> {
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
@@ -1160,7 +1395,9 @@ impl Mesher<'_> {
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1175,6 +1412,7 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
@@ -1193,17 +1431,16 @@ impl Mesher<'_> {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.solid
|
||||
&& Direction::ALL.iter().all(|d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
|
||||
})
|
||||
{
|
||||
if hidden(opaque, x, y, z) {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let shade = (self.nether_shading, self.shader_pack_lighting);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
self.separate_ao,
|
||||
);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
@@ -1237,6 +1474,11 @@ impl Mesher<'_> {
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
@@ -1346,27 +1588,57 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
let (gray, coords) = if ao {
|
||||
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
|
||||
let (gray, coords, ao_factor) = if ao {
|
||||
let info = &self.tables.info;
|
||||
let (brightness, coords) =
|
||||
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
|
||||
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
|
||||
(gray, coords)
|
||||
let gray = if self.separate_ao {
|
||||
[lighting::gray(directional); 4]
|
||||
} else {
|
||||
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
|
||||
};
|
||||
(gray, coords, brightness)
|
||||
} else {
|
||||
let coords = match q.cull {
|
||||
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
|
||||
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
|
||||
};
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
|
||||
// drawn over it (a few per face at most; more are dropped).
|
||||
let connected = &self.render.connected;
|
||||
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
|
||||
// and the overlays drawn over it (a few per face at most; more are dropped).
|
||||
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let pick = if connected.is_empty() {
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour =
|
||||
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
id,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
q.tint_index,
|
||||
neighbour,
|
||||
|s| {
|
||||
self.tables
|
||||
.info
|
||||
.get(s as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
});
|
||||
let pick = if let Some(pick) = fused {
|
||||
pick
|
||||
} else if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
@@ -1375,14 +1647,17 @@ impl Mesher<'_> {
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
neighbour,
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
if state.pane {
|
||||
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
|
||||
} else {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
|
||||
@@ -1393,12 +1668,18 @@ impl Mesher<'_> {
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
let color = if self.separate_ao {
|
||||
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
|
||||
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
|
||||
} else {
|
||||
emitting(pack_rgb(rgb), tier)
|
||||
};
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
color,
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
uv[i],
|
||||
@@ -1452,6 +1733,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Pick::Nothing => {}
|
||||
}
|
||||
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
|
||||
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
|
||||
@@ -1464,6 +1746,7 @@ impl Mesher<'_> {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
|
||||
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
|
||||
/// the faces (`Mesher::shader_pack_lighting`).
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting): (bool, bool),
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
pad: &Padded,
|
||||
water_color: u32,
|
||||
out: &mut SectionMesh,
|
||||
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
if separate_ao {
|
||||
pack_rgb(rgb)
|
||||
} else {
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
}
|
||||
};
|
||||
let height = |p: [i32; 3]| -> f32 {
|
||||
let s = at(p);
|
||||
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
|
||||
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
|
||||
sprite_layers.push(layer);
|
||||
}
|
||||
let color = fluid_face(color, face);
|
||||
let color = if separate_ao {
|
||||
color
|
||||
} else {
|
||||
fluid_face(color, face)
|
||||
};
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
if back {
|
||||
|
||||
@@ -72,6 +72,7 @@ fn mesh(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
|
||||
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
|
||||
};
|
||||
channel(0) | channel(16) << 16
|
||||
}
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
|
||||
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
|
||||
/// one lower where that rounded a negative value up (saturating as `as` does).
|
||||
pub(super) fn gray(brightness: f32) -> i32 {
|
||||
(brightness * 255.0).floor() as i32
|
||||
let v = brightness * 255.0;
|
||||
let whole = v as i32;
|
||||
if whole as f32 > v {
|
||||
whole.saturating_sub(1)
|
||||
} else {
|
||||
whole
|
||||
}
|
||||
}
|
||||
/// One channel of `ARGB.scaleRGB(color, float)`.
|
||||
pub(super) fn scale(channel: i32, by: f32) -> i32 {
|
||||
@@ -406,6 +414,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gray_floors_like_f32_floor() {
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
1e10,
|
||||
-1e10,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(7919) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in -3000..3000 {
|
||||
values.push(i as f32 / 1024.0);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn colour_arithmetic_matches_argb_helpers() {
|
||||
assert_eq!(gray(1.0), 255);
|
||||
assert_eq!(gray(0.8), 204);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::chunk::Light;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
@@ -45,6 +46,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([0; 4096]);
|
||||
for min_y in [MIN_Y, 0] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
|
||||
// Nine chunks with their own blocks and per-block light, near the
|
||||
// bottom and top sections too, where the padding leaves the column.
|
||||
let chunks: Vec<Chunk> = (0..9)
|
||||
.map(|i| {
|
||||
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
|
||||
for s in [0, 1, 30, 31] {
|
||||
for _ in 0..300 {
|
||||
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
|
||||
let y = min_y + s * 16 + y as i32;
|
||||
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
|
||||
}
|
||||
let mut light = Box::new([0u8; 4096]);
|
||||
light.iter_mut().for_each(|l| *l = next() as u8);
|
||||
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
|
||||
Light::Full(light);
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
|
||||
for sy in [0, 1, 30, 31] {
|
||||
pad.fill(®ion, sy, &mut own);
|
||||
let base_y = min_y + sy * 16;
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
|
||||
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
|
||||
assert_eq!(
|
||||
pad.blocks[pi(x, y, z)],
|
||||
c.get(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
assert_eq!(
|
||||
pad.light[pi(x, y, z)],
|
||||
c.light(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
|
||||
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
|
||||
@@ -72,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
@@ -98,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
// Place stone with neighbouring blocks to create AO variations.
|
||||
chunk.set(8, MIN_Y + 8, 8, stone);
|
||||
chunk.set(9, MIN_Y + 8, 8, stone);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
assert!(!mesh.solid.is_empty());
|
||||
for v in &mesh.solid {
|
||||
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
|
||||
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
|
||||
// Alpha holds the AO factor, which must be non-zero.
|
||||
assert!(v.color >> 24 > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
@@ -149,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
@@ -181,7 +291,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
assert_eq!(scratch.solid_rows.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -203,13 +203,37 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
|
||||
/// cutout quads are also ordered front to back within each axis-facing
|
||||
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
|
||||
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
|
||||
/// sort key), so coplanar overlays still draw over their base.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
/// nothing: each quad's part, its sort key, and the sorted quads, which are
|
||||
/// copied back into the mesh's own (recycled) buffer.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
}
|
||||
|
||||
/// `f32::total_cmp`'s order as an unsigned integer's.
|
||||
fn total_order_key(v: f32) -> u32 {
|
||||
let bits = v.to_bits() as i32;
|
||||
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
|
||||
}
|
||||
|
||||
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
|
||||
@@ -218,15 +242,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
true => b + 7 * usize::from(cutout),
|
||||
false => b * 2 + usize::from(cutout),
|
||||
};
|
||||
let slots: Vec<u8> = quads
|
||||
.iter()
|
||||
.map(|q| position(bucket(q), cutout(q)) as u8)
|
||||
.collect();
|
||||
let slots = &mut scratch.slots;
|
||||
slots.clear();
|
||||
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
|
||||
let has_cutout = quads.iter().any(cutout);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut slot_counts = [0u32; 14];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &part) in quads.iter().zip(&slots) {
|
||||
for (quad, &part) in quads.iter().zip(slots.iter()) {
|
||||
slot_counts[usize::from(part)] += 1;
|
||||
let b = slot(grouped, usize::from(part)).0;
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
@@ -246,33 +269,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
*end = total;
|
||||
}
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &part) in quads.iter().zip(&slots) {
|
||||
let at = &mut next[usize::from(part)];
|
||||
sorted[*at as usize] = *quad;
|
||||
*at += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (s, &end) in slot_ends.iter().enumerate() {
|
||||
let end = end as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
// One integer key per quad: its part, then (cutout meshes, side
|
||||
// buckets) its plane from the outside in, then its index, which keeps
|
||||
// the order of equal quads as a stable sort does.
|
||||
let keys = &mut scratch.keys;
|
||||
keys.clear();
|
||||
keys.extend(
|
||||
quads
|
||||
.iter()
|
||||
.zip(slots.iter())
|
||||
.enumerate()
|
||||
.map(|(i, (quad, &part))| {
|
||||
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
|
||||
Bucket::Side(axis, positive) if has_cutout => {
|
||||
let key = total_order_key(quad[0].pos[axis]);
|
||||
if positive { !key } else { key }
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
_ => 0,
|
||||
};
|
||||
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
|
||||
}),
|
||||
);
|
||||
debug_assert!(quads.len() < 1 << 24);
|
||||
keys.sort_unstable();
|
||||
let sorted = &mut scratch.sorted;
|
||||
sorted.clear();
|
||||
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
|
||||
solid.clear();
|
||||
solid.extend_from_slice(sorted.as_flattened());
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
@@ -286,6 +310,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sort as it was before the integer keys: quads moved to their
|
||||
/// parts in order, then a stable sort by plane in each side bucket.
|
||||
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut sorted = Vec::new();
|
||||
for n in 0..14 {
|
||||
let (b, cutout) = slot(grouped, n);
|
||||
let mut part: Vec<[TerrainVertex; 4]> = quads
|
||||
.iter()
|
||||
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
|
||||
.copied()
|
||||
.collect();
|
||||
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
|
||||
part.sort_by(|a, b| {
|
||||
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
sorted.extend(part);
|
||||
}
|
||||
sorted.into_flattened()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_keys_sort_as_the_stable_sort_did() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for round in 0..100 {
|
||||
let grouped = round % 2 == 1;
|
||||
let mut solid = Vec::new();
|
||||
for _ in 0..(next() % 300) {
|
||||
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
|
||||
// Some negative and signed-zero planes too.
|
||||
let origin = if next() % 9 == 0 {
|
||||
origin.map(|v| -v)
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
let layer = if round % 3 == 0 {
|
||||
OPAQUE_LAYER
|
||||
} else {
|
||||
(next() % 2) as u16 * OPAQUE_LAYER
|
||||
};
|
||||
let faces = cube(origin, layer);
|
||||
let face = (next() % 6) as usize;
|
||||
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
|
||||
}
|
||||
let expected = reference(&solid, grouped);
|
||||
let sides = sort_parts(&mut solid, grouped);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&solid),
|
||||
bytemuck::cast_slice::<_, u8>(&expected),
|
||||
"round {round}"
|
||||
);
|
||||
assert_eq!(sides.quads() as usize * 4, solid.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
|
||||
//! prepared biome data, like the other mesher tests that load resources.
|
||||
use super::*;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
Biomes::load(&root).unwrap()
|
||||
}
|
||||
|
||||
/// A chunk whose every quart cell holds `biome`.
|
||||
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..(384 / 4) {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
let biomes = biomes();
|
||||
let desert = biomes.id("minecraft:desert");
|
||||
let (cx, cz) = (3, -2);
|
||||
// Grass without and with the dark forest modifier, swamp noise, and
|
||||
// biomes whose foliage, dry foliage and water differ from the defaults.
|
||||
for name in [
|
||||
"minecraft:plains",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:warm_ocean",
|
||||
"minecraft:badlands",
|
||||
] {
|
||||
let biome = biomes.id(name);
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| {
|
||||
(0..3)
|
||||
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// One desert column in the east neighbour, next to the section's edge,
|
||||
// where every blend radius reaches it.
|
||||
let mut east = filled(cx + 1, cz, biome);
|
||||
for qy in 0..(384 / 4) {
|
||||
east.set_biome_cell(0, qy, 2, desert);
|
||||
}
|
||||
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
|
||||
let uniform: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
|
||||
let mut mixed = uniform;
|
||||
mixed[1][2] = Some(&east);
|
||||
// A missing neighbour reads as plains.
|
||||
let mut missing = uniform;
|
||||
missing[0][0] = None;
|
||||
for (region, one_biome) in [
|
||||
(&uniform, true),
|
||||
(&mixed, false),
|
||||
(&missing, name == "minecraft:plains"),
|
||||
] {
|
||||
for base_y in [MIN_Y, 0, 304] {
|
||||
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
|
||||
let new = || {
|
||||
Tints::new(
|
||||
region,
|
||||
&biomes,
|
||||
BiomeZoom::new(seed),
|
||||
radius,
|
||||
[cx, cz, base_y],
|
||||
)
|
||||
};
|
||||
let mut fast = new();
|
||||
let mut full = new();
|
||||
full.uniform = Some(None);
|
||||
assert_eq!(
|
||||
fast.uniform_biome().is_some(),
|
||||
one_biome,
|
||||
"{name} r{radius} y{base_y}"
|
||||
);
|
||||
for resolver in [
|
||||
Resolver::Grass,
|
||||
Resolver::Foliage,
|
||||
Resolver::Dry,
|
||||
Resolver::Water,
|
||||
] {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(
|
||||
fast.get(resolver, x, y, z),
|
||||
full.get(resolver, x, y, z),
|
||||
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
|
||||
resolver as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
fn row_fill_matches_a_cell_by_cell_fill() {
|
||||
let naive = |seeds: u16, open: u16| {
|
||||
let mut fill = seeds;
|
||||
loop {
|
||||
let next = (fill | fill << 1 | fill >> 1) & open;
|
||||
if next == fill {
|
||||
return fill;
|
||||
}
|
||||
fill = next;
|
||||
}
|
||||
};
|
||||
let mut state = 0x9E37_79B9u32;
|
||||
for _ in 0..200_000 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let open = state as u16;
|
||||
let seeds = (state >> 16) as u16 & open;
|
||||
assert_eq!(
|
||||
fill_row(seeds, open),
|
||||
naive(seeds, open),
|
||||
"{open:016b} {seeds:016b}"
|
||||
);
|
||||
}
|
||||
// Several seeds in one run, the case a carry-based fill gets wrong.
|
||||
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
|
||||
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fill_follows_open_runs() {
|
||||
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
|
||||
for bit in 0..16 {
|
||||
let seed = 1u16 << bit;
|
||||
if open & seed == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut expected = seed;
|
||||
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mazes: one long winding cavity, and many small ones, where the row
|
||||
/// sweeps are slowest.
|
||||
#[test]
|
||||
fn winding_and_scattered_cavities_match_reference() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let serpentine = fixture(|x, y, z| {
|
||||
let lane = z % 4 == 0 && y % 4 == 0;
|
||||
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
|
||||
let climb = x == 0 && z == 0 && y % 4 != 0;
|
||||
!(lane || (turn && y % 4 == 0) || climb)
|
||||
});
|
||||
assert_eq!(
|
||||
visibility(&serpentine, &info),
|
||||
reference(&serpentine, &info)
|
||||
);
|
||||
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
|
||||
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -522,6 +522,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
let mut world = world_with(
|
||||
(-2..=2)
|
||||
@@ -609,6 +610,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
// Grass everywhere, one stone block 10 blocks north of the player.
|
||||
let mut world = world_with(
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct BakedFace {
|
||||
pub opaque: bool,
|
||||
/// Fractional alpha: drawn in the sorted translucent pass.
|
||||
pub translucent: bool,
|
||||
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
|
||||
/// two bytes, within the padding every face already has.
|
||||
pub fusion: Option<std::num::NonZeroU16>,
|
||||
}
|
||||
pub struct BakedModel {
|
||||
pub ambient_occlusion: bool,
|
||||
@@ -90,6 +93,7 @@ fn bake_resolved(
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
fusion: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
@@ -343,6 +347,12 @@ impl StateModels {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Every baked model with the variant it was baked from, to change its faces.
|
||||
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
|
||||
self.baked
|
||||
.iter_mut()
|
||||
.map(|(variant, model)| (&*variant, model))
|
||||
}
|
||||
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
|
||||
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
|
||||
let &i = self.standalone.get(name)?;
|
||||
|
||||
@@ -119,6 +119,7 @@ mod tests {
|
||||
layer: 7,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
let Some(styles) = cube_styles(&faces) else {
|
||||
@@ -153,6 +154,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
faces[0].opaque = false;
|
||||
|
||||
@@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
|
||||
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
|
||||
fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_prefix("assets/")?;
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
|
||||
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
@@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
for layer in sprites.layers.values() {
|
||||
*sharing.entry(*layer).or_default() += 1;
|
||||
}
|
||||
let bases: Vec<String> = sprites
|
||||
let mut bases: Vec<String> = sprites
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|(name, layer)| {
|
||||
sharing[layer] == 1
|
||||
&& name
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
|
||||
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
|
||||
let mut pairs = Vec::new();
|
||||
for base in bases {
|
||||
let overlay = format!("{base}{suffix}");
|
||||
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
|
||||
// A tile is named by its file, a block sprite by its id.
|
||||
let (overlay, file) = match tile_file(&base) {
|
||||
Some(stem) => {
|
||||
let overlay = format!("{stem}{suffix}.png");
|
||||
(overlay.clone(), root.join(overlay))
|
||||
}
|
||||
None => {
|
||||
let overlay = format!("{base}{suffix}");
|
||||
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
|
||||
(overlay, file)
|
||||
}
|
||||
};
|
||||
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
@@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
continue;
|
||||
}
|
||||
// A file that doesn't load would glow as the missing texture: no pair instead.
|
||||
match sprites.try_insert(root, &overlay) {
|
||||
let loaded = if overlay.starts_with("assets/") {
|
||||
sprites.insert_file(root, &overlay)
|
||||
} else {
|
||||
sprites.try_insert(root, &overlay)
|
||||
};
|
||||
match loaded {
|
||||
Ok(_) => pairs.push((base, overlay)),
|
||||
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
|
||||
}
|
||||
@@ -192,13 +218,40 @@ mod tests {
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
png(&tiles.join("0_glow.png"), 240);
|
||||
png(&tiles.join("1.png"), 250);
|
||||
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
|
||||
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
|
||||
png(
|
||||
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
|
||||
246,
|
||||
);
|
||||
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
|
||||
sprites
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
"minecraft:block/deepslate".into(),
|
||||
"minecraft:block/deepslate_glow".into()
|
||||
)]
|
||||
// Blocks first, then tiles.
|
||||
[
|
||||
(
|
||||
"minecraft:block/deepslate".into(),
|
||||
"minecraft:block/deepslate_glow".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
|
||||
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/glass/2.png".into(),
|
||||
"assets/minecraft/optifine/glass/2_glow.png".into()
|
||||
),
|
||||
]
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -1264,7 +1264,7 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
/// Splitscreen: the monitor map and draft window arrangement.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1308,6 +1308,10 @@ enum Act {
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
/// AI Agent: start or stop the repro recording.
|
||||
ToggleRecording,
|
||||
/// AI Agent: write the repro bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -1435,6 +1439,10 @@ pub enum Event {
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The AI Agent page's recording button: start or stop.
|
||||
ToggleRecording,
|
||||
/// The AI Agent page's export button.
|
||||
ExportRepro,
|
||||
/// The narrator option changed (after its `Changed`): the option's
|
||||
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
|
||||
Narrator,
|
||||
@@ -1512,10 +1520,17 @@ pub struct OptionsUi {
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// A repro recording is running: the AI Agent page's button says stop.
|
||||
pub agent_recording: bool,
|
||||
/// Keyboard focus: the interactive target currently focused.
|
||||
focus: Option<FocusId>,
|
||||
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
|
||||
/// from anywhere (`wheel`). A click focuses its widget too, for the highlight,
|
||||
/// but vanilla's wheel ignores that: only the hovered widget then reacts.
|
||||
focus_from_keyboard: bool,
|
||||
/// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
|
||||
pub(crate) can_change_value: bool,
|
||||
/// The current GUI size (w, h).
|
||||
@@ -1528,8 +1543,6 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// Other local players are joined: leave before turning splitscreen off.
|
||||
pub split_joined: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1568,14 +1581,16 @@ impl Default for OptionsUi {
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
agent_recording: false,
|
||||
focus: None,
|
||||
focus_from_keyboard: false,
|
||||
can_change_value: false,
|
||||
size: (427.0, 240.0),
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2093,6 +2108,7 @@ impl OptionsUi {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
@@ -2103,6 +2119,7 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
self.scrollbar_drag_y = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
|
||||
self.settings_search.set_value("");
|
||||
@@ -2122,6 +2139,9 @@ impl OptionsUi {
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
@@ -2262,14 +2282,7 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => vec![Row::Buttons(
|
||||
if self.split_guest || self.split_joined {
|
||||
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
|
||||
} else {
|
||||
self.extra_button(s, crate::extras::Item::Splitscreen)
|
||||
},
|
||||
None,
|
||||
)],
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -2376,6 +2389,30 @@ impl OptionsUi {
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr(if self.agent_recording {
|
||||
"horizon.agent.record_stop"
|
||||
} else {
|
||||
"horizon.agent.record_session"
|
||||
}),
|
||||
Act::ToggleRecording,
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.agent.export_repro"),
|
||||
Act::ExportRepro,
|
||||
)),
|
||||
));
|
||||
rows.push(Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::ReproKeys),
|
||||
None,
|
||||
));
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
@@ -3346,6 +3383,11 @@ impl OptionsUi {
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
@@ -3381,6 +3423,9 @@ impl OptionsUi {
|
||||
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
|
||||
}
|
||||
let mut tooltip = None;
|
||||
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
|
||||
// keeps it against that widget, not at the pointer.
|
||||
let mut tooltip_rect: Option<[f32; 4]> = None;
|
||||
for (wd_idx, wd) in widgets.iter().enumerate() {
|
||||
if !visible(wd) {
|
||||
continue;
|
||||
@@ -3390,9 +3435,9 @@ impl OptionsUi {
|
||||
.as_ref()
|
||||
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
|
||||
let start = batch.vertices.len();
|
||||
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|
||||
|| is_focused;
|
||||
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
|
||||
let hover = pointer_over || is_focused;
|
||||
// A pack's option: its label within the widget (the name shortened), and on hover
|
||||
// the whole label when it was, then what the pack says of it.
|
||||
let shader = s.shader_options.as_ref();
|
||||
@@ -3436,6 +3481,7 @@ impl OptionsUi {
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
tooltip = Some(lines);
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
}
|
||||
if hover && let Some(tip) = tooltip_text(s, wd) {
|
||||
@@ -3450,6 +3496,7 @@ impl OptionsUi {
|
||||
.collect::<Vec<_>>(),
|
||||
_ => atlas.font.split(&tip, 170.0),
|
||||
});
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, .. } => {
|
||||
@@ -3621,7 +3668,10 @@ impl OptionsUi {
|
||||
self.settings_search = search;
|
||||
}
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
match tooltip_rect {
|
||||
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
|
||||
None => ui::tooltip(batch, atlas, &lines, cursor),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Mouse press/drag. Returns events for the game.
|
||||
@@ -3646,6 +3696,26 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
use crate::split_ui::Click;
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
return match self.split_setup.click((x, y), size, drag) {
|
||||
Click::Nothing => Vec::new(),
|
||||
Click::Pressed => vec![Event::ButtonClick],
|
||||
Click::Switch => {
|
||||
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
}
|
||||
Click::Done => {
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
use crate::split_profiles::Click;
|
||||
if right || drag {
|
||||
@@ -3750,6 +3820,7 @@ impl OptionsUi {
|
||||
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
|
||||
if search.focused {
|
||||
self.focus = Some(FocusId::Search);
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
search.click(atlas, x, false, false);
|
||||
self.scroll = 0.0;
|
||||
@@ -3762,6 +3833,7 @@ impl OptionsUi {
|
||||
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
|
||||
if search.focused {
|
||||
self.focus = Some(FocusId::Search);
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
search.click(atlas, x, false, false);
|
||||
self.scroll = 0.0;
|
||||
@@ -3875,7 +3947,10 @@ impl OptionsUi {
|
||||
if wd.list && (y < top || y >= bottom) {
|
||||
continue;
|
||||
}
|
||||
// A click focuses the widget for its highlight only: vanilla's wheel
|
||||
// steps a keyboard-focused widget from anywhere, not a clicked one.
|
||||
self.focus = Some(focus_id(wd_idx, &wd));
|
||||
self.focus_from_keyboard = false;
|
||||
match wd.kind {
|
||||
WKind::Button {
|
||||
active: true, act, ..
|
||||
@@ -3994,6 +4069,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -4049,8 +4126,11 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -4095,15 +4175,19 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
|
||||
/// over a slider it nudges the slider up or down.
|
||||
/// The mouse wheel at the cursor. Vanilla scrolls the list
|
||||
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered cycle button
|
||||
/// takes the step and keeps the wheel (`CycleButton.mouseScrolled`: up goes
|
||||
/// back); `AbstractSliderButton` does not override it, so a slider under the
|
||||
/// cursor lets the list scroll.
|
||||
///
|
||||
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
|
||||
/// cycle button is keyboard-focused (highlighted), the wheel steps or
|
||||
/// cycles that widget regardless of where the cursor is. Vanilla 26.3
|
||||
/// does not intercept the wheel for sliders at all; this makes keyboard
|
||||
/// navigation far more usable.
|
||||
/// **Horizon improvement (diverges from Vanilla 26.3):** a slider or cycle
|
||||
/// button focused by the keyboard (Tab, the arrows, a pad) steps or cycles on
|
||||
/// the wheel wherever the cursor is. Vanilla 26.3 does not intercept the wheel
|
||||
/// for sliders at all; this makes keyboard navigation far more usable. Focus
|
||||
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
|
||||
/// ignores it, and honouring it toggled the clicked option on every later wheel
|
||||
/// event, from anywhere on the screen, while the list never scrolled.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -4149,13 +4233,17 @@ impl OptionsUi {
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !plain || !s.extras.wheel_cycles_settings {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
// Horizon improvement: a keyboard-focused slider or cycle button
|
||||
// intercepts the wheel before hover detection (see doc-comment above).
|
||||
if amount != 0.0 {
|
||||
// A click focuses its widget for the highlight too, and vanilla's wheel
|
||||
// must not act on that: it would toggle the clicked option from anywhere
|
||||
// on the screen and the list would never scroll.
|
||||
if amount != 0.0 && self.focus_from_keyboard {
|
||||
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
|
||||
match &self.focus {
|
||||
Some(FocusId::Slider(opt)) => {
|
||||
@@ -4341,6 +4429,7 @@ impl OptionsUi {
|
||||
self.selected_key = None;
|
||||
self.selected_pad_action = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.settings_search.focused = false;
|
||||
self.language_search.focused = false;
|
||||
self.can_change_value = false;
|
||||
@@ -4684,6 +4773,7 @@ impl OptionsUi {
|
||||
};
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_));
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4722,6 +4812,7 @@ impl OptionsUi {
|
||||
if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4735,6 +4826,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4757,6 +4849,7 @@ impl OptionsUi {
|
||||
if let Some(next_idx) = step {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4774,6 +4867,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4797,6 +4891,7 @@ impl OptionsUi {
|
||||
match act {
|
||||
Act::Push(p) => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
if p == Page::ShaderPacks {
|
||||
events.push(Event::ListShaderPacks);
|
||||
@@ -4805,6 +4900,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
@@ -4844,6 +4940,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
@@ -4868,6 +4965,7 @@ impl OptionsUi {
|
||||
// The shader options page's buttons, as a click does them.
|
||||
Act::ShaderOptions => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
s.shader_options = None;
|
||||
self.push(Page::ShaderOptions);
|
||||
@@ -4885,6 +4983,7 @@ impl OptionsUi {
|
||||
Act::ShaderScreen(screen) => {
|
||||
if let Some(options) = &mut s.shader_options {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
options.path.push(screen);
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
@@ -4917,6 +5016,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -5345,6 +5446,61 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
|
||||
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
|
||||
let s = Settings::default();
|
||||
lang::scoped(lang::Language::load(&[], code), || {
|
||||
let mut texts = Vec::new();
|
||||
for page in pages {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(*page);
|
||||
for w in ui.layout(&s, 480.0, 4000.0).0 {
|
||||
match w.kind {
|
||||
WKind::Button { label: t, .. }
|
||||
| WKind::Slider { label: t, .. }
|
||||
| WKind::Text { text: t, .. } => texts.push(t.plain()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
|
||||
/// "Hotbar: Previous Slot" in every language (#1927).
|
||||
#[test]
|
||||
fn horizon_option_pages_follow_the_language() {
|
||||
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Splitscreen …",
|
||||
"Partikel",
|
||||
"Blockpartikel weg: Aus",
|
||||
"Weicher Chat: Aus",
|
||||
"Nachrichtenmarkierung: An",
|
||||
"Kanäle für UI-Töne: Aus",
|
||||
"Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Schnellzugriffsleiste: Nächster Platz",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
|
||||
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
|
||||
// language does not have.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Ukryj cząstki bloków: Wył.",
|
||||
"Pasek szybkiego wyboru: poprzednie pole",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
@@ -7092,6 +7248,129 @@ mod tests {
|
||||
assert_eq!(s.horizon.chunk_updates, 11);
|
||||
}
|
||||
|
||||
/// Vanilla's wheel scrolls the list (`AbstractScrollArea.mouseScrolled`); only a
|
||||
/// widget focused by the keyboard takes the step from anywhere on the screen.
|
||||
/// A click focuses its widget for the highlight, and honouring that focus made a
|
||||
/// clicked option toggle on every later wheel event while the list never scrolled.
|
||||
#[test]
|
||||
fn a_clicked_option_lets_the_wheel_scroll_the_list() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let click_size = (427.0, 400.0);
|
||||
let (widgets, click_top, click_bottom, _) = ui.layout(&s, click_size.0, click_size.1);
|
||||
|
||||
// A click leaves the widget focused for its highlight, not for the wheel.
|
||||
let wd = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Cycle(_),
|
||||
..
|
||||
}
|
||||
) && (!wd.list || (wd.y >= click_top && wd.y + wd.h <= click_bottom))
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
wd.x + 5.0,
|
||||
wd.y + 5.0,
|
||||
click_size,
|
||||
framebuffer,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(ui.focus, Some(FocusId::Cycle(_))),
|
||||
"the click focuses the cycle button it hit"
|
||||
);
|
||||
assert!(
|
||||
!ui.focus_from_keyboard,
|
||||
"a click does not leave keyboard focus"
|
||||
);
|
||||
|
||||
// The wheel over the list's empty margin scrolls it at a size where the
|
||||
// page overflows, and the clicked cycle option stays put: no ButtonClick,
|
||||
// no Changed.
|
||||
let size = (427.0, 240.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let before = s.view_bobbing;
|
||||
let start = ui.scroll;
|
||||
let cursor = (2.0, (top + bottom) / 2.0);
|
||||
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
|
||||
assert_eq!(s.view_bobbing, before, "the clicked option does not toggle");
|
||||
assert!(!events.contains(&Event::ButtonClick), "no click reaches it");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(ui.scroll > start, "the list scrolls down instead");
|
||||
|
||||
// Keyboard focus keeps the documented divergence: the wheel steps it
|
||||
// wherever the cursor is, even off the widget.
|
||||
ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
|
||||
ui.focus_from_keyboard = true;
|
||||
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
|
||||
assert_ne!(s.view_bobbing, before, "a keyboard-focused option cycles");
|
||||
assert!(events.contains(&Event::ButtonClick));
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_only_scrolls_with_the_extra_off() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let size = (427.0, 240.0);
|
||||
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let (opt, over) = widgets
|
||||
.iter()
|
||||
.find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
|
||||
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let before = format!("{s:?}");
|
||||
let start = ui.scroll;
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(
|
||||
ui.scroll > start,
|
||||
"the list scrolls under the cursor instead"
|
||||
);
|
||||
// Keyboard focus doesn't take the wheel either.
|
||||
ui.focus = Some(FocusId::Cycle(opt));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before);
|
||||
|
||||
// On (the default), vanilla's CycleButton.mouseScrolled again.
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
let before = format!("{s:?}");
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
|
||||
let stepped_opts = [
|
||||
|
||||
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
|
||||
/// as RGB and reach, and its alpha as the intensity.
|
||||
pub fn lights(
|
||||
&self,
|
||||
entries: &[horizon_shader_api::manifest::ParticleLight],
|
||||
partial: f32,
|
||||
) -> Vec<([f64; 3], u32, f32, f32)> {
|
||||
let shining: Vec<(ParticleType, u32, f32)> = entries
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
|
||||
e.particles.iter().filter_map(move |name| {
|
||||
let name = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
Some((
|
||||
ParticleType::from_name(&name)?,
|
||||
r << 16 | g << 8 | b,
|
||||
e.reach,
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if shining.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = f64::from(partial);
|
||||
self.particles
|
||||
.iter()
|
||||
.filter(|p| p.rgba[3] > 0.0)
|
||||
.filter_map(|p| {
|
||||
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
|
||||
let at =
|
||||
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
|
||||
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
pub fn render(
|
||||
&self,
|
||||
|
||||
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
|
||||
/// in it stays a character, as `FormattedCharSequence.forward`
|
||||
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
|
||||
/// builds its own sequence that way, so a `§` in its value shows.
|
||||
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
|
||||
if (color as u32) >> 24 != 0 {
|
||||
out.push(Draw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
|
||||
/// with their index in it and their style; legacy `§` codes restyle what
|
||||
@@ -186,6 +201,11 @@ impl Measure<'_> {
|
||||
pub fn width(&self, t: &Text) -> i32 {
|
||||
self.0.width_text(&t.resolve_legacy()) as i32
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
|
||||
/// `§` in the text counts as the glyph it is drawn as.
|
||||
pub fn width_drawn(&self, t: &Text) -> i32 {
|
||||
self.0.width_drawn(t) as i32
|
||||
}
|
||||
pub fn width_str(&self, s: &str) -> i32 {
|
||||
self.0.width(s) as i32
|
||||
}
|
||||
@@ -864,8 +884,8 @@ impl TextField {
|
||||
displayed.clone()
|
||||
};
|
||||
let t = styled(half, self.display);
|
||||
let w = m.width(&t);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
let w = m.width_drawn(&t);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
draw_x += w + 1;
|
||||
}
|
||||
let insert = self.cursor < len || len >= self.max_length;
|
||||
@@ -878,7 +898,7 @@ impl TextField {
|
||||
}
|
||||
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
|
||||
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
}
|
||||
if let Some(hint) = &self.hint
|
||||
&& displayed.is_empty()
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct ScreenQuad {
|
||||
pub blend: Blend,
|
||||
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
|
||||
pub sampling: u32,
|
||||
/// A tint of the whole view (the underwater overlay), not something in front of it: in HDR
|
||||
/// output the world shines through it (`Renderer::draw_overlay`).
|
||||
pub world_tint: bool,
|
||||
}
|
||||
|
||||
/// What the camera sees this frame.
|
||||
@@ -160,6 +163,7 @@ impl ScreenEffects {
|
||||
}),
|
||||
blend: Blend::Alpha,
|
||||
sampling,
|
||||
world_tint: false,
|
||||
};
|
||||
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
|
||||
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
|
||||
@@ -180,13 +184,16 @@ impl ScreenEffects {
|
||||
{
|
||||
let (u, v) = (-yaw / 64.0, pitch / 64.0);
|
||||
let uv = [[u + 4.0, v + 4.0], [u, v + 4.0], [u, v], [u + 4.0, v]];
|
||||
out.push(hand_quad(
|
||||
full,
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
));
|
||||
out.push(ScreenQuad {
|
||||
world_tint: true,
|
||||
..hand_quad(
|
||||
full,
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
)
|
||||
});
|
||||
}
|
||||
if input.on_fire
|
||||
&& let Some(rect) = sprite("block/fire_1")
|
||||
@@ -224,6 +231,7 @@ impl ScreenEffects {
|
||||
}),
|
||||
blend,
|
||||
sampling,
|
||||
world_tint: false,
|
||||
};
|
||||
let rgba = |c: u32| {
|
||||
let [a, r, g, b] = c.to_be_bytes();
|
||||
@@ -397,6 +405,9 @@ mod tests {
|
||||
});
|
||||
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
|
||||
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
|
||||
// Only the underwater overlay tints the world; the block in the way covers it.
|
||||
let tints: Vec<_> = quads.iter().map(|q| q.world_tint).collect();
|
||||
assert_eq!(tints, [false, true, false]);
|
||||
}
|
||||
#[test]
|
||||
fn mcmeta_texture_section() {
|
||||
|
||||
@@ -222,10 +222,14 @@ fn component_text(tag: &Tag) -> String {
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// Whether a block name is a sign (standing, wall, hanging or wall hanging).
|
||||
/// Whether a vanilla block's name is a sign (standing, wall, hanging or wall
|
||||
/// hanging). A mod's sign goes by its template's class, whatever its name; a
|
||||
/// mod's `road_sign` of stone is no sign.
|
||||
pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
if name.contains(':') {
|
||||
None
|
||||
} else if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
Some(true)
|
||||
} else if name == "sign" || name.ends_with("_sign") {
|
||||
Some(false)
|
||||
@@ -233,15 +237,17 @@ pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Wood type of a sign block (`SignBlock.getWoodType`), for the editor texture.
|
||||
pub fn wood_type(name: &str) -> &str {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
for suffix in ["_wall_hanging_sign", "_hanging_sign", "_wall_sign", "_sign"] {
|
||||
if let Some(wood) = name.strip_suffix(suffix) {
|
||||
return wood;
|
||||
/// The sprite of a wood type's editor background, `folder` being `signs` or
|
||||
/// `hanging_signs`: `textures/gui/<folder>/<wood>.png` (`SignEditScreen`), in
|
||||
/// a mod's namespace when the wood names one.
|
||||
pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
match wood.split_once(':') {
|
||||
Some((namespace, path)) if namespace != "minecraft" => {
|
||||
format!("{namespace}:gui/{folder}/{path}")
|
||||
}
|
||||
Some((_, path)) => format!("gui/{folder}/{path}"),
|
||||
None => format!("gui/{folder}/{wood}"),
|
||||
}
|
||||
"oak"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -262,12 +268,25 @@ mod tests {
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
#[test]
|
||||
fn a_mods_sign_like_name_does_not_make_it_a_sign() {
|
||||
assert_eq!(sign_kind("ruby:road_sign"), None);
|
||||
assert!(crate::containers::BlockEntity::for_block("ruby:road_sign").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn names_and_colors() {
|
||||
assert_eq!(sign_kind("minecraft:oak_wall_hanging_sign"), Some(true));
|
||||
assert_eq!(sign_kind("minecraft:sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:spruce_wall_sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:stone"), None);
|
||||
assert_eq!(wood_type("minecraft:dark_oak_wall_sign"), "dark_oak");
|
||||
assert_eq!(editor_sprite("signs", "dark_oak"), "gui/signs/dark_oak");
|
||||
assert_eq!(
|
||||
editor_sprite("signs", "minecraft:cherry"),
|
||||
"gui/signs/cherry"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_sprite("hanging_signs", "ruby:elm"),
|
||||
"ruby:gui/hanging_signs/elm"
|
||||
);
|
||||
assert_eq!(SignText::default().dark_color(), 0);
|
||||
let red = SignText {
|
||||
color: 14,
|
||||
|
||||
@@ -46,6 +46,30 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
|
||||
pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
#[inline]
|
||||
pub fn lap(&mut self, name: &'static str) {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
/// with more wall time than CPU time waited (for a lock, or for a core on a
|
||||
/// busy machine).
|
||||
@@ -92,6 +116,185 @@ pub fn describe(spans: &[Span]) -> String {
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
|
||||
/// the start (resource tables, the renderer, the title, a world's open, a
|
||||
/// server's join, a dimension change) on whichever thread ran it, one Chrome
|
||||
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
|
||||
/// Perfetto opens it). Times are microseconds since the process started. Off,
|
||||
/// a step costs one relaxed load.
|
||||
pub mod startup {
|
||||
use std::{
|
||||
io::Write as _,
|
||||
sync::{
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
|
||||
static ORIGIN: OnceLock<Instant> = OnceLock::new();
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's trace id, given (with its name) on its first event.
|
||||
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
|
||||
/// (the process's start). Call once, first thing.
|
||||
pub fn init(origin: Instant) {
|
||||
ORIGIN.get_or_init(|| origin);
|
||||
let sink = SINK.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
|
||||
std::fs::File::create(&path).ok().map(Mutex::new)
|
||||
});
|
||||
ON.store(sink.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn micros(at: Instant) -> u128 {
|
||||
ORIGIN.get().map_or(0, |origin| {
|
||||
at.saturating_duration_since(*origin).as_micros()
|
||||
})
|
||||
}
|
||||
|
||||
fn write(event: std::fmt::Arguments) {
|
||||
let Some(Some(sink)) = SINK.get() else {
|
||||
return;
|
||||
};
|
||||
let tid = THREAD.with(|id| {
|
||||
if id.get() == 0 {
|
||||
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(
|
||||
file,
|
||||
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
|
||||
id.get()
|
||||
);
|
||||
}
|
||||
id.get()
|
||||
});
|
||||
let mut file = sink
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
|
||||
}
|
||||
|
||||
/// A step running until it is dropped.
|
||||
#[must_use = "the step ends when this is dropped"]
|
||||
pub struct Step {
|
||||
name: &'static str,
|
||||
started: Instant,
|
||||
}
|
||||
impl Drop for Step {
|
||||
fn drop(&mut self) {
|
||||
let end = Instant::now();
|
||||
write(format_args!(
|
||||
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
|
||||
self.name,
|
||||
micros(self.started),
|
||||
end.saturating_duration_since(self.started).as_micros()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a step named `name` (a literal: no quotes or backslashes).
|
||||
#[inline]
|
||||
pub fn step(name: &'static str) -> Option<Step> {
|
||||
on().then(|| Step {
|
||||
name,
|
||||
started: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` as a step named `name`.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _step = step(name);
|
||||
f()
|
||||
}
|
||||
|
||||
/// A moment of the start: the title drawn, the world in view.
|
||||
#[inline]
|
||||
pub fn mark(name: &'static str) {
|
||||
if on() {
|
||||
write(format_args!(
|
||||
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
|
||||
micros(Instant::now())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The first time `name` happens, as a mark. `once` is the caller's flag.
|
||||
#[inline]
|
||||
pub fn mark_once(once: &AtomicBool, name: &'static str) {
|
||||
if on() && !once.swap(true, Ordering::Relaxed) {
|
||||
mark(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set while a world opens, a server is joined or the dimension changes,
|
||||
/// until the view first fills ([`view_filled`]).
|
||||
static FILLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A world starts loading: the next full view is marked.
|
||||
pub fn arm(name: &'static str) {
|
||||
if on() {
|
||||
mark(name);
|
||||
FILLING.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a world's view is still filling (between [`arm`] and the
|
||||
/// `world.view_full` mark).
|
||||
#[inline]
|
||||
pub fn filling() -> bool {
|
||||
on() && FILLING.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Values at this moment, as a counter track named `name` (Perfetto draws
|
||||
/// each value as a line): what the view waits on while it fills.
|
||||
pub fn counters(name: &'static str, values: &[(&'static str, usize)]) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let args: Vec<String> = values
|
||||
.iter()
|
||||
.map(|(k, v)| format!(r#""{k}":{v}"#))
|
||||
.collect();
|
||||
write(format_args!(
|
||||
r#""ph":"C","name":"{name}","ts":{},"args":{{{}}}"#,
|
||||
micros(Instant::now()),
|
||||
args.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
/// Called each frame in a world: marks `world.view_full` the first frame
|
||||
/// after [`arm`] where nothing waits to load, generate, light, mesh or
|
||||
/// upload and the resident columns cover perf_bench's share of the view
|
||||
/// (0.8 pi r^2: corners are never loaded).
|
||||
#[inline]
|
||||
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
|
||||
if FILLING.load(Ordering::Relaxed)
|
||||
&& pending == 0
|
||||
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
|
||||
&& FILLING.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
mark("world.view_full");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Span;
|
||||
@@ -138,6 +341,25 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
let _ = super::take();
|
||||
let mut off = super::laps();
|
||||
off.lap("unseen");
|
||||
super::enable(true);
|
||||
let mut laps = super::laps();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
laps.lap("first");
|
||||
laps.lap("second");
|
||||
super::enable(false);
|
||||
off.lap("still unseen");
|
||||
let spans = super::take();
|
||||
let names: Vec<_> = spans.iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["first", "second"]);
|
||||
assert!(spans[0].ms >= 2.0);
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
|
||||
//! plays in each, before any of it is applied.
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
|
||||
/// physical pixels), and its scale.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
pub at: [i32; 2],
|
||||
pub size: [u32; 2],
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
|
||||
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
|
||||
pub fn desktop(&self) -> [f64; 4] {
|
||||
let k = if cfg!(target_os = "macos") {
|
||||
self.scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[
|
||||
f64::from(self.at[0]) / k,
|
||||
f64::from(self.at[1]) / k,
|
||||
f64::from(self.size[0]) / k,
|
||||
f64::from(self.size[1]) / k,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
|
||||
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// A new free window's size, in logical pixels.
|
||||
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
|
||||
/// monitor (by name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Spot {
|
||||
Window(u8),
|
||||
New,
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// Something to tell before Apply.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Warning {
|
||||
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
|
||||
Empty(u8),
|
||||
/// This window fills a monitor that isn't there: its players play in Window 1.
|
||||
Unplugged(u8, String),
|
||||
/// This window fills the monitor Window 1 is on: its players play in Window 1.
|
||||
OnWindow1(u8),
|
||||
}
|
||||
|
||||
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
|
||||
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub windows: Vec<Window>,
|
||||
pub players: Vec<u8>,
|
||||
pub two: u8,
|
||||
pub three: u8,
|
||||
/// The players (indices) in the order their views fill a window shared with others: first
|
||||
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
|
||||
pub order: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// Everyone in Window 1 (where it is now).
|
||||
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
|
||||
Self {
|
||||
windows: vec![Window {
|
||||
number: 1,
|
||||
placement: window1,
|
||||
}],
|
||||
players: vec![1; players],
|
||||
two,
|
||||
three,
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Players `i` and `j` trade places: their windows and their places in them.
|
||||
pub fn swap(&mut self, i: usize, j: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n || j >= n || i == j {
|
||||
return;
|
||||
}
|
||||
self.players.swap(i, j);
|
||||
let mut order = ordered(&self.order, n);
|
||||
let (a, b) = (
|
||||
order.iter().position(|&p| p == i),
|
||||
order.iter().position(|&p| p == j),
|
||||
);
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
order.swap(a, b);
|
||||
}
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// Player `i` to place `k` among the others in its window (0: before them all; past the
|
||||
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
|
||||
pub fn put_at(&mut self, i: usize, k: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
let mine = self.players[i];
|
||||
let mut order: Vec<usize> = ordered(&self.order, n)
|
||||
.into_iter()
|
||||
.filter(|&p| p != i)
|
||||
.collect();
|
||||
let others: Vec<usize> = order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| self.players[p] == mine)
|
||||
.collect();
|
||||
let at = match others.get(k) {
|
||||
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
|
||||
None => others
|
||||
.last()
|
||||
.and_then(|j| order.iter().position(|p| p == j))
|
||||
.map_or(order.len(), |p| p + 1),
|
||||
};
|
||||
order.insert(at, i);
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// The players but `i` in window `number`, in their order.
|
||||
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
|
||||
ordered(&self.order, self.players.len())
|
||||
.into_iter()
|
||||
.filter(|&p| p != i && self.players[p] == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A new free window, numbered with the lowest number free; its number.
|
||||
pub fn add_window(&mut self) -> u8 {
|
||||
self.open(Placement::Free(None))
|
||||
}
|
||||
|
||||
fn open(&mut self, placement: Placement) -> u8 {
|
||||
let number = (2..=u8::MAX)
|
||||
.find(|n| self.windows.iter().all(|w| w.number != *n))
|
||||
.unwrap_or(u8::MAX);
|
||||
self.windows.push(Window { number, placement });
|
||||
number
|
||||
}
|
||||
|
||||
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
|
||||
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
|
||||
/// number the draft doesn't have is ignored.
|
||||
pub fn place_player(&mut self, i: usize, spot: Spot) {
|
||||
if i >= self.players.len() {
|
||||
return;
|
||||
}
|
||||
let number = match spot {
|
||||
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
|
||||
Spot::Window(_) => return,
|
||||
Spot::New => self.add_window(),
|
||||
Spot::Fill(name) => self.open(Placement::Fill(name)),
|
||||
};
|
||||
self.players[i] = number;
|
||||
}
|
||||
|
||||
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
|
||||
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
|
||||
/// so their numbers hold still as players move).
|
||||
pub fn drop_empty(&mut self, keep: &[u8]) {
|
||||
let players = &self.players;
|
||||
self.windows
|
||||
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
|
||||
}
|
||||
|
||||
/// Whether nobody plays in window `number`.
|
||||
pub fn empty(&self, number: u8) -> bool {
|
||||
!self.players.contains(&number)
|
||||
}
|
||||
|
||||
/// What to tell before Apply, in window order.
|
||||
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
|
||||
let mut out = Vec::new();
|
||||
for w in &self.windows {
|
||||
if let Placement::Fill(name) = &w.placement
|
||||
&& !monitors.iter().any(|m| &m.name == name)
|
||||
{
|
||||
out.push(Warning::Unplugged(w.number, name.clone()));
|
||||
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
|
||||
out.push(Warning::OnWindow1(w.number));
|
||||
} else if !self.players.contains(&w.number) {
|
||||
out.push(Warning::Empty(w.number));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
|
||||
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
|
||||
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
|
||||
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
|
||||
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
|
||||
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
|
||||
let home = monitors
|
||||
.iter()
|
||||
.find(|m| m.name == main)
|
||||
.or(monitors.first());
|
||||
// Centred on Window 1's monitor, as the game opens a new window.
|
||||
let new = |number: u8| {
|
||||
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
|
||||
let at = m.desktop();
|
||||
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
|
||||
});
|
||||
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
|
||||
let step = cascade(number) * scale * k;
|
||||
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
|
||||
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
|
||||
([x as i32, y as i32], size)
|
||||
};
|
||||
self.windows
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let rect = match &w.placement {
|
||||
Placement::Free(Some(rect)) => Some(*rect),
|
||||
Placement::Free(None) => Some(new(w.number)),
|
||||
Placement::Fill(name) if w.number != 1 && name == main => None,
|
||||
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
|
||||
let at = m.desktop();
|
||||
([at[0] as i32, at[1] as i32], m.size)
|
||||
}),
|
||||
};
|
||||
// Everyone with nowhere else plays in Window 1: it never has no size.
|
||||
if w.number == 1 {
|
||||
rect.or_else(|| Some(new(1)))
|
||||
} else {
|
||||
rect
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
|
||||
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
|
||||
/// Window 1's.
|
||||
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
|
||||
let rects = self.rects(monitors, main);
|
||||
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
|
||||
let window1 = index(1).unwrap_or(0);
|
||||
let windows: Vec<usize> = self
|
||||
.players
|
||||
.iter()
|
||||
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
|
||||
.collect();
|
||||
let sizes: Vec<PhysicalSize<u32>> = rects
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
|
||||
PhysicalSize::new(w, h)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
|
||||
let monitors = [Monitor {
|
||||
name: "Main".into(),
|
||||
at: [0, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
}];
|
||||
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
|
||||
assert!(d.rects(&monitors, "Main")[0].is_some());
|
||||
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
|
||||
d.place_player(1, Spot::Window(9));
|
||||
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
|
||||
}
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
@@ -272,6 +549,97 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
let mut draft = here(16);
|
||||
draft.place_player(15, Spot::New);
|
||||
let views = draft.preview(&monitors(), "Built-in");
|
||||
assert_eq!(views.len(), 16);
|
||||
assert_ne!(views[0].0, views[15].0);
|
||||
}
|
||||
|
||||
fn monitors() -> Vec<Monitor> {
|
||||
vec![
|
||||
Monitor {
|
||||
name: "Built-in".into(),
|
||||
at: [0, 0],
|
||||
size: [3024, 1964],
|
||||
scale: 2.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [3024, 0],
|
||||
size: [3840, 2160],
|
||||
scale: 1.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// P1 in Window 1, everyone else in Window 2 filling the TV.
|
||||
trait DeskAndTv {
|
||||
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
|
||||
}
|
||||
impl DeskAndTv for Draft {
|
||||
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
|
||||
let tv = monitors.iter().find(|m| m.name != main)?;
|
||||
self.windows.push(Window {
|
||||
number: 2,
|
||||
placement: Placement::Fill(tv.name.clone()),
|
||||
});
|
||||
for p in self.players.iter_mut().skip(1) {
|
||||
*p = 2;
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn here(players: usize) -> Draft {
|
||||
Draft::everyone_here(
|
||||
players,
|
||||
Placement::Free(Some(([100, 100], [2000, 1200]))),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets() {
|
||||
let m = monitors();
|
||||
// Everyone here: four quarters of Window 1.
|
||||
let views = here(4).preview(&m, "Built-in");
|
||||
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
|
||||
// P1 alone, the others share the TV in rows.
|
||||
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
|
||||
assert_eq!(tv.players, [1, 2, 2, 2]);
|
||||
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
|
||||
let views = tv.preview(&m, "Built-in");
|
||||
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_windows_cascade_instead_of_stacking() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::New);
|
||||
d.place_player(2, Spot::New);
|
||||
let rects = d.rects(&m, "Built-in");
|
||||
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
|
||||
// Window 3 a step right and down of Window 2, on the same diagonal.
|
||||
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
|
||||
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_at_a_place_among_a_window_s_players() {
|
||||
let mut d = here(3);
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
d.put_at(2, 5); // past the last: after them
|
||||
assert_eq!(d.order, [0, 1, 2]);
|
||||
// Players in another window keep their places.
|
||||
d.players = vec![1, 2, 1];
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.sharing(2, 1), [0]);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,4 +661,98 @@ mod tests {
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_players_swap_in_a_window_and_between_windows() {
|
||||
let m = monitors();
|
||||
// In one window: P1 and P2 trade sides.
|
||||
let mut d = here(2);
|
||||
d.swap(0, 1);
|
||||
assert_eq!(d.order, [1, 0]);
|
||||
let views = d.preview(&m, "Built-in");
|
||||
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
|
||||
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.swap(1, 2);
|
||||
assert_eq!(d.players, [1, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_placed() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.place_player(2, Spot::Window(2));
|
||||
assert_eq!(d.players, [1, 2, 2]);
|
||||
d.place_player(2, Spot::New);
|
||||
assert_eq!(d.players, [1, 2, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
// The window left behind stays while the draft is edited (numbers hold still), and
|
||||
// closes at Apply; Window 1 never.
|
||||
d.place_player(1, Spot::Window(1));
|
||||
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
|
||||
assert_eq!(numbers(&d), [1, 2, 3]);
|
||||
assert!(d.empty(2));
|
||||
d.drop_empty(&[]);
|
||||
assert_eq!(numbers(&d), [1, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
d.place_player(0, Spot::Window(3));
|
||||
d.place_player(1, Spot::Window(3));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_numbered_lowest_free() {
|
||||
let mut d = here(3);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
assert_eq!(d.add_window(), 3);
|
||||
d.windows.retain(|w| w.number != 2);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
// Nobody to place: no window opens for it.
|
||||
d.place_player(9, Spot::New);
|
||||
assert_eq!(d.windows.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_over_window_1s_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2);
|
||||
d.place_player(1, Spot::Fill("Built-in".into()));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
|
||||
// As the game does: P2 beside P1 in Window 1.
|
||||
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_and_an_unplugged_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let three = d.add_window();
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
|
||||
// The TV unplugged: its player plays in Window 1 beside P1.
|
||||
assert_eq!(
|
||||
d.warnings(&m[..1], "Built-in"),
|
||||
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
|
||||
);
|
||||
let views = d.preview(&m[..1], "Built-in");
|
||||
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
|
||||
// P1 elsewhere: Window 1 is blank.
|
||||
d.players = vec![2, 2];
|
||||
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_players_two_windows() {
|
||||
let m = monitors();
|
||||
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let views = d.preview(&m, "Built-in");
|
||||
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
|
||||
let area: u64 = views[1..]
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(area, 3840 * 2160);
|
||||
assert!(views[1..].iter().all(|v| v.0 == 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Project a draft arrangement onto the Setup screen, without opening a window.
|
||||
use crate::split_layout::{Draft, Monitor};
|
||||
|
||||
/// Map area: below the title and above the move row and footer.
|
||||
pub const TOP: f32 = 30.0;
|
||||
pub const BOTTOM: f32 = 80.0;
|
||||
pub type Rect = [f32; 4];
|
||||
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
|
||||
|
||||
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
|
||||
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
|
||||
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
|
||||
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
|
||||
let rects = draft.rects(monitors, main);
|
||||
let on_desk: Vec<(u8, [f64; 4])> = draft
|
||||
.windows
|
||||
.iter()
|
||||
.zip(&rects)
|
||||
.filter_map(|(win, rect)| {
|
||||
let ([x, y], [pw, ph]) = (*rect)?;
|
||||
let (x, y) = (f64::from(x), f64::from(y));
|
||||
let scale = monitors
|
||||
.iter()
|
||||
.find(|m| {
|
||||
let [mx, my, mw, mh] = m.desktop();
|
||||
x >= mx && x < mx + mw && y >= my && y < my + mh
|
||||
})
|
||||
.or_else(|| monitors.iter().find(|m| m.name == main))
|
||||
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
|
||||
Some((
|
||||
win.number,
|
||||
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
|
||||
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
|
||||
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
|
||||
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
|
||||
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
|
||||
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
|
||||
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
|
||||
return Default::default();
|
||||
}
|
||||
let (aw, ah) = (
|
||||
f64::from(w) - 40.0,
|
||||
// Room under the monitors for their names, and always for the move row: the map
|
||||
// doesn't jump when a player is picked (a drop back to where its tile was misses).
|
||||
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
|
||||
);
|
||||
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
|
||||
let (ox, oy) = (
|
||||
(f64::from(w) - (x1 - x0) * k) / 2.0,
|
||||
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
|
||||
);
|
||||
let to_map = |[x, y, dw, dh]: [f64; 4]| {
|
||||
[
|
||||
(ox + (x - x0) * k) as f32,
|
||||
(oy + (y - y0) * k) as f32,
|
||||
(dw * k) as f32,
|
||||
(dh * k) as f32,
|
||||
]
|
||||
};
|
||||
let mapped_monitors = desk.iter().copied().map(to_map).collect();
|
||||
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
|
||||
// In their own order, emptied ones too: a window a player was just dragged out of stays
|
||||
// where it was until Apply closes it, so the player can be dragged back into it.
|
||||
let tiles = draft
|
||||
.preview(monitors, main)
|
||||
.iter()
|
||||
.map(|&(i, [vx, vy], size)| {
|
||||
let number = draft.windows.get(i).map_or(1, |w| w.number);
|
||||
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
|
||||
return (number, [0.0; 4]);
|
||||
};
|
||||
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
|
||||
(
|
||||
number,
|
||||
[
|
||||
wx + vx as f32 / pw * ww,
|
||||
wy + vy as f32 / ph * wh,
|
||||
size.width as f32 / pw * ww,
|
||||
size.height as f32 / ph * wh,
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(mapped_monitors, windows, tiles)
|
||||
}
|
||||
|
||||
/// The largest visible side of a tile after later windows cover it.
|
||||
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
|
||||
for [cx, cy, cw, ch] in covers {
|
||||
let [x, y, w, h] = r;
|
||||
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
|
||||
continue;
|
||||
}
|
||||
let sides = [
|
||||
[x, y, cx - x, h],
|
||||
[cx + cw, y, x + w - (cx + cw), h],
|
||||
[x, y, w, cy - y],
|
||||
[x, cy + ch, w, y + h - (cy + ch)],
|
||||
];
|
||||
r = sides
|
||||
.into_iter()
|
||||
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
|
||||
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
|
||||
.unwrap_or([x, y, 0.0, 0.0]);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::split_layout::{Placement, Spot};
|
||||
|
||||
#[test]
|
||||
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
|
||||
let monitors = vec![
|
||||
Monitor {
|
||||
name: "Desk".into(),
|
||||
at: [0, 0],
|
||||
size: [2000, 1200],
|
||||
scale: 1.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [-1920, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let mut draft =
|
||||
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
|
||||
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
let (a, b) = (tiles[0].1, tiles[1].1);
|
||||
assert!((a[2] - b[2]).abs() < 0.01);
|
||||
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
|
||||
draft.place_player(1, Spot::Fill("TV".into()));
|
||||
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert!(screens[1][0] < screens[0][0]);
|
||||
assert_eq!(windows.len(), 2);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
|
||||
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
|
||||
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
|
||||
let tile = [0.0, 0.0, 100.0, 100.0];
|
||||
assert_eq!(
|
||||
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
|
||||
[60.0, 0.0, 40.0, 100.0]
|
||||
);
|
||||
let hidden = uncovered(tile, [tile].into_iter());
|
||||
assert_eq!(hidden[2] * hidden[3], 0.0);
|
||||
assert_eq!(
|
||||
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
|
||||
tile
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -413,6 +413,119 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
|
||||
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
|
||||
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
|
||||
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
|
||||
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
|
||||
/// The sheet's own layer, if models already show it, shows its first tile from then on.
|
||||
pub fn insert_tiles(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
(columns, rows): (u32, u32),
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let animation = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("animation"))
|
||||
.map(|v| v.get().to_owned());
|
||||
// The sheet's `texture` section is each tile's.
|
||||
let texture = texture_section(metadata.as_ref());
|
||||
let strategy = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["mipmap_strategy"].as_str())
|
||||
.and_then(crate::mipmap::Strategy::parse)
|
||||
.unwrap_or_default();
|
||||
let alpha_cutoff_bias = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let (width, height, pixels) = read_rgba(&path)?;
|
||||
let side = width / columns.max(1);
|
||||
// How far down the next frame of a tile is, and the next row of tiles.
|
||||
let (frame_step, row_step) = match per_tile {
|
||||
true => (side, height / rows.max(1)),
|
||||
false => (rows * side, side),
|
||||
};
|
||||
let frames = match per_tile {
|
||||
true => row_step / side.max(1),
|
||||
false => height / frame_step.max(1),
|
||||
};
|
||||
let whole = match per_tile {
|
||||
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
|
||||
false => height % frame_step.max(1) == 0,
|
||||
};
|
||||
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
|
||||
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
|
||||
}
|
||||
let tiles = (0..columns * rows)
|
||||
.map(|tile| {
|
||||
let frames: Vec<Vec<u8>> = (0..frames)
|
||||
.map(|f| {
|
||||
let (x, y) = (
|
||||
tile % columns * side,
|
||||
tile / columns * row_step + f * frame_step,
|
||||
);
|
||||
(y..y + side)
|
||||
.flat_map(|row| {
|
||||
let start = ((row * width + x) * 4) as usize;
|
||||
pixels[start..start + side as usize * 4].iter().copied()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
|
||||
if alphas().all(|&a| a == 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let decoded = crate::sprite_cache::Decoded {
|
||||
side,
|
||||
animation: animation.clone().filter(|_| frames.len() > 1),
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent: alphas().any(|&a| a == 0),
|
||||
opaque: alphas().all(|&a| a == 255),
|
||||
translucent: alphas().any(|&a| a != 0 && a != 255),
|
||||
frames,
|
||||
};
|
||||
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
|
||||
self.register(root, &tile_name, decoded).map(Some)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
|
||||
// the whole sheet squeezed square, whose size would set every layer's.
|
||||
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
|
||||
self.show_as(own, first);
|
||||
}
|
||||
Ok(tiles)
|
||||
}
|
||||
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
|
||||
fn show_as(&mut self, layer: u32, source: u32) {
|
||||
self.still.retain(|_, l| *l != layer);
|
||||
let (l, s) = (layer as usize, source as usize);
|
||||
self.native[l] = self.native[s].clone();
|
||||
self.opaque[l] = self.opaque[s];
|
||||
self.translucent[l] = self.translucent[s];
|
||||
self.mips[l] = self.mips[s];
|
||||
self.pbr[l] = self.pbr[s].clone();
|
||||
self.animations.retain(|a| a.layer != layer);
|
||||
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
|
||||
let animation = SpriteAnimation {
|
||||
layer,
|
||||
..animation.clone()
|
||||
};
|
||||
self.animations.push(animation);
|
||||
}
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
if !name.contains(':') {
|
||||
@@ -762,6 +875,75 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn tile_sheets_cut_into_a_layer_per_tile() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/textures/block/sheet.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
|
||||
// y; the middle column is transparent, and the top-right tile half see-through.
|
||||
let (w, h) = (6u32, 4u32);
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let alpha = match x / 2 {
|
||||
1 => 0,
|
||||
2 if y < 2 => 128,
|
||||
_ => 255,
|
||||
};
|
||||
[x as u8, y as u8, 0, alpha]
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
// Red and green of each pixel in a layer's frame.
|
||||
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
|
||||
sprites.native[layer as usize].1[frame]
|
||||
.chunks(4)
|
||||
.map(|p| (p[0], p[1]))
|
||||
.collect()
|
||||
};
|
||||
let mut sprites = Sprites::default();
|
||||
let name = "minecraft:block/sheet";
|
||||
let own = sprites.insert(&root, name).unwrap();
|
||||
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
|
||||
// The sheet's own layer now shows the top-left tile, at a tile's size.
|
||||
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(
|
||||
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
|
||||
[true, false, true, true, false, true]
|
||||
);
|
||||
let bottom_left = tiles[3].unwrap();
|
||||
assert_eq!(
|
||||
texels(&sprites, bottom_left, 0),
|
||||
[(0, 2), (1, 2), (0, 3), (1, 3)]
|
||||
);
|
||||
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
|
||||
assert!(sprites.opaque[bottom_left as usize]);
|
||||
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
|
||||
// per tile, each cell is the column of a tile's frames, which here is the same.
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
for per_tile in [false, true] {
|
||||
let mut sprites = Sprites::default();
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
|
||||
let first = tiles[0].unwrap();
|
||||
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
|
||||
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
|
||||
}
|
||||
// Not a grid of square tiles.
|
||||
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
|
||||
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_texture_ids_load_with_their_animation() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Static block-entity meshes use section-local positions and the shared
|
||||
// entity vertex/fragment helpers. The shader subtracts integer block origins
|
||||
// before converting to f32; CPU supplies the camera's fractional block offset.
|
||||
struct StaticEntityUniform {
|
||||
camera_block: vec4<i32>,
|
||||
camera_fraction: vec4<f32>,
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> static_entity: StaticEntityUniform;
|
||||
|
||||
@vertex fn vs_static_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
@@ -6,34 +6,67 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
chunk_map::ChunkMembershipDelta,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
||||
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Worker CPU time and job counts by kind, for benchmarks
|
||||
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
|
||||
/// job then.
|
||||
pub mod job_stats {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
pub const KINDS: [&str; 7] = [
|
||||
"load", "generate", "decorate", "finish", "light", "mesh", "other",
|
||||
];
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(super) fn add(kind: usize, nanos: u64) {
|
||||
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
|
||||
COUNT[kind].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
/// (kind, nanoseconds, count) since the last call; resets them.
|
||||
pub fn take() -> Vec<(&'static str, u64, u64)> {
|
||||
(0..KINDS.len())
|
||||
.map(|i| {
|
||||
(
|
||||
KINDS[i],
|
||||
NANOS[i].swap(0, Ordering::Relaxed),
|
||||
COUNT[i].swap(0, Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
|
||||
/// (FEATURES, which may write into neighbours), then conversion of a chunk
|
||||
/// whose eight neighbours are decorated into a final column. Implementations
|
||||
/// must be thread-safe; each worker thread owns one `Worker`.
|
||||
/// must be thread-safe; each worker of the shared pool
|
||||
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
|
||||
pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
type Worker: Send + 'static;
|
||||
fn worker(&self) -> Self::Worker;
|
||||
@@ -203,6 +236,7 @@ impl<G: ChunkGenerator> Saves for G {
|
||||
struct MeshSettings {
|
||||
nether: bool,
|
||||
shader_pack_lighting: bool,
|
||||
separate_ao: bool,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
@@ -278,8 +312,6 @@ impl Ord for Queued {
|
||||
}
|
||||
struct Shared {
|
||||
queue: Mutex<BinaryHeap<Queued>>,
|
||||
ready: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
@@ -287,11 +319,6 @@ struct Shared {
|
||||
/// Only workers use this pool; frames never wait for its lock. Retaining
|
||||
/// cleared buffers avoids racing the renderer for the process heap.
|
||||
meshes: Mutex<MeshPool>,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
/// The workers that started.
|
||||
workers: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
@@ -366,7 +393,11 @@ pub struct Streaming {
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
|
||||
/// dropping it waits for its running jobs.
|
||||
jobs: Registration,
|
||||
/// The workers its in-flight work is sized for.
|
||||
workers: usize,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
scan_age: u8,
|
||||
@@ -437,6 +468,9 @@ pub struct Streaming {
|
||||
/// A shader pack lights the faces: sections mesh without face shading
|
||||
/// (`Mesher::shader_pack_lighting`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
|
||||
/// (`Mesher::separate_ao`).
|
||||
pub separate_ao: bool,
|
||||
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
|
||||
pub biome_zoom: BiomeZoom,
|
||||
pub biome_blend: i32,
|
||||
@@ -478,6 +512,14 @@ pub struct Streaming {
|
||||
/// Snapshot only after a scan found no missing candidates. Public range fields
|
||||
/// and `World::chunks` can change without going through Streaming, so compare
|
||||
/// their values and structural membership token rather than a frame timeout.
|
||||
///
|
||||
/// A scan also keeps every candidate it found, nearest first (`plan`): while
|
||||
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
|
||||
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
|
||||
/// next batches are the plan's entries still missing. A full scan looks at
|
||||
/// every column of the areas (thousands at render distance 32), and one ran
|
||||
/// whenever a worker finished: it took a quarter of the thread that runs
|
||||
/// `Streaming::update` while a world loaded.
|
||||
#[derive(Default)]
|
||||
struct GenerationScan {
|
||||
exhausted: bool,
|
||||
@@ -487,17 +529,52 @@ struct GenerationScan {
|
||||
forced: HashSet<(i32, i32)>,
|
||||
band: Option<Band>,
|
||||
chunk_revision: u64,
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.center == ranges.center
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
self.center == ranges.center
|
||||
&& self.radius == ranges.radius
|
||||
&& self.extra == ranges.extra
|
||||
&& self.forced == *ranges.forced
|
||||
&& self.band.as_ref() == ranges.band
|
||||
}
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.same_ranges(ranges)
|
||||
&& self.chunk_revision == world.chunks.membership_revision()
|
||||
}
|
||||
/// A column may be wanted again (a proto chunk was dropped): the next
|
||||
/// call scans the areas again.
|
||||
fn invalidate(&mut self) {
|
||||
self.exhausted = false;
|
||||
self.plan_basis = None;
|
||||
}
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
if revision != self.chunk_revision {
|
||||
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
|
||||
return false;
|
||||
};
|
||||
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
|
||||
return false;
|
||||
}
|
||||
self.chunk_revision = revision;
|
||||
}
|
||||
true
|
||||
}
|
||||
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
|
||||
self.exhausted = true;
|
||||
self.center = ranges.center;
|
||||
@@ -535,6 +612,30 @@ impl GenerationScan {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let missing = |&(_, x, z): &(i64, i32, i32)| {
|
||||
!input.world.chunks.contains_key(&(x, z))
|
||||
&& !input.generating.contains(&(x, z))
|
||||
&& !input.protos.contains_key(&(x, z))
|
||||
&& !input.finishing.contains(&(x, z))
|
||||
};
|
||||
if self.plan_holds(&input) {
|
||||
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
|
||||
self.next += 1;
|
||||
}
|
||||
let room = capacity - input.generating.len();
|
||||
let batch: Vec<_> = self.plan[self.next..]
|
||||
.iter()
|
||||
.filter(|c| missing(c))
|
||||
.take(room)
|
||||
.copied()
|
||||
.collect();
|
||||
// Past the plan's end, the full scan below decides whether
|
||||
// anything is left (and remembers when nothing is).
|
||||
if !batch.is_empty() {
|
||||
self.exhausted = false;
|
||||
return Some(batch);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut wanted = Vec::new();
|
||||
// Each area's square of columns, as one run per row. Players'
|
||||
@@ -594,28 +695,218 @@ impl GenerationScan {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
if wanted.is_empty() {
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
self.plan.clear();
|
||||
self.plan_basis = None;
|
||||
} else {
|
||||
// A bounded batch may leave candidates behind. Keep scanning
|
||||
// as capacity opens, even if no range or membership changed.
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
self.plan.clear();
|
||||
self.plan.extend_from_slice(&wanted);
|
||||
self.next = 0;
|
||||
wanted.truncate(capacity - input.generating.len());
|
||||
Some(wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a pool worker keeps for one streamer between its jobs: the
|
||||
/// generator's worker, and the face visibility table and its occlusion
|
||||
/// cache (like vanilla's per-thread cache: cloning it per job copied every
|
||||
/// state and face shape) and the mesher's scratch.
|
||||
struct WorkerLocal<W> {
|
||||
worker: W,
|
||||
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
|
||||
mesh_scratch: MeshScratch,
|
||||
}
|
||||
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
|
||||
struct Jobs<G: ChunkGenerator> {
|
||||
shared: Arc<Shared>,
|
||||
generator: Arc<G>,
|
||||
light: Arc<LightTables>,
|
||||
locals: PerWorker<WorkerLocal<G::Worker>>,
|
||||
}
|
||||
impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
let queue = self.shared.queue.lock().unwrap();
|
||||
queue.peek().map(|q| {
|
||||
// Jobs without a column (saves, meshes to free) go first.
|
||||
let class = if q.at.is_none() {
|
||||
Class::Urgent
|
||||
} else {
|
||||
Class::Near
|
||||
};
|
||||
Rank::new(class, q.priority)
|
||||
})
|
||||
}
|
||||
fn run(&self, worker: usize) -> bool {
|
||||
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
|
||||
return false;
|
||||
};
|
||||
let started = job_stats::enabled().then(|| {
|
||||
let kind = match &job.job {
|
||||
Job::Generate(..) => 1,
|
||||
Job::Decorate(..) => 2,
|
||||
Job::Finish(..) => 3,
|
||||
Job::Light(..) => 4,
|
||||
Job::Mesh(..) => 5,
|
||||
_ => 6,
|
||||
};
|
||||
(kind, crate::spans::thread_cpu().unwrap_or_default())
|
||||
});
|
||||
let done = self.locals.with(
|
||||
worker,
|
||||
|| WorkerLocal {
|
||||
worker: self.generator.worker(),
|
||||
faces: None,
|
||||
mesh_scratch: MeshScratch::default(),
|
||||
},
|
||||
|local| self.run_job(job.job, local),
|
||||
);
|
||||
if let Some((kind, started)) = started {
|
||||
// A generation job that found the column saved loaded it.
|
||||
let kind = if kind == 1 && matches!(done, Some(Done::Generated(_))) {
|
||||
0
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
let cpu = crate::spans::thread_cpu().unwrap_or_default();
|
||||
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
|
||||
}
|
||||
if let Some(done) = done {
|
||||
self.shared.done.send(done);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn idle(&self, worker: usize) {
|
||||
// A server nobody is on, a client standing still: free the
|
||||
// generator's buffers (and the mesher's face cache and scratch)
|
||||
// until the next job needs them.
|
||||
self.locals.with_existing(worker, |local| {
|
||||
self.generator.idle(&mut local.worker);
|
||||
local.faces = None;
|
||||
local.mesh_scratch = MeshScratch::default();
|
||||
});
|
||||
}
|
||||
fn all_idle(&self) {
|
||||
// No worker needs retained mesh capacity now. Drop it outside the
|
||||
// pool lock; the pool then asks the allocator to return freed pages.
|
||||
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
}
|
||||
}
|
||||
impl<G: ChunkGenerator> Jobs<G> {
|
||||
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
|
||||
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
|
||||
let WorkerLocal {
|
||||
worker,
|
||||
faces,
|
||||
mesh_scratch,
|
||||
} = local;
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut *worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut *worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut *worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes
|
||||
.block_entities
|
||||
.extend(generator.final_block_entities(®ion[*i], &blocks));
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
separate_ao: settings.separate_ao,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(&borrow(®ion), sy, recycled, mesh_scratch);
|
||||
mesh.sort_solid();
|
||||
*faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
Some(done)
|
||||
}
|
||||
}
|
||||
pub struct WorkerContext {
|
||||
pub assets: Arc<crate::assets::Assets>,
|
||||
pub render: Arc<crate::assets::RenderAssets>,
|
||||
@@ -631,190 +922,23 @@ impl Streaming {
|
||||
) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
queue: Mutex::new(BinaryHeap::new()),
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
workers: AtomicUsize::new(0),
|
||||
});
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
.name(format!("horizon-worker-{i}"))
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut worker = generator.worker();
|
||||
// The face visibility table and its occlusion cache stay with
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
let mut mesh_scratch = MeshScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache and scratch) until the next job
|
||||
// needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
mesh_scratch = MeshScratch::default();
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== shared.workers.load(Ordering::Relaxed)
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
// the allocator to return freed pages.
|
||||
let meshes =
|
||||
std::mem::take(&mut *shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) =
|
||||
generator.proto_beside_final(&mut worker, snapshot)
|
||||
{
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes.block_entities.extend(
|
||||
generator.final_block_entities(®ion[*i], &blocks),
|
||||
);
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility))
|
||||
if Arc::ptr_eq(&tables, &context.tables) =>
|
||||
{
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(
|
||||
&borrow(®ion),
|
||||
sy,
|
||||
recycled,
|
||||
&mut mesh_scratch,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
})
|
||||
// A thread the system refuses (a container's process limit reached) leaves the pool a
|
||||
// worker short: a server that panicked here went down with its players' moves unsaved.
|
||||
.filter_map(|spawned| {
|
||||
spawned
|
||||
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
shared.workers.store(threads.len(), Ordering::Relaxed);
|
||||
let pool = horizon_threads::jobs::pool();
|
||||
let source = Arc::new(Jobs {
|
||||
shared: shared.clone(),
|
||||
generator: generator.clone(),
|
||||
light: context.light.clone(),
|
||||
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
|
||||
});
|
||||
let jobs = pool.register(source);
|
||||
// The work kept in flight is sized for the workers this streamer may
|
||||
// use: the pool's, or fewer when asked (tests, a placeholder).
|
||||
let workers = threads.clamp(1, pool.threads());
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
@@ -822,7 +946,8 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
non_air_sections,
|
||||
threads,
|
||||
jobs,
|
||||
workers,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
@@ -861,6 +986,7 @@ impl Streaming {
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
biome_zoom: BiomeZoom::new(0),
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
@@ -1021,7 +1147,7 @@ impl Streaming {
|
||||
order: self.order,
|
||||
job,
|
||||
});
|
||||
self.shared.ready.notify_one();
|
||||
self.jobs.notify();
|
||||
}
|
||||
/// Rank the queued jobs again when what their priorities were computed
|
||||
/// from changed: the centre, the extra areas, the band, or the loading
|
||||
@@ -1133,6 +1259,13 @@ impl Streaming {
|
||||
world
|
||||
.block_entities
|
||||
.load_chunk(std::mem::take(&mut chunk.block_entities));
|
||||
if let Some(extras) = &chunk.saved {
|
||||
for (pos, attachments) in &extras.block_entity_attachments {
|
||||
world
|
||||
.block_entities
|
||||
.restore_attachments(*pos, attachments.clone());
|
||||
}
|
||||
}
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
@@ -1484,7 +1617,7 @@ impl Streaming {
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.generation_scan.invalidate();
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
@@ -1533,7 +1666,7 @@ impl Streaming {
|
||||
// Decorate chunks whose 3x3 proto neighbourhood is available, in
|
||||
// vanilla's order: decoration is order dependent where write regions
|
||||
// overlap (see `DecorationOrder`).
|
||||
let workers = self.threads.len();
|
||||
let workers = self.workers;
|
||||
if self.decorates && self.order_area != Some((self.center, self.radius)) {
|
||||
let (decorated, chunks) = (&self.decorated, &world.chunks);
|
||||
// The render distance is the server view distance in singleplayer.
|
||||
@@ -1595,14 +1728,24 @@ impl Streaming {
|
||||
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Most wanted chunks are blocked, and the same neighbours are
|
||||
// asked about for each of them: what `waits_for` says is kept for
|
||||
// the pass (nothing it reads changes in it but `decorated`, which
|
||||
// updates the entry). Without it, scheduling at render distance 32
|
||||
// took most of the thread that runs it while the world loaded.
|
||||
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
|
||||
for (_, _, x, z) in wanted {
|
||||
if self.decorating >= workers * 2 {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.decoration_order
|
||||
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
|
||||
{
|
||||
if self.decoration_order.blocked(x, z, |nx, nz| {
|
||||
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
|
||||
return known;
|
||||
}
|
||||
let known = waits_for(self, nx, nz);
|
||||
waiting.borrow_mut().insert((nx, nz), known);
|
||||
known
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
@@ -1622,6 +1765,7 @@ impl Streaming {
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
@@ -1675,7 +1819,7 @@ impl Streaming {
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
@@ -1761,7 +1905,7 @@ impl Streaming {
|
||||
// jobs went stale long before a worker reached them (#3629). What
|
||||
// stays dirty is asked for again, from the current centre, as
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
|
||||
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
@@ -1784,6 +1928,7 @@ impl Streaming {
|
||||
MeshSettings {
|
||||
nether: self.nether_shading,
|
||||
shader_pack_lighting: self.shader_pack_lighting,
|
||||
separate_ao: self.separate_ao,
|
||||
zoom: self.biome_zoom,
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
@@ -2433,11 +2578,8 @@ impl Drop for Streaming {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.shared.ready.notify_all();
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
// `jobs` leaves the pool after this, once its running jobs are
|
||||
// done; the jobs still queued go with the queue.
|
||||
}
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
@@ -2549,14 +2691,7 @@ fn keep_unchanged_sections(
|
||||
}
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
use crate::chunk::Light;
|
||||
match (a, b) {
|
||||
(Light::Uniform(x), Light::Uniform(y)) => x == y,
|
||||
(Light::Full(x), Light::Full(y)) => x == y,
|
||||
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
|
||||
f.iter().all(|c| c == v)
|
||||
}
|
||||
}
|
||||
a.same_as(b)
|
||||
}
|
||||
/// Whether two biome containers (64 cells) hold the same values.
|
||||
fn same_values(
|
||||
@@ -3138,6 +3273,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches taken from the plan are the full scan's, as jobs finish,
|
||||
/// columns and proto chunks leave and the centre moves.
|
||||
#[test]
|
||||
fn generation_plan_matches_full_scans_as_columns_arrive() {
|
||||
for decorates in [false, true] {
|
||||
let mut f = GenerationFixture::new(6, decorates);
|
||||
let mut gate = GenerationScan::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut rand = |n: u64| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed % n
|
||||
};
|
||||
let mut scheduled = 0;
|
||||
for step in 0..600 {
|
||||
let capacity = 3 + rand(6) as usize;
|
||||
let expected = baseline_candidates(f.input(), capacity);
|
||||
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
|
||||
assert_eq!(batch, expected, "step {step}");
|
||||
for (_, x, z) in batch {
|
||||
f.generating.insert((x, z));
|
||||
scheduled += 1;
|
||||
}
|
||||
// Some jobs finish: as a proto chunk, or a final column.
|
||||
let mut done: Vec<_> = f.generating.iter().copied().collect();
|
||||
done.sort_unstable();
|
||||
for key in done {
|
||||
if rand(3) != 0 {
|
||||
continue;
|
||||
}
|
||||
f.generating.remove(&key);
|
||||
if rand(2) == 0 {
|
||||
f.protos.insert(key, None);
|
||||
} else {
|
||||
f.world.chunks.insert(key, chunk.clone());
|
||||
}
|
||||
gate.exhausted = false;
|
||||
}
|
||||
// Now and then a column or a proto chunk leaves.
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.world.chunks.remove(key);
|
||||
}
|
||||
}
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.protos.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.protos.remove(key);
|
||||
gate.invalidate();
|
||||
}
|
||||
}
|
||||
if step == 300 {
|
||||
f.center = (3, -2);
|
||||
}
|
||||
}
|
||||
assert!(scheduled > 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_scan_looks_at_overlapping_areas_once() {
|
||||
// A server's players: many areas around one spot, some apart, one
|
||||
|
||||
@@ -20,9 +20,6 @@ const MIN_SECONDS: f32 = 60.0;
|
||||
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
|
||||
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// When the menu bundle became ready, so the time to the title screen measures
|
||||
/// the menu and not the world bundle loaded behind it.
|
||||
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
|
||||
@@ -46,14 +43,6 @@ pub fn mark_start() {
|
||||
STARTED.get_or_init(Instant::now);
|
||||
}
|
||||
|
||||
/// The menu bundle is ready: the title screen can draw. The first writer wins.
|
||||
pub fn mark_ui_ready() {
|
||||
if let Some(started) = STARTED.get() {
|
||||
let ms = started.elapsed().as_millis() as u32;
|
||||
let _ = UI_READY_MS.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_core_ready() {
|
||||
mark_ready(&CORE_READY_MS);
|
||||
}
|
||||
@@ -241,17 +230,18 @@ impl Session {
|
||||
pub fn title_shown(&mut self) {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
|
||||
self.to_title_ms = if ui_ms != u32::MAX {
|
||||
Some(ui_ms)
|
||||
} else {
|
||||
STARTED.get().map(|s| s.elapsed().as_millis() as u32)
|
||||
};
|
||||
crate::spans::startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before
|
||||
// the renderer that draws it (pipelines compiling on a slow driver).
|
||||
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
crate::spans::startup::arm("world.opening");
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
@@ -262,10 +252,11 @@ impl Session {
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
if let Some(opening) = self.opening.take()
|
||||
&& self.to_world_ms.is_none()
|
||||
{
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
self.frames.record(ms);
|
||||
self.played_ms += ms;
|
||||
|
||||
@@ -37,6 +37,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -171,6 +174,8 @@ struct TerrainOut {
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
// HDR lighting: block and sky light, as levels 0 to 15.
|
||||
@location(11) scene: vec2<f32>,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
@@ -361,6 +366,22 @@ fn left_out() -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
|
||||
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
|
||||
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
// The face's normal, from the fragment's derivatives, for how it faces the sun.
|
||||
var<private> hdr_normal: vec3<f32>;
|
||||
fn hdr_facing() -> f32 {
|
||||
return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0));
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
@@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
}
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
out.scene = vec2(block / 16.0, f32(light.x) / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
|
||||
}
|
||||
@@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
@@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
@@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
// texel's own brightness, so its light level and face shading don't change
|
||||
// it. Only pixels as bright as their sprite's threshold may: its bright
|
||||
// parts, or all of it. Nothing else reads the frame's alpha.
|
||||
var emissive = 1.0;
|
||||
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
|
||||
// fully fogged mountain would stand out from the fog behind it.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
|
||||
globals.hdr_light.y, fogged));
|
||||
if in.emission > 0u {
|
||||
// A light source's own face shines as one or stays as drawn, without the lighting.
|
||||
emissive = 1.0;
|
||||
let whole = in.emission > 4u;
|
||||
let tier = select(in.emission, in.emission - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
|
||||
let y = brightness(texel.rgb);
|
||||
if y >= select(t.r, t.g, whole) {
|
||||
emissive = hdr_mark(tier, y);
|
||||
// All over means all over: the sprite's range is one frame's, and an animated sprite's
|
||||
// other frames dip below it. Fog hides a light source as it hides everything else.
|
||||
if whole || y >= t.r {
|
||||
emissive = hdr_mark(tier, y * (1.0 - fogged));
|
||||
}
|
||||
}
|
||||
if in.emissive_layer != 0xffffffffu {
|
||||
@@ -477,6 +510,19 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
}
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
|
||||
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
|
||||
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
|
||||
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
if sprite(in.uv, in.layer & 0x7fffu).a < 0.6 { discard; }
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let scene = mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(scene));
|
||||
}
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
@@ -612,7 +658,8 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
|
||||
@@ -384,19 +384,42 @@ pub fn scrolling_text_at(
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
// `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`.
|
||||
let center_x = left + ((right - left) / 2.0).floor();
|
||||
scrolling_text_around_at(
|
||||
batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color,
|
||||
);
|
||||
}
|
||||
|
||||
/// [`scrolling_text_at`] around an explicit `center_x`
|
||||
/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon`
|
||||
/// calls with the button's middle): a label that fits is centred on `center_x`
|
||||
/// moved as far as it must to stay between `left` and `right`
|
||||
/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so
|
||||
/// beside an icon it ends where the icon's room begins. A wider one is drawn
|
||||
/// from the left and slid inside the box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn scrolling_text_around_at(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
center_x: f32,
|
||||
left: f32,
|
||||
right: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
let room = right - left;
|
||||
if width <= room {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
centered_left(left, room, width),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
let half = (width / 2.0).floor();
|
||||
let text_x = center_x.max(left + half).min(right - half);
|
||||
batch.text_styled(atlas, &text, text_x - half, y, color, true);
|
||||
return;
|
||||
}
|
||||
// Scissored to the widget: what does not fit is read as the label
|
||||
@@ -440,6 +463,37 @@ pub fn scrolling_text(
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label
|
||||
/// inside the tab less 1 px on each side, from the tab's top when selected
|
||||
/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider
|
||||
/// than the tab slides instead of running into the next tab). White, gray
|
||||
/// for an inactive tab (`WithInactiveMessage`).
|
||||
pub fn menu_tab_label(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
label: &Text,
|
||||
(x, y, width, height): (f32, f32, f32, f32),
|
||||
selected: bool,
|
||||
active: bool,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let top = y + if selected { 0.0 } else { 3.0 };
|
||||
let bottom = y + height;
|
||||
scrolling_text_at(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
x + 1.0,
|
||||
x + width - 1.0,
|
||||
top,
|
||||
bottom,
|
||||
// defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`.
|
||||
((top + bottom - 9.0) / 2.0).floor() + 1.0,
|
||||
now_ms,
|
||||
if active { u32::MAX } else { 0xFFA0_A0A0 },
|
||||
);
|
||||
}
|
||||
|
||||
/// Button (AbstractButton): nine-slice sprite plus the centred label.
|
||||
pub fn button(
|
||||
batch: &mut HudBatch,
|
||||
@@ -814,7 +868,77 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
tooltip_box(batch, atlas, lines, x, y, w, h);
|
||||
}
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
|
||||
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
|
||||
/// pointer where it is, and its tooltip stays against the widget at `rect`.
|
||||
pub fn tooltip_menu(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
cursor: (f32, f32),
|
||||
rect: [f32; 4],
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = menu_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
(cursor.0 as i32, cursor.1 as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
|
||||
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
|
||||
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 and `MAX_DISTANCE_TO_WIDGET` 5 of the vertical
|
||||
/// offset from the widget's top or bottom edge.
|
||||
pub fn menu_tooltip_position(
|
||||
screen: (i32, i32),
|
||||
cursor: (i32, i32),
|
||||
rect: [i32; 4],
|
||||
size: (i32, i32),
|
||||
) -> (i32, i32) {
|
||||
let [_, y, _, height] = rect;
|
||||
let height = height.max(1);
|
||||
let mut pos = (cursor.0 + 12, cursor.1);
|
||||
if pos.0 + size.0 > screen.0 - 5 {
|
||||
pos.0 = (cursor.0 - 12 - size.0).max(9);
|
||||
}
|
||||
pos.1 += 3;
|
||||
// `bottom() + 3 + getOffset(0, 0, height)` is the widget's edge, `height`
|
||||
// further down; below that the tooltip goes under it, else over it.
|
||||
let below = size.1 + 6;
|
||||
if y + 2 * height + below <= screen.1 - 5 {
|
||||
pos.1 += widget_offset(pos.1, y, height);
|
||||
} else {
|
||||
pos.1 -= below + widget_offset(pos.1, y + height, height);
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
|
||||
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
|
||||
/// The distance over the height is an int division in vanilla, so the lerp
|
||||
/// is `height - 3` until the pointer is a whole height away, then 5.
|
||||
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
|
||||
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
|
||||
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
|
||||
}
|
||||
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
@@ -824,14 +948,18 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
} else {
|
||||
10.0 * lines.len() as f32
|
||||
};
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
fn tooltip_box(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -1642,6 +1770,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its
|
||||
/// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at
|
||||
/// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected
|
||||
/// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The
|
||||
/// Statistics tabs drew every label at 8 and an active unselected one gray.
|
||||
#[test]
|
||||
fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() {
|
||||
let atlas = atlas();
|
||||
let label = Text::literal("Items");
|
||||
let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor();
|
||||
// A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`.
|
||||
let left = 162.0 - half;
|
||||
for (selected, active, y, color) in [
|
||||
(true, true, 8.0, u32::MAX),
|
||||
(false, true, 10.0, u32::MAX),
|
||||
(false, false, 10.0, 0xFFA0_A0A0),
|
||||
] {
|
||||
let mut tab = HudBatch::new([640, 480]);
|
||||
menu_tab_label(
|
||||
&mut tab,
|
||||
&atlas,
|
||||
&label,
|
||||
(100.0, 0.0, 124.0, 24.0),
|
||||
selected,
|
||||
active,
|
||||
0,
|
||||
);
|
||||
let mut want = HudBatch::new([640, 480]);
|
||||
want.text_styled(&atlas, &label.resolved(), left, y, color, true);
|
||||
let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> {
|
||||
b.vertices
|
||||
.iter()
|
||||
.filter(|v| v.text == 1)
|
||||
.map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color))
|
||||
.collect()
|
||||
};
|
||||
assert!(!glyphs(&want).is_empty());
|
||||
assert_eq!(
|
||||
glyphs(&tab),
|
||||
glyphs(&want),
|
||||
"selected {selected}, active {active}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
@@ -1987,4 +2160,35 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
|
||||
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
|
||||
/// widget's top or bottom edge.
|
||||
#[test]
|
||||
fn a_menu_tooltip_stays_by_its_widget() {
|
||||
let screen = (427, 240);
|
||||
let widget = [100, 100, 150, 20];
|
||||
let size = (80, 10);
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px, then
|
||||
// `getOffset`'s int division gives `height - 3` = 17 more.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 100), widget, size),
|
||||
(112, 120)
|
||||
);
|
||||
// Near the right edge its left side mirrors to the pointer's left.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (400, 100), widget, size),
|
||||
(308, 120)
|
||||
);
|
||||
// A whole widget height from the edge, the offset is 5.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 117), widget, size),
|
||||
(112, 125)
|
||||
);
|
||||
// A widget too close to the bottom: the tooltip goes above it,
|
||||
// 203 - (10 + 6) - 17.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
|
||||
(112, 170)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ impl DirtySections {
|
||||
}
|
||||
}
|
||||
pub struct World {
|
||||
/// Server-owned mod data attached to this dimension.
|
||||
pub attachments: horizon_world::attachments::Attachments,
|
||||
/// The level's build range and skylight (its dimension type); its
|
||||
/// chunks hold `shape.column()`.
|
||||
pub shape: LevelShape,
|
||||
@@ -200,6 +202,7 @@ impl World {
|
||||
loaded_entities: Vec::new(),
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
attachments: Default::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
|
||||
@@ -114,6 +114,9 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
|
||||
system-allocator = []
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Translucent Batching": "Transparenz-Batching",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
@@ -142,14 +143,12 @@
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
"%s KiB/frame": "%s KiB/Bild",
|
||||
"%s nits": "%s Nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s Nits)",
|
||||
"%.2f nits": "%.2f Nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
|
||||
@@ -167,7 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Hides entities behind opaque blocks.": "Versteckt Entitäten hinter undurchsichtigen Blöcken.",
|
||||
"No entity occlusion culling.": "Keine Verdeckungserkennung für Entitäten.",
|
||||
"CPU occlusion test runs this frame.": "Die CPU prüft die Verdeckung in diesem Frame.",
|
||||
"Occlusion checks run on a worker thread.": "Verdeckungsprüfungen laufen in einem Worker-Thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
@@ -201,6 +203,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
@@ -289,5 +292,53 @@
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
|
||||
"Leaf Culling": "Laub-Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
|
||||
"Distant Plant Dither": "Pflanzen-Dithering",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.",
|
||||
"Screenshot Quality": "Screenshot-Qualität",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.",
|
||||
"Frame Generation": "Bildgenerierung",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.",
|
||||
"Shadow Cache": "Schatten-Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.",
|
||||
"Synchronous": "Synchron",
|
||||
"Asynchronous": "Asynchron",
|
||||
"Solid": "Massiv",
|
||||
"Solid Aggressive": "Massiv, aggressiv",
|
||||
"Hollow": "Hohl",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
"Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz",
|
||||
"horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung",
|
||||
"horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen",
|
||||
"horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s",
|
||||
"horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s",
|
||||
"horizon.agent.waiting": "Wartet auf einen Agenten.",
|
||||
"horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.",
|
||||
"horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.",
|
||||
"horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)",
|
||||
"horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
|
||||
"horizon.extras.chat_animation": "Weicher Chat",
|
||||
"horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.",
|
||||
"horizon.extras.chat_indicator": "Nachrichtenmarkierung",
|
||||
"horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.particles": "Partikel",
|
||||
"horizon.extras.header.sound": "Ton",
|
||||
"horizon.extras.hide_block_particles": "Blockpartikel weg",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.",
|
||||
"horizon.pages.open.split_setup": "Splitscreen …",
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
|
||||
}
|
||||
@@ -35,16 +35,16 @@
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.split_setup": "Splitscreen Setup",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
|
||||
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
|
||||
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
@@ -152,10 +152,7 @@
|
||||
"%s per frame": "%s per frame",
|
||||
"%s MiB/frame": "%s MiB/frame",
|
||||
"%s KiB/frame": "%s KiB/frame",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
|
||||
@@ -173,7 +170,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
|
||||
"No entity occlusion culling.": "No entity occlusion culling.",
|
||||
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
|
||||
"Occlusion checks run on a worker thread.": "Occlusion checks run on a worker thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
@@ -225,6 +225,7 @@
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.header.menus": "Menus",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
@@ -241,6 +242,7 @@
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
@@ -309,5 +311,120 @@
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
"horizon.splitscreen.setup.window": "Window %s",
|
||||
"horizon.splitscreen.setup.new_window": "New window",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
|
||||
"horizon.splitscreen.setup.done": "Done",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "On",
|
||||
"horizon.splitscreen.setup.off": "Off",
|
||||
"horizon.splitscreen.setup.player": "Player %s",
|
||||
"horizon.splitscreen.setup.revert": "Revert",
|
||||
"horizon.splitscreen.setup.apply": "Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
|
||||
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
|
||||
"horizon.splitscreen.setup.status.alone": "No other local players.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
|
||||
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
|
||||
"horizon.splitscreen.setup.note.select": "Click a player, or drag it to a window or a monitor.",
|
||||
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
|
||||
"horizon.splitscreen.setup.note.place": "Where should %s play?",
|
||||
"horizon.splitscreen.setup.this_player": "this player",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
|
||||
"horizon.splitscreen.setup.window_place": "Window %s (%s of %s)",
|
||||
"horizon.splitscreen.setup.names": "Names",
|
||||
"horizon.splitscreen.setup.identify": "Identify",
|
||||
"horizon.splitscreen.setup.layout.two": "2 sharing: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 sharing: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Side by side",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Top and bottom",
|
||||
"horizon.splitscreen.setup.layout.three.0": "One wide on top",
|
||||
"horizon.splitscreen.setup.layout.three.1": "One tall on the left",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Side by side",
|
||||
"horizon.splitscreen.setup.help.player": "Click or drag %s to move them.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (side by side when the window is wide). Press to change.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Press to change.",
|
||||
"horizon.splitscreen.setup.help.switch": "Splitscreen for this computer, on or off.",
|
||||
"horizon.splitscreen.setup.help.identify": "Each player's view shows which window it is in.",
|
||||
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
|
||||
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
|
||||
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Block Entities": "Block Entities",
|
||||
"Shulker Boxes": "Shulker Boxes",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Reuses settled boxes; lid movement stays animated.",
|
||||
"Signs": "Signs",
|
||||
"Reuses sign text until text, light, or font changes.": "Reuses sign text until text, light, or font changes.",
|
||||
"Decorated Pots": "Decorated Pots",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Reuses still pots; break wobble and sherd changes redraw.",
|
||||
"Bells": "Bells",
|
||||
"Reuses still bells; ringing stays animated.": "Reuses still bells; ringing stays animated.",
|
||||
"Chests": "Chests",
|
||||
"Reuses closed chests; moving lids stay animated.": "Reuses closed chests; moving lids stay animated.",
|
||||
"Banners": "Banners",
|
||||
"Reuses poles; flags and pattern layers still update.": "Reuses poles; flags and pattern layers still update.",
|
||||
"Copper Golem Statues": "Copper Golem Statues",
|
||||
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
|
||||
"Distant Plant Dither": "Distant Plant Dither",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
|
||||
"Screenshot Quality": "Screenshot Quality",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
"Frame Generation": "Frame Generation",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Find BlueMap": "Find BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
"BlueMap Radius": "BlueMap Radius",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "How far around you BlueMap tiles are downloaded. Next join.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
"Solid Aggressive": "Solid Aggressive",
|
||||
"Hollow": "Hollow",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.",
|
||||
"Hotbar: Previous Slot": "Hotbar: Previous Slot",
|
||||
"Hotbar: Next Slot": "Hotbar: Next Slot",
|
||||
"horizon.agent.connected.one": "Connected: 1 agent connection",
|
||||
"horizon.agent.connected.many": "Connected: %s agent connections",
|
||||
"horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago",
|
||||
"horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago",
|
||||
"horizon.agent.waiting": "Waiting for an agent.",
|
||||
"horizon.agent.off": "Off: agents can't reach this game.",
|
||||
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
|
||||
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"horizon.extras.repro_keys": "Recording shortcuts",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
|
||||
"horizon.agent.record_session": "Start Recording Session",
|
||||
"horizon.agent.record_stop": "Stop Recording Session",
|
||||
"horizon.agent.export_repro": "Export Repro Bundle",
|
||||
"horizon.repro.started": "Recording started; F3+R stops it and writes the bundle.",
|
||||
"horizon.repro.stopped": "Recording stopped: %s",
|
||||
"horizon.repro.bundle": "Repro bundle: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s"
|
||||
}
|
||||
Loaded 100 of 761 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user