mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #6690
post.rs: both new test modules kept. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
cfbd424754
661 files changed
+62909
-4968
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
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mcp/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
@@ -174,7 +175,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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,3 +91,27 @@ licensed under the MIT license:
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## FusionExamples
|
||||
|
||||
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
|
||||
texture templates (`templates/template <layout>.png`) of
|
||||
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2024 Martijn van den Brand
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
f32::consts::PI,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, PoisonError, RwLock},
|
||||
};
|
||||
|
||||
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
|
||||
/// normalized UVs) and named children.
|
||||
@@ -802,16 +807,102 @@ pub struct Scene<'a> {
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
vanilla: Arc<BlockEntityShapes>,
|
||||
/// What draws: `vanilla`, or the packs' custom entity models over it.
|
||||
shapes: RwLock<Arc<BlockEntityShapes>>,
|
||||
kinds: Vec<Kind>,
|
||||
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
|
||||
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
|
||||
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
|
||||
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
|
||||
textures: HashMap<&'static str, String>,
|
||||
}
|
||||
/// The chest models, each baked with its lid closed.
|
||||
const CHEST_MODELS: [&str; 7] = [
|
||||
"chest",
|
||||
"double_chest_left",
|
||||
"double_chest_right",
|
||||
"trapped_chest",
|
||||
"trapped_chest_left",
|
||||
"trapped_chest_right",
|
||||
"ender_chest",
|
||||
];
|
||||
impl BlockEntityShapes {
|
||||
fn new(
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
textures: HashMap<&'static str, String>,
|
||||
) -> Self {
|
||||
let closed_chests = CHEST_MODELS
|
||||
.iter()
|
||||
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
closed_chests,
|
||||
textures,
|
||||
}
|
||||
}
|
||||
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
|
||||
fn texture<'a>(
|
||||
&'a self,
|
||||
key: &str,
|
||||
vanilla: &'a str,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> &'a str {
|
||||
match self.textures.get(key) {
|
||||
Some(texture) if sprites.contains_key(texture) => texture,
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
|
||||
/// over the whole texture.
|
||||
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
|
||||
[false, true].map(|nether| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
vertices
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
@@ -894,6 +985,8 @@ impl BlockEntityRenderer {
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// The packs' custom entity models (`.jem` files), before anything draws.
|
||||
renderer.set_shapes(renderer.shapes_for(root));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
@@ -920,44 +1013,7 @@ impl BlockEntityRenderer {
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let closed_chests = [false, true].map(|nether| {
|
||||
["chest", "double_chest_left", "double_chest_right"].map(|name| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
if let Some(model) = models.get(name) {
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
}
|
||||
vertices
|
||||
})
|
||||
})
|
||||
});
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
|
||||
let kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
|
||||
@@ -970,14 +1026,31 @@ impl BlockEntityRenderer {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds,
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
}
|
||||
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
|
||||
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
|
||||
}
|
||||
fn shapes(&self) -> Arc<BlockEntityShapes> {
|
||||
self.shapes
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
@@ -1271,6 +1344,7 @@ impl BlockEntityRenderer {
|
||||
world_chests: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let shapes = self.shapes();
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
let paint = |name: &str, tint: u32, packed: u8| {
|
||||
let rect = if crumbling {
|
||||
@@ -1363,12 +1437,34 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
ChestPart::Right => "double_chest_right",
|
||||
// Trapped and ender chests have models of their own when a `.jem` reshapes
|
||||
// them; copper chests have no CEM name and draw vanilla's chest.
|
||||
let shapes: &BlockEntityShapes =
|
||||
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
|
||||
&shapes
|
||||
} else {
|
||||
&self.vanilla
|
||||
};
|
||||
let (own, chest) = match (block.as_str(), part) {
|
||||
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
|
||||
("trapped_chest", ChestPart::Left) => {
|
||||
("trapped_chest_left", "double_chest_left")
|
||||
}
|
||||
("trapped_chest", ChestPart::Right) => {
|
||||
("trapped_chest_right", "double_chest_right")
|
||||
}
|
||||
("ender_chest", _) => ("ender_chest", "chest"),
|
||||
(_, ChestPart::Single) => ("chest", "chest"),
|
||||
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
|
||||
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
|
||||
};
|
||||
let Some(model) = self.models.get(model) else {
|
||||
let key = if shapes.models.contains_key(own) {
|
||||
own
|
||||
} else {
|
||||
chest
|
||||
};
|
||||
let texture = shapes.texture(key, texture, scene.sprites);
|
||||
let Some(model) = shapes.models.get(key) else {
|
||||
return;
|
||||
};
|
||||
let (open, other) = self.chest_openness(
|
||||
@@ -1384,9 +1480,11 @@ impl BlockEntityRenderer {
|
||||
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
|
||||
}
|
||||
if world_chests && open == 0.0 {
|
||||
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
|
||||
let vertices = &self.closed_chests[usize::from(scene.nether)]
|
||||
[*part as usize][*y_rot as usize / 90];
|
||||
if let (Some(paint), Some(closed)) = (
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.closed_chests.get(key),
|
||||
) {
|
||||
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
|
||||
let origin = base.w_axis.truncate();
|
||||
let passes = std::iter::once((paint.sprite, paint.light))
|
||||
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
|
||||
@@ -1421,8 +1519,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Shulker { texture, facing } => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("shulker_box"),
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.models.get("shulker_box"),
|
||||
paint(
|
||||
shapes.texture("shulker_box", texture, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1446,12 +1548,13 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &lid, paint, out);
|
||||
}
|
||||
Kind::Banner { color, wall, angle } => {
|
||||
let (main, flag) = if *wall {
|
||||
let (main_key, flag_key) = if *wall {
|
||||
("wall_banner", "wall_banner_flag")
|
||||
} else {
|
||||
("standing_banner", "standing_banner_flag")
|
||||
};
|
||||
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
|
||||
let (Some(main), Some(flag)) =
|
||||
(shapes.models.get(main_key), shapes.models.get(flag_key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1472,8 +1575,13 @@ impl BlockEntityRenderer {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
|
||||
}
|
||||
};
|
||||
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
|
||||
let banner_base = "entity/banner/banner_base";
|
||||
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
|
||||
draw_part(main, "root", m, &none, paint, out);
|
||||
}
|
||||
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
|
||||
draw_part(flag, "root", m, &wave, paint, out);
|
||||
}
|
||||
// Pattern passes must not multiply the crumbling decal.
|
||||
@@ -1515,9 +1623,12 @@ impl BlockEntityRenderer {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
// A `.jem` texture stands in for the default skin, not a profile's.
|
||||
let texture = skin
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1559,8 +1670,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("bell"),
|
||||
paint("entity/bell/bell_body", 0xFFFFFF, packed),
|
||||
shapes.models.get("bell"),
|
||||
paint(
|
||||
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1588,8 +1703,8 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::DecoratedPot { y_rot } => {
|
||||
let (Some(base_model), Some(sides)) = (
|
||||
self.models.get("decorated_pot_base"),
|
||||
self.models.get("decorated_pot_sides"),
|
||||
shapes.models.get("decorated_pot_base"),
|
||||
shapes.models.get("decorated_pot_sides"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1617,17 +1732,20 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
let pot_base = "entity/decorated_pot/decorated_pot_base";
|
||||
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
|
||||
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
|
||||
draw_part(base_model, "root", m, &none, paint, out);
|
||||
}
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank
|
||||
// side (a `.jem`'s texture, if any).
|
||||
let blank = "entity/decorated_pot/decorated_pot_side";
|
||||
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
|
||||
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
|
||||
let texture = item
|
||||
.as_ref()
|
||||
.and_then(|i| self.pot_patterns.get(i))
|
||||
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
|
||||
.map_or(blank, String::as_str);
|
||||
if let (Some(part), Some(paint)) =
|
||||
(sides.child(side), paint(texture, 0xFFFFFF, packed))
|
||||
{
|
||||
@@ -1674,20 +1792,33 @@ impl BlockEntityRenderer {
|
||||
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
|
||||
}
|
||||
Kind::Conduit => {
|
||||
// HDR output: the conduit shines, not its crumbling overlay.
|
||||
let start = out.len();
|
||||
let shine = |out: &mut Vec<Vertex>| {
|
||||
if !crumbling {
|
||||
for v in &mut out[start..] {
|
||||
v.layer |= u32::from(crate::mesher::BRIGHT) << 16;
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
|
||||
let tex = |key: &str, name: &str| {
|
||||
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
|
||||
};
|
||||
if !active {
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
|
||||
else {
|
||||
let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_shell"),
|
||||
tex("conduit_shell", "entity/conduit/base"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_y(rotation.to_radians());
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
shine(out);
|
||||
return;
|
||||
}
|
||||
let anim_time = ticks as f32 + scene.partial;
|
||||
@@ -1696,9 +1827,10 @@ impl BlockEntityRenderer {
|
||||
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
|
||||
let hh = hh * hh + hh;
|
||||
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
|
||||
if let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
|
||||
{
|
||||
if let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_cage"),
|
||||
tex("conduit_cage", "entity/conduit/cage"),
|
||||
) {
|
||||
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
@@ -1714,7 +1846,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/wind"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
|
||||
{
|
||||
let turn = match phase {
|
||||
1 => Mat4::from_rotation_x(PI / 2.0),
|
||||
2 => Mat4::from_rotation_z(PI / 2.0),
|
||||
@@ -1732,7 +1866,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/closed_eye"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
|
||||
{
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
@@ -1741,11 +1877,17 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_scale(Vec3::splat(1.333_333_4));
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
}
|
||||
shine(out);
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get("book"),
|
||||
paint(
|
||||
shapes.texture("book", book, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1764,9 +1906,16 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
|
||||
}
|
||||
Kind::Lectern { y_rot } => {
|
||||
// Its own `.jem`'s book, else the enchanting table's.
|
||||
let key = if shapes.models.contains_key("lectern_book") {
|
||||
"lectern_book"
|
||||
} else {
|
||||
"book"
|
||||
};
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get(key),
|
||||
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1797,7 +1946,7 @@ impl BlockEntityRenderer {
|
||||
_ => "copper_golem",
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -2003,11 +2152,13 @@ const PORTAL_FACES: [[[f32; 3]; 4]; 6] = [
|
||||
];
|
||||
/// `AbstractEndPortalRenderer.submitCube`: position-only quads (the portal
|
||||
/// shader projects its textures from the screen; uv, colour and light are unused).
|
||||
/// `rendertype_end_portal` culls back faces, as `entitySolid` does, so the cube's
|
||||
/// two Y faces are one-sided: only the one facing the camera draws.
|
||||
fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>) {
|
||||
for &d in faces {
|
||||
for c in PORTAL_FACES[d as usize] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: CULL_BACK,
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
@@ -2449,7 +2600,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
vanilla: Default::default(),
|
||||
shapes: Default::default(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
@@ -2462,7 +2614,6 @@ mod tests {
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
@@ -2578,6 +2729,35 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
/// face only the side looking at the camera draws: the portal shows one
|
||||
/// surface from above and one from below, never both at once.
|
||||
#[test]
|
||||
fn the_end_portal_cube_is_one_sided() {
|
||||
let mut out = Vec::new();
|
||||
portal_cube(
|
||||
Mat4::IDENTITY,
|
||||
&[Direction::Down, Direction::Up],
|
||||
END_PORTAL_LAYER,
|
||||
&mut out,
|
||||
);
|
||||
// `TheEndPortalBlockEntity.shouldRenderFace` keeps the Y faces only.
|
||||
assert_eq!(out.len(), 8, "the cube submits both Y faces");
|
||||
assert!(
|
||||
out.iter().all(|v| v.normal == CULL_BACK),
|
||||
"rendertype_end_portal culls back faces"
|
||||
);
|
||||
// Each face is wound outward, so the shader's `!front` discard keeps the
|
||||
// camera-facing side of it and drops the other.
|
||||
let facing = |quad: &[Vertex]| {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
(p(1) - p(0)).cross(p(2) - p(0))
|
||||
};
|
||||
assert!(facing(&out[..4]).y < 0.0, "the Down face looks down");
|
||||
assert!(facing(&out[4..]).y > 0.0, "the Up face looks up");
|
||||
}
|
||||
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
@@ -2646,4 +2826,131 @@ mod tests {
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
));
|
||||
let renderer = BlockEntityRenderer {
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
|
||||
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
|
||||
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
|
||||
let root = pack(
|
||||
"render-chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png",
|
||||
"models": [{{"part": "lid", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
renderer.set_shapes(renderer.shapes_for(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&empty);
|
||||
let rect = |u0: f32| Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.2,
|
||||
v1: 0.2,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
("entity/chest/normal", rect(0.0)),
|
||||
("entity/chest/ender", rect(0.2)),
|
||||
("entity/chest/fancy", rect(0.4)),
|
||||
("entity/chest/copper", rect(0.6)),
|
||||
]
|
||||
.map(|(name, r)| (name.to_owned(), r))
|
||||
.into();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
|
||||
let kind = Kind::Chest {
|
||||
block: block.into(),
|
||||
texture,
|
||||
part: ChestPart::Single,
|
||||
y_rot: 0.0,
|
||||
double_with: None,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
renderer.render_one(
|
||||
&scene,
|
||||
[0; 3],
|
||||
&kind,
|
||||
Mat4::IDENTITY,
|
||||
0xF0,
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut out,
|
||||
);
|
||||
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
("copper_chest", "entity/chest/copper", 0.6, 3),
|
||||
] {
|
||||
let out = draw(&renderer, block, texture);
|
||||
assert_eq!(out.len(), faces * 4, "{block}");
|
||||
assert!(
|
||||
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
|
||||
"{block}"
|
||||
);
|
||||
}
|
||||
// A copper chest draws exactly what it draws without any pack.
|
||||
let plain = BlockEntityRenderer {
|
||||
shapes: RwLock::new(renderer.vanilla.clone()),
|
||||
vanilla: renderer.vanilla.clone(),
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
draw(&renderer, "copper_chest", "entity/chest/copper"),
|
||||
draw(&plain, "copper_chest", "entity/chest/copper")
|
||||
);
|
||||
// The atlas has no such texture: vanilla's.
|
||||
let shapes = renderer.shapes();
|
||||
assert_eq!(
|
||||
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
|
||||
"entity/chest/normal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -387,7 +387,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 +397,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 +429,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 +498,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 +539,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 +706,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 +732,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 +855,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 +888,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 +932,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 +1013,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
at,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| out.push(o.layer),
|
||||
);
|
||||
(out, pick)
|
||||
@@ -962,6 +1051,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 +1089,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
door_north,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| got.push(o),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1039,6 +1130,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,7 @@ 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));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@@ -375,8 +399,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,21 @@ 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,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -160,8 +171,12 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,8 +255,12 @@ 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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +268,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:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -287,8 +306,12 @@ 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),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -402,6 +425,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,6 +452,8 @@ 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,
|
||||
}
|
||||
@@ -469,6 +498,7 @@ 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",
|
||||
@@ -490,6 +520,7 @@ 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",
|
||||
@@ -522,6 +553,7 @@ 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",
|
||||
}
|
||||
@@ -535,7 +567,7 @@ impl Item {
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
return None;
|
||||
}
|
||||
@@ -588,6 +620,9 @@ 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -623,6 +658,7 @@ 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,
|
||||
_ => return None,
|
||||
@@ -655,6 +691,7 @@ 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::SortKey => {}
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -539,6 +543,11 @@ impl Hdr {
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
@@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
@@ -670,3 +685,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.
|
||||
@@ -493,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`).
|
||||
@@ -511,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`.
|
||||
@@ -534,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.*`.
|
||||
@@ -648,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),
|
||||
}
|
||||
}
|
||||
@@ -1074,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,
|
||||
};
|
||||
@@ -1085,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.
|
||||
@@ -1342,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));
|
||||
}
|
||||
@@ -1434,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 {
|
||||
@@ -1894,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))
|
||||
@@ -1911,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())
|
||||
@@ -2046,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()
|
||||
@@ -2127,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>) =
|
||||
@@ -2216,6 +2283,8 @@ impl PackPost {
|
||||
});
|
||||
}
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
@@ -2235,6 +2304,7 @@ impl PackPost {
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
shadow_water,
|
||||
@@ -2245,6 +2315,7 @@ impl PackPost {
|
||||
draws,
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
gui_size: world.gui_size,
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
@@ -2268,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() {
|
||||
@@ -2473,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
|
||||
@@ -2480,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)
|
||||
@@ -2651,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);
|
||||
@@ -2870,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();
|
||||
@@ -2886,6 +2979,7 @@ impl PackPost {
|
||||
entities,
|
||||
spans,
|
||||
drawn,
|
||||
(crumbling, self.gui_size),
|
||||
shadow,
|
||||
rest,
|
||||
);
|
||||
@@ -2936,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);
|
||||
}
|
||||
|
||||
@@ -3327,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.
|
||||
@@ -3360,6 +3475,7 @@ impl PackPost {
|
||||
depth: depth_texture.create_view(&Default::default()),
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -4055,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);
|
||||
|
||||
@@ -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,148 @@ 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: &[],
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -582,8 +590,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.
|
||||
@@ -830,6 +839,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 {
|
||||
|
||||
@@ -606,24 +606,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)])
|
||||
@@ -2229,4 +2226,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,15 @@ impl TextureFlags {
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
/// 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 +257,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 +492,76 @@ 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 {
|
||||
/// 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> {
|
||||
@@ -622,7 +708,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 +752,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 +779,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 +1143,9 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -1155,6 +1268,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 +2005,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 +2038,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 +2094,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 +2126,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 +2232,7 @@ impl HudRenderer {
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
"fs_vignette",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
@@ -2122,7 +2258,9 @@ impl HudRenderer {
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
group,
|
||||
cit_glint: None,
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
@@ -2132,6 +2270,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 +2517,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 +2879,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 +2995,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 +3049,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 +3059,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 +3295,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 border_render;
|
||||
pub mod breaking;
|
||||
@@ -20,7 +21,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;
|
||||
@@ -46,6 +49,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;
|
||||
@@ -87,7 +91,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()];
|
||||
@@ -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,49 +909,100 @@ 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).
|
||||
@@ -892,13 +1010,36 @@ impl Padded {
|
||||
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,
|
||||
@@ -1162,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
|
||||
@@ -1177,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 {
|
||||
@@ -1195,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 {
|
||||
@@ -1239,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;
|
||||
@@ -1348,19 +1588,23 @@ 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
|
||||
@@ -1379,6 +1623,14 @@ impl Mesher<'_> {
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
@@ -1395,12 +1647,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],
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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,
|
||||
@@ -4155,7 +4239,10 @@ impl OptionsUi {
|
||||
}
|
||||
// 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 +4428,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 +4772,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 +4811,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 +4825,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 +4848,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 +4866,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 +4890,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 +4899,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 +4939,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 +4964,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 +4982,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 +5015,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 +5445,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 +7247,80 @@ 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 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()
|
||||
|
||||
@@ -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).
|
||||
@@ -293,6 +317,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");
|
||||
|
||||
@@ -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;
|
||||
@@ -201,6 +234,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,
|
||||
@@ -276,8 +310,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>>,
|
||||
@@ -285,11 +317,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 {
|
||||
@@ -364,7 +391,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,
|
||||
@@ -435,6 +466,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,
|
||||
@@ -476,6 +510,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,
|
||||
@@ -485,17 +527,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;
|
||||
@@ -533,6 +610,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'
|
||||
@@ -592,28 +693,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>,
|
||||
@@ -629,190 +920,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))
|
||||
@@ -820,7 +944,8 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
non_air_sections,
|
||||
threads,
|
||||
jobs,
|
||||
workers,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
@@ -859,6 +984,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,
|
||||
@@ -1019,7 +1145,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
|
||||
@@ -1131,6 +1257,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));
|
||||
@@ -1482,7 +1615,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.
|
||||
@@ -1531,7 +1664,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.
|
||||
@@ -1593,14 +1726,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)> =
|
||||
@@ -1620,6 +1763,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)| {
|
||||
@@ -1673,7 +1817,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 {
|
||||
@@ -1759,7 +1903,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 {
|
||||
@@ -1782,6 +1926,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,
|
||||
@@ -2431,11 +2576,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
|
||||
@@ -2547,14 +2689,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(
|
||||
@@ -3136,6 +3271,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);
|
||||
}
|
||||
@@ -242,12 +231,9 @@ impl Session {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
crate::spans::startup::mark("title.shown");
|
||||
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)
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 @@
|
||||
"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.",
|
||||
"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 +199,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 +288,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,6 @@
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -309,5 +305,100 @@
|
||||
"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.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.",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
@@ -140,10 +140,7 @@
|
||||
"%s per frame": "%s por fotograma",
|
||||
"%s MiB/frame": "%s MiB/fotograma",
|
||||
"%s KiB/frame": "%s KiB/fotograma",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Automático (%s)",
|
||||
"Auto (%s nits)": "Automático (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
|
||||
@@ -161,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Translucent Batching": "Regroupement translucide",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
@@ -142,14 +143,12 @@
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s par image",
|
||||
"%s MiB/frame": "%s MiB/image",
|
||||
"%s KiB/frame": "%s KiB/image",
|
||||
"%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.": "Terrain en détail complet. La distance LOD va plus loin.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
|
||||
@@ -167,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
@@ -201,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
@@ -289,5 +288,102 @@
|
||||
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
|
||||
"horizon.splitscreen.setup.monitor": "Écran %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "un écran",
|
||||
"horizon.splitscreen.setup.window": "Fenêtre %s",
|
||||
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
|
||||
"horizon.splitscreen.setup.done": "Terminé",
|
||||
"horizon.splitscreen.setup.switch": "%s : %s",
|
||||
"horizon.splitscreen.setup.on": "Activé",
|
||||
"horizon.splitscreen.setup.off": "Désactivé",
|
||||
"horizon.splitscreen.setup.player": "Joueur %s",
|
||||
"horizon.splitscreen.setup.revert": "Rétablir",
|
||||
"horizon.splitscreen.setup.apply": "Appliquer",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
|
||||
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
|
||||
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
|
||||
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
|
||||
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
|
||||
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur, ou faites-le glisser vers une fenêtre ou un écran.",
|
||||
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
|
||||
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
|
||||
"horizon.splitscreen.setup.this_player": "ce joueur",
|
||||
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
|
||||
"horizon.pages.split_setup": "Configuration de l'écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"horizon.splitscreen.setup.window_place": "Fenêtre %s (%s sur %s)",
|
||||
"horizon.splitscreen.setup.names": "Noms",
|
||||
"horizon.splitscreen.setup.identify": "Identifier",
|
||||
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Un haut à gauche",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Côte à côte",
|
||||
"horizon.splitscreen.setup.help.player": "Cliquez sur %s ou faites-le glisser pour le déplacer.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (côte à côte si la fenêtre est large). Appuyez pour changer.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Appuyez pour changer.",
|
||||
"horizon.splitscreen.setup.help.switch": "L'écran partagé sur cet ordinateur, activé ou désactivé.",
|
||||
"horizon.splitscreen.setup.help.identify": "La vue de chaque joueur indique sa fenêtre.",
|
||||
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
|
||||
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
|
||||
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.",
|
||||
"Distant Plant Dither": "Tramage des plantes",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.",
|
||||
"Screenshot Quality": "Qualité des captures",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.",
|
||||
"Frame Generation": "Génération d'images",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
"Solid Aggressive": "Opaque agressif",
|
||||
"Hollow": "Creux",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
"Hotbar: Previous Slot": "Barre d'action : case précédente",
|
||||
"Hotbar: Next Slot": "Barre d'action : case suivante",
|
||||
"horizon.agent.connected.one": "Connecté : 1 connexion d'agent",
|
||||
"horizon.agent.connected.many": "Connecté : %s connexions d'agent",
|
||||
"horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.waiting": "En attente d'un agent.",
|
||||
"horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.",
|
||||
"horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.",
|
||||
"horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)",
|
||||
"horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
|
||||
"horizon.extras.chat_animation": "Chat fluide",
|
||||
"horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.",
|
||||
"horizon.extras.chat_indicator": "Marqueur de message",
|
||||
"horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.particles": "Particules",
|
||||
"horizon.extras.header.sound": "Son",
|
||||
"horizon.extras.hide_block_particles": "Masquer particules",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Translucent Batching": "Grupowanie półprzezroczystych",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
@@ -142,14 +143,12 @@
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
"%s KiB/frame": "%s KiB/klatka",
|
||||
"%s nits": "%s nitów",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nitów)",
|
||||
"%.2f nits": "%.2f nitów",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
|
||||
@@ -167,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
@@ -201,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
@@ -289,5 +288,53 @@
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
"horizon.osk.close": "Zamknij",
|
||||
"Culling": "Pomijanie",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
|
||||
"Leaf Culling": "Pomijanie liści",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
|
||||
"Distant Plant Dither": "Dithering roślin",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.",
|
||||
"Screenshot Quality": "Jakość zrzutów ekranu",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.",
|
||||
"Frame Generation": "Generowanie klatek",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.",
|
||||
"Shadow Cache": "Pamięć cieni",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.",
|
||||
"Synchronous": "Synchroniczne",
|
||||
"Asynchronous": "Asynchroniczne",
|
||||
"Solid": "Pełne",
|
||||
"Solid Aggressive": "Pełne, agresywnie",
|
||||
"Hollow": "Puste",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
"Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole",
|
||||
"Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole",
|
||||
"horizon.agent.connected.one": "Połączono: 1 połączenie agenta",
|
||||
"horizon.agent.connected.many": "Połączenia agenta: %s",
|
||||
"horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.waiting": "Czeka na agenta.",
|
||||
"horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.",
|
||||
"horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.",
|
||||
"horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)",
|
||||
"horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
|
||||
"horizon.extras.chat_animation": "Płynny czat",
|
||||
"horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.",
|
||||
"horizon.extras.chat_indicator": "Znacznik wiadomości",
|
||||
"horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.",
|
||||
"horizon.extras.header.chat": "Czat",
|
||||
"horizon.extras.header.particles": "Cząsteczki",
|
||||
"horizon.extras.header.sound": "Dźwięk",
|
||||
"horizon.extras.hide_block_particles": "Ukryj cząstki bloków",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.",
|
||||
"horizon.extras.splitscreen": "Podzielony ekran",
|
||||
"horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.",
|
||||
"horizon.pages.open.split_setup": "Podzielony ekran…",
|
||||
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
|
||||
"horizon.pages.split_setup": "Podzielony ekran",
|
||||
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
|
||||
}
|
||||
@@ -139,10 +139,7 @@
|
||||
"%s per frame": "%s за кадр",
|
||||
"%s MiB/frame": "%s МиБ/кадр",
|
||||
"%s KiB/frame": "%s КиБ/кадр",
|
||||
"%s nits": "%s нит",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s нит)",
|
||||
"%.2f nits": "%.2f нит",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.",
|
||||
@@ -160,7 +157,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
|
||||
@@ -140,10 +140,7 @@
|
||||
"%s per frame": "Kare başına %s",
|
||||
"%s MiB/frame": "Kare başına %s MiB",
|
||||
"%s KiB/frame": "Kare başına %s KiB",
|
||||
"%s nits": "%s nit",
|
||||
"Auto (%s)": "Otomatik (%s)",
|
||||
"Auto (%s nits)": "Otomatik (%s nit)",
|
||||
"%.2f nits": "%.2f nit",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Arazinin tam ayrıntıyla görüntülendiği mesafe. LOD Mesafesi, yerel dünyalarda bu mesafeyi daha da artırabilir.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerel dünyalarda uzaktaki arazileri daha düşük ayrıntıyla görüntüler. Tümleşik GPU'larda sınır 128 parçadır.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Düşük ayarı, uzak arazileri daha düşük ayrıntıyla görüntüler. Bu tümleşik GPU'da daha yüksek performans sağlar.",
|
||||
@@ -161,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Translucent Batching": "Групування напівпрозорого",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
@@ -142,14 +143,12 @@
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
|
||||
"%s chunks": "%s чанків",
|
||||
"%s per frame": "%s на кадр",
|
||||
"%s MiB/frame": "%s MiB/кадр",
|
||||
"%s KiB/frame": "%s KiB/кадр",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
|
||||
@@ -167,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
@@ -201,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода і скло в одному буфері: менше викликів малювання, трохи більше VRAM. Після перезапуску.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
@@ -289,5 +288,53 @@
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
"horizon.osk.close": "Закрити",
|
||||
"Culling": "Відсікання",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
|
||||
"Leaf Culling": "Відсікання листя",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
|
||||
"Distant Plant Dither": "Дизеринг рослин",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Не дає дрібним рослинам у пакетах високої роздільності зникати здалеку.",
|
||||
"Screenshot Quality": "Якість знімків екрана",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Знімки F2 у стільки разів більшій роздільності, ніж гра. Більше значить довше.",
|
||||
"Frame Generation": "Генерація кадрів",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показує згенерований кадр між кожними двома справжніми: плавніше, трохи більша затримка.",
|
||||
"Shadow Cache": "Кеш тіней",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.",
|
||||
"Synchronous": "Синхронно",
|
||||
"Asynchronous": "Асинхронно",
|
||||
"Solid": "Суцільне",
|
||||
"Solid Aggressive": "Суцільне, агресивно",
|
||||
"Hollow": "Порожнє",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листя всередині крони малюється суцільним: менше прозорих шарів.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Листя з одним відкритим боком теж малюється суцільним: швидше, грубіші краї.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
"Hotbar: Previous Slot": "Панель швидкого доступу: попередній слот",
|
||||
"Hotbar: Next Slot": "Панель швидкого доступу: наступний слот",
|
||||
"horizon.agent.connected.one": "Підключено: 1 з’єднання агента",
|
||||
"horizon.agent.connected.many": "Підключено з’єднань агента: %s",
|
||||
"horizon.agent.connected.one_ago": "Підключено: 1 з’єднання агента, остання команда %s с тому",
|
||||
"horizon.agent.connected.many_ago": "Підключено з’єднань агента: %s, остання команда %s с тому",
|
||||
"horizon.agent.waiting": "Очікування агента.",
|
||||
"horizon.agent.off": "Вимкнено: агенти не можуть дістатися цієї гри.",
|
||||
"horizon.agent.session_opened": "Відкрито на цей сеанс програмою, що запустила гру.",
|
||||
"horizon.agent.socket": "Сокет: %s (скопіюйте дані, щоб побачити повний шлях)",
|
||||
"horizon.agent.local_only": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.",
|
||||
"horizon.extras.chat_animation": "Плавний чат",
|
||||
"horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.",
|
||||
"horizon.extras.chat_indicator": "Позначка повідомлення",
|
||||
"horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.",
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
"horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.",
|
||||
"horizon.extras.ui_sound_reserve": "Канали для звуків UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.",
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"UI Brightness": "界面亮度",
|
||||
"Battery Mode": "电池模式",
|
||||
"Graphics API": "图形 API",
|
||||
"Translucent Batching": "半透明批处理",
|
||||
"Performance Overlay": "性能叠加层",
|
||||
"Full Detail Distance": "全细节距离",
|
||||
"On": "开",
|
||||
@@ -136,14 +137,12 @@
|
||||
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
|
||||
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
|
||||
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
|
||||
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
|
||||
"%s chunks": "%s 个区块",
|
||||
"%s per frame": "每帧 %s",
|
||||
"%s MiB/frame": "每帧 %s MiB",
|
||||
"%s KiB/frame": "每帧 %s KiB",
|
||||
"%s nits": "%s 尼特",
|
||||
"Auto (%s)": "自动(%s)",
|
||||
"Auto (%s nits)": "自动(%s 尼特)",
|
||||
"%.2f nits": "%.2f 尼特",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
|
||||
@@ -161,7 +160,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
@@ -195,6 +193,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
@@ -306,5 +305,36 @@
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
|
||||
"Culling": "剔除",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
|
||||
"Leaf Culling": "树叶剔除",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
|
||||
"Distant Plant Dither": "远处植物抖动",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "避免高分辨率资源包中的细小植物在远处逐渐消失。",
|
||||
"Screenshot Quality": "截图质量",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 截图的分辨率为游戏分辨率的这个倍数。越高耗时越长。",
|
||||
"Frame Generation": "帧生成",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "在每两个真实帧之间插入一个生成帧:更流畅,延迟略增。",
|
||||
"Shadow Cache": "阴影缓存",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。",
|
||||
"Synchronous": "同步",
|
||||
"Asynchronous": "异步",
|
||||
"Solid": "实心",
|
||||
"Solid Aggressive": "激进实心",
|
||||
"Hollow": "镂空",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "树冠内部的树叶绘制为实心:透明层更少。",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "只有一面外露的树叶也绘制为实心:更快,边缘更粗糙。",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "省略树叶之间的面:更快,但缝隙会透视。",
|
||||
"Hotbar: Previous Slot": "快捷栏:上一个栏位",
|
||||
"Hotbar: Next Slot": "快捷栏:下一个栏位",
|
||||
"horizon.agent.connected.one": "已连接:1 个代理连接",
|
||||
"horizon.agent.connected.many": "已连接:%s 个代理连接",
|
||||
"horizon.agent.connected.one_ago": "已连接:1 个代理连接,上次命令在 %s 秒前",
|
||||
"horizon.agent.connected.many_ago": "已连接:%s 个代理连接,上次命令在 %s 秒前",
|
||||
"horizon.agent.waiting": "正在等待代理。",
|
||||
"horizon.agent.off": "关闭:代理无法访问此游戏。",
|
||||
"horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。",
|
||||
"horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)",
|
||||
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。"
|
||||
}
|
||||
@@ -24,7 +24,15 @@ fn main() {
|
||||
resource.set_manifest_file(&manifest);
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
|
||||
// with a space, and a Windows user name often has one: such a build goes on without
|
||||
// the icon. Release builds run in CI, where the icon stays required.
|
||||
if std::env::var_os("CI").is_some() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
println!(
|
||||
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
// Generation and light, on the game's worker pool (`threads` workers).
|
||||
// Meshing is left out here and measured on its own below.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
@@ -218,6 +219,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Generation and light throughput of the game's worker pool, and how it
|
||||
//! scales with the number of workers: the columns around a point go through
|
||||
//! the whole pipeline (proto chunk, decoration, final column, light) as when a
|
||||
//! world loads, once per worker count, each on a freshly seeded generator.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
|
||||
//!
|
||||
//! For each run it prints the wall time until every column within the radius
|
||||
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
|
||||
//! column, the coordinator's (`Streaming::update`) share, and the workers' CPU
|
||||
//! time by job kind (`streaming::job_stats`). The shared worker pool is sized
|
||||
//! once per process (`HORIZON_JOB_THREADS`), so each worker count runs in a
|
||||
//! process of its own, started by this one. The checksum
|
||||
//! covers the blocks and light of the columns within the radius: an A/B pair of
|
||||
//! builds, and every worker count, must print the same one.
|
||||
//! `--period-ms` is how long the coordinator sleeps between updates (the
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
|
||||
//! orders work ring by ring, as under the level loading screen.
|
||||
//!
|
||||
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
|
||||
//! one whose jobs sleep that long and make empty columns: what is left is the
|
||||
//! pipeline itself (the coordinator, the in-flight limits, decoration order,
|
||||
//! neighbour waits). Sleeping workers don't compete for cores, so this shows
|
||||
//! how the pipeline scales to many workers even on a busy machine.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
light::LightTables,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext, job_stats},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn process_cpu() -> Duration {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let t = rustix::time::clock_gettime(rustix::time::ClockId::ProcessCPUTime);
|
||||
Duration::new(t.tv_sec as u64, t.tv_nsec as u32)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Duration::ZERO
|
||||
}
|
||||
|
||||
/// Jobs that take a fixed time and make empty columns (`--simulate`).
|
||||
struct Simulated {
|
||||
proto: Duration,
|
||||
decorate: Duration,
|
||||
finish: Duration,
|
||||
}
|
||||
impl ChunkGenerator for Simulated {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
fn proto(&self, _worker: &mut (), x: i32, z: i32) -> Proto {
|
||||
thread::sleep(self.proto);
|
||||
Box::new((x, z))
|
||||
}
|
||||
fn decorates(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn decorate(&self, _worker: &mut (), _region: &mut [Proto; 9]) {
|
||||
thread::sleep(self.decorate);
|
||||
}
|
||||
fn finish(&self, proto: Proto) -> Chunk {
|
||||
thread::sleep(self.finish);
|
||||
let (x, z) = *proto.downcast::<(i32, i32)>().expect("simulated proto");
|
||||
Chunk::empty(x, z)
|
||||
}
|
||||
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
|
||||
let &(x, z) = proto.downcast_ref::<(i32, i32)>()?;
|
||||
Some(Chunk::empty(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
wall: f64,
|
||||
columns: usize,
|
||||
lit: usize,
|
||||
cpu: f64,
|
||||
coordinator: f64,
|
||||
updates: usize,
|
||||
jobs: Vec<(&'static str, u64, u64)>,
|
||||
checksum: u64,
|
||||
half: f64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run<G: ChunkGenerator>(
|
||||
generator: G,
|
||||
context: &Arc<WorkerContext>,
|
||||
center: (i32, i32),
|
||||
radius: i32,
|
||||
threads: usize,
|
||||
period: Duration,
|
||||
loading: bool,
|
||||
) -> Result<Run, Error> {
|
||||
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
streaming.loading = loading;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
|
||||
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
|
||||
// The columns Streaming finishes (`Ranges::in_main_range` with a
|
||||
// margin of 1). `in_view_distance` reaches a few columns past them.
|
||||
.filter(|&(x, z)| {
|
||||
let (dx, dz) = (i64::from(x - center.0), i64::from(z - center.1));
|
||||
dx * dx + dz * dz <= i64::from(radius + 1).pow(2)
|
||||
})
|
||||
.collect();
|
||||
job_stats::enable(true);
|
||||
job_stats::take();
|
||||
let (cpu0, started) = (process_cpu(), Instant::now());
|
||||
let mut coordinator = Duration::ZERO;
|
||||
let mut updates = 0;
|
||||
let mut half = None;
|
||||
let trace = std::env::var_os("GEN_LIGHT_TRACE").is_some();
|
||||
let mut next_trace = Duration::from_secs(2);
|
||||
loop {
|
||||
let t = thread_cpu().unwrap_or_default();
|
||||
let _ = streaming.update(&mut world);
|
||||
coordinator += thread_cpu().unwrap_or_default().saturating_sub(t);
|
||||
updates += 1;
|
||||
let lit = view
|
||||
.iter()
|
||||
.filter(|k| world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.count();
|
||||
if half.is_none() && lit * 2 >= view.len() {
|
||||
half = Some(started.elapsed().as_secs_f64());
|
||||
}
|
||||
if lit == view.len() {
|
||||
break;
|
||||
}
|
||||
if trace && started.elapsed() >= next_trace {
|
||||
next_trace += Duration::from_secs(2);
|
||||
let missing: Vec<_> = view
|
||||
.iter()
|
||||
.filter(|k| !world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.take(4)
|
||||
.collect();
|
||||
eprintln!(
|
||||
" {:.0} s: {lit}/{} lit, {} columns, {:?}, waiting e.g. {missing:?}",
|
||||
started.elapsed().as_secs_f64(),
|
||||
view.len(),
|
||||
world.chunks.len(),
|
||||
streaming.stages()
|
||||
);
|
||||
}
|
||||
if started.elapsed() > Duration::from_secs(1800) {
|
||||
return Err("timed out".into());
|
||||
}
|
||||
streaming.wait(period);
|
||||
}
|
||||
let wall = started.elapsed().as_secs_f64();
|
||||
let cpu = (process_cpu() - cpu0).as_secs_f64();
|
||||
let jobs = job_stats::take();
|
||||
job_stats::enable(false);
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
for key in &view {
|
||||
let chunk = &world.chunks[key];
|
||||
key.hash(&mut hash);
|
||||
for section in &chunk.sections {
|
||||
section.unpack(&mut ids);
|
||||
ids.hash(&mut hash);
|
||||
(0..4096)
|
||||
.map(|i| section.light.get(i))
|
||||
.for_each(|v| v.hash(&mut hash));
|
||||
}
|
||||
}
|
||||
let lit = world.chunks.values().filter(|c| c.lit).count();
|
||||
Ok(Run {
|
||||
wall,
|
||||
columns: world.chunks.len(),
|
||||
lit,
|
||||
cpu,
|
||||
coordinator: coordinator.as_secs_f64(),
|
||||
updates,
|
||||
jobs,
|
||||
checksum: hash.finish(),
|
||||
half: half.unwrap_or(wall),
|
||||
})
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: gen_light_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 12);
|
||||
let repeat: usize = flag(&args, "--repeat", 1);
|
||||
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
|
||||
let loading = args.iter().any(|a| a == "--loading");
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let simulate: Option<Vec<f64>> = args
|
||||
.iter()
|
||||
.position(|a| a == "--simulate")
|
||||
.map(|i| -> Result<Vec<f64>, Error> {
|
||||
let ms: Vec<f64> = args
|
||||
.get(i + 1)
|
||||
.ok_or("--simulate PROTO,DECORATE,FINISH")?
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?;
|
||||
(ms.len() == 3)
|
||||
.then_some(ms)
|
||||
.ok_or_else(|| "--simulate PROTO,DECORATE,FINISH".into())
|
||||
})
|
||||
.transpose()?;
|
||||
let threads: Vec<usize> = flag(&args, "--threads", String::from("1,2,4,8"))
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
// The pool is made once per process with its size: one process per
|
||||
// worker count and repeat, each given the same flags but one count.
|
||||
if threads.len() > 1 || repeat > 1 {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut rest = Vec::new();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
if args[i] == "--threads" || args[i] == "--repeat" {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
rest.push(args[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
for _ in 0..repeat.max(1) {
|
||||
for &n in &threads {
|
||||
let status = Command::new(&exe)
|
||||
.args(&rest)
|
||||
.args(["--threads", &n.to_string()])
|
||||
.env("HORIZON_JOB_THREADS", n.to_string())
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(format!("the run with {n} workers failed: {status}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let n = threads[0];
|
||||
horizon_threads::jobs::configure(n);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
|
||||
let go = |center, radius, n, loading| -> Result<Run, Error> {
|
||||
match &simulate {
|
||||
Some(ms) => {
|
||||
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
|
||||
let generator = Simulated {
|
||||
proto: ms(0),
|
||||
decorate: ms(1),
|
||||
finish: ms(2),
|
||||
};
|
||||
run(generator, &context, center, radius, n, period, loading)
|
||||
}
|
||||
None => run(terrain()?, &context, center, radius, n, period, loading),
|
||||
}
|
||||
};
|
||||
// Warm the shared generator data and the code away from the measured area.
|
||||
go((center.0 + 4000, center.1), 2, n, false)?;
|
||||
let pool = horizon_threads::jobs::pool().threads();
|
||||
if pool != n {
|
||||
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
|
||||
}
|
||||
let r = go(center, radius, n, loading)?;
|
||||
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
|
||||
println!(
|
||||
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
|
||||
{:.1} ms CPU/column, coordinator {:.2} s CPU in {} updates, workers busy {:.0}%, checksum {:016x}",
|
||||
r.wall,
|
||||
r.half,
|
||||
r.columns,
|
||||
r.lit,
|
||||
r.columns as f64 / r.wall,
|
||||
r.lit as f64 / r.wall,
|
||||
r.cpu * 1e3 / r.columns.max(1) as f64,
|
||||
r.coordinator,
|
||||
r.updates,
|
||||
busy * 100.0 / (r.wall * n as f64),
|
||||
r.checksum,
|
||||
);
|
||||
let line: Vec<String> = r
|
||||
.jobs
|
||||
.iter()
|
||||
.filter(|j| j.2 > 0)
|
||||
.map(|(kind, ns, count)| {
|
||||
format!(
|
||||
"{kind} {count} x {:.2} ms = {:.2} s",
|
||||
*ns as f64 / 1e6 / *count as f64,
|
||||
*ns as f64 / 1e9
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
println!(" {}", line.join(", "));
|
||||
if json {
|
||||
println!(
|
||||
"BENCH {}",
|
||||
serde_json::json!({
|
||||
"workers": n, "wall_s": r.wall, "half_s": r.half, "columns": r.columns,
|
||||
"lit": r.lit, "cpu_s": r.cpu, "coordinator_s": r.coordinator,
|
||||
"updates": r.updates, "checksum": format!("{:016x}", r.checksum),
|
||||
"jobs": r.jobs.iter().map(|(k, ns, c)| serde_json::json!({"kind": k, "s": *ns as f64 / 1e9, "count": c})).collect::<Vec<_>>(),
|
||||
})
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -536,6 +536,9 @@ pub fn main() -> Result<()> {
|
||||
let flying = op["value"].as_bool().unwrap();
|
||||
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
|
||||
world.player.flying = flying;
|
||||
if let Some(speed) = op.get("speed") {
|
||||
world.player.flying_speed = single(speed);
|
||||
}
|
||||
}
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
|
||||
@@ -129,6 +129,7 @@ fn scene(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: row["nether"].as_bool().unwrap_or(false),
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
|
||||
@@ -403,6 +403,18 @@ pub fn clip_block_stop(
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
let (location, block, normal) = clip_block_hit(world, facts, collision, from, movement)?;
|
||||
Some((back_off(location, movement), block, normal))
|
||||
}
|
||||
/// The clip of `clip_block_stop` before the back-off: the hit location (what a
|
||||
/// `BlockHitResult` carries), the block and the normal of the face.
|
||||
pub fn clip_block_hit(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
let to = Vec3::new(
|
||||
from.x + movement.x,
|
||||
@@ -426,11 +438,7 @@ pub fn clip_block_stop(
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some((
|
||||
back_off(hit.location, movement),
|
||||
hit.block,
|
||||
hit.direction.normal(),
|
||||
))
|
||||
Some((hit.location, hit.block, hit.direction.normal()))
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
|
||||
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
|
||||
|
||||
@@ -116,6 +116,39 @@ impl Control {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
|
||||
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
|
||||
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
|
||||
/// overlap on the GPU, so their times add up to more than the frame.
|
||||
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
|
||||
if report
|
||||
.hardware
|
||||
.gpu
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.backend == "metal")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
control.call("gpu-passes on")?;
|
||||
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
|
||||
control.call("gpu-passes off")?;
|
||||
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
|
||||
let pass: String = pass
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
if let Some(ms) = ms.as_f64() {
|
||||
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
thread::sleep(SETTLE);
|
||||
if !time_passes(control, name, &mut report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
@@ -499,6 +534,60 @@ mod tests {
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
/// The wait before a flight times the passes and turns the timing off again before the
|
||||
/// flight; the report gets one metric per pass. Not on Metal.
|
||||
#[test]
|
||||
fn the_wait_times_the_passes_but_not_the_flight() {
|
||||
let time_on = |backend: &str| {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = thread::spawn(move || {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = if request.line.starts_with("bench ") {
|
||||
serde_json::json!({
|
||||
"frames": 120,
|
||||
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "ok": true })
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
let hardware = horizon_bench::Hardware {
|
||||
gpu: Some(horizon_bench::Gpu {
|
||||
backend: backend.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
|
||||
let control = Control(tx);
|
||||
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
|
||||
drop(control);
|
||||
(timed, server.join().unwrap(), report.metrics)
|
||||
};
|
||||
|
||||
let (timed, lines, metrics) = time_on("vulkan");
|
||||
assert!(timed);
|
||||
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
|
||||
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
|
||||
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
|
||||
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
|
||||
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
|
||||
assert_eq!(metrics.len(), 3);
|
||||
|
||||
let (timed, lines, metrics) = time_on("metal");
|
||||
assert!(!timed);
|
||||
assert!(
|
||||
lines.is_empty(),
|
||||
"Metal's overlapping passes are not timed: {lines:?}"
|
||||
);
|
||||
assert!(metrics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -204,6 +204,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
|
||||
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
|
||||
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
@@ -214,6 +217,11 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
|
||||
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
@@ -444,11 +452,28 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
// Repro recordings
|
||||
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
|
||||
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
|
||||
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
|
||||
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
|
||||
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
|
||||
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
|
||||
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
|
||||
["horizon-ctl record-status"], &[], mcp: true, local: false),
|
||||
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
|
||||
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]", "replay a recording, best effort",
|
||||
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. Only input and movement commands replay (never world, network or account ones). --in-place feeds the input into the world already open (a server recording needs it); --keep-world leaves the copied world behind when it ends. --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
|
||||
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
|
||||
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("keep-world", Ty::Bool, "leave the copied world in saves/"), flag("timeout", Ty::Int, "give up after this many seconds (default 300)")], mcp: true, local: false),
|
||||
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
|
||||
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
|
||||
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
|
||||
@@ -314,13 +314,26 @@ fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
/// Paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return;
|
||||
};
|
||||
match words.first().map(String::as_str) {
|
||||
Some("screenshot") => {
|
||||
if let Some(path) = words.get_mut(1) {
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
// `repro-export [NAME] [PATH]`: the second positional is the zip.
|
||||
Some("repro-export") => {
|
||||
if let Some(path) = words.get_mut(2)
|
||||
&& (path.contains('/') || path.ends_with(".zip"))
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 661 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user