Merge main into #6761

The shared recipe-folder read gets its own span, ahead of sync.registries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 09:28:20 +01:00
commit b1feecff54
597 files changed
+45963 -3920

No files matched your search

+2 -1
View File
@@ -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"}
+4
View File
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
Generated
+11
View File
@@ -1399,6 +1399,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1461,6 +1462,7 @@ dependencies = [
"horizon-threads",
"horizon-world",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1542,6 +1544,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -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",
+2
View File
@@ -17,6 +17,8 @@ members = [
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+24
View File
@@ -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.
+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+92 -18
View File
@@ -239,8 +239,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +323,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +380,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +423,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+74 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
+106 -2
View File
@@ -3,6 +3,7 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -34,6 +35,15 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -267,13 +319,19 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| key.to_owned())
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -350,6 +408,52 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -51,6 +51,10 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
+6
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -132,6 +134,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
+2
View File
@@ -7,6 +7,8 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
+74 -6
View File
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
}
}
@@ -564,6 +597,41 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -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"
);
}
}
+24 -18
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,6 +158,28 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -172,23 +194,7 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
let mut sources: Vec<(String, Value)> = brewing_sources(root);
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
+223 -17
View File
@@ -285,20 +285,122 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -347,14 +449,15 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -366,13 +469,47 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -564,19 +701,61 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -674,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);
+193 -9
View File
@@ -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() {
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+33 -5
View File
@@ -31,6 +31,9 @@ struct Globals {
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -69,6 +72,8 @@ struct Out {
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -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
+39 -2
View File
@@ -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 => {}
+10
View File
@@ -947,6 +947,16 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
+329 -29
View File
@@ -110,6 +110,7 @@ mod arena;
pub mod compositor;
mod cull;
mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod hdr;
@@ -329,6 +330,7 @@ struct Globals {
/// HDR output's tier windows, low to very bright: where the lift starts, where it is
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
@@ -523,6 +525,9 @@ const COMPACT_BUDGET: u64 = 32 << 20;
/// A large translucent page starts to shrink (`Renderer::shrink_translucent`)
/// this long after terrain last arrived, and `SHRINK_RETRY` after a try found
/// no room.
/// `Renderer::remove_columns` filters each store once rather than looking up every
/// section key from this many keys, or from as many as there are resident sections.
const BULK_REMOVAL_KEYS: usize = 256;
const SHRINK_SETTLE: std::time::Duration = std::time::Duration::from_secs(1);
const SHRINK_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
/// Not before then may a large translucent page start to shrink.
@@ -626,6 +631,9 @@ pub struct Frame<'a> {
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
/// ordinary ones; only when `Renderer::skins_entities` said so.
pub skin: Option<&'a entity_skin::SkinFrame>,
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -772,6 +780,7 @@ pub struct Renderer {
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
flat: Option<flat::Flat>,
/// `flat` draws this frame's sections (off: the per-section draws).
@@ -855,6 +864,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
@@ -1016,6 +1027,8 @@ pub struct Renderer {
pack_atlas: Option<pack_atlas::PackAtlas>,
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -1522,37 +1535,64 @@ async fn open_device(
api: GraphicsApi,
layers: u32,
) -> Result<Opened> {
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
// still has OpenGL).
match open_device_with(window, offscreen, api, layers, false).await {
Ok(opened) => Ok(opened),
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: no GPU adapter could be used ({first}); \
rendering on the CPU, which is slow"
);
Ok(opened)
if let Some(first) = &first {
log::warn!(
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
if allow_cpu {
"rendering on the CPU, which is slow"
} else {
"using the GPU through another one"
}
);
}
return Ok(opened);
}
Err(_) => Err(first),
},
Err(e) => {
first.get_or_insert(e);
}
}
}
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
}
/// `open_device`, taking an adapter of the CPU type (software rendering) only
/// when `allow_cpu`.
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
fn device_search(
api: GraphicsApi,
os: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
.collect();
let mut tries = vec![(chosen.clone(), false)];
if !others.is_empty() {
tries.push((others, false));
}
tries.push((chosen, true));
tries
}
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
/// (software rendering) only when `allow_cpu`.
async fn open_device_with(
window: &Arc<Window>,
offscreen: bool,
api: GraphicsApi,
order: &[wgpu::Backends],
layers: u32,
allow_cpu: bool,
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
for (attempt, backends) in order.iter().copied().enumerate() {
let name = backends_name(backends);
@@ -1807,6 +1847,53 @@ impl Renderer {
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
@@ -2199,6 +2286,17 @@ impl Renderer {
true,
Some(alpha_blend),
);
let translucent_mark = pipeline(
"Terrain translucent HDR mark",
"vs_terrain",
"fs_translucent_mark",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
false,
true,
Some(flat::ALPHA_ONLY),
);
let sky = pipeline(
"Sky",
"vs_sky",
@@ -3442,10 +3540,14 @@ impl Renderer {
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
animate::SpriteAnimator::compute(&adapter, &device),
);
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
@@ -3609,6 +3711,7 @@ impl Renderer {
solid_opaque,
solid_cutout,
translucent,
translucent_mark,
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
@@ -3671,6 +3774,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -3758,6 +3862,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_terrain_flat: false,
separate_ao: false,
hud,
menu_blur: None,
post_effects: None,
@@ -3796,6 +3901,31 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
/// HDR lighting on translucent terrain, over the draws that just blended it.
fn mark_translucent_terrain(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: Option<&flat::Flat>,
solid_draws: u32,
translucent_draws: u32,
) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
match flat {
Some(flat) => {
pass.set_pipeline(&flat.translucent_mark);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
pass.set_bind_group(0, &self.group, &[]);
}
None => {
pass.set_pipeline(&self.translucent_mark);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(pass, &self.indirect, 0, solid_draws, translucent_draws);
}
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -4009,6 +4139,21 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether the running native pack gives any block a `[[lights]]` colour.
pub fn has_light_colors(&self) -> bool {
self.native
.as_ref()
.is_some_and(|native| !native.light_colors.is_empty())
}
/// The running native pack's `[[lights]]` colour of the block `name`.
pub fn light_color(&self, name: &str) -> Option<[f32; 3]> {
let native = self.native.as_ref()?;
horizon_shader_api::manifest::light_color(&native.light_colors, name)
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
@@ -4602,6 +4747,18 @@ impl Renderer {
}
}
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
let columns: Vec<(i32, i32)> = columns.into_iter().collect();
// Most of the view at once (a teleport at render distance 32 drops some 3700
// columns): a pass over each store instead of a lookup per section key, which
// took 10 ms of one frame. Far terrain fades the sections out instead.
let keys = columns.len() * self.layout.sections();
if self.lods.is_empty()
&& self.lod_cutout.is_empty()
&& keys >= self.sections.len().max(BULK_REMOVAL_KEYS)
{
self.remove_columns_at_once(&columns);
return;
}
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
@@ -4631,6 +4788,35 @@ impl Renderer {
if block_entities {
self.block_entity_revision = next_block_entity_revision();
}
self.release_meshes(released);
}
/// `remove_columns` for a large share of the resident sections: each store filtered
/// once by column.
fn remove_columns_at_once(&mut self, columns: &[(i32, i32)]) {
let gone: crate::fast_hash::FxHashSet<(i32, i32)> = columns.iter().copied().collect();
let complete = self.complete_column();
for &(x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
if self.block_entities.len() != before {
self.block_entity_revision = next_block_entity_revision();
}
let before = self.light_blocks.len();
self.light_blocks.retain(|key, _| kept(key));
self.light_revision += (before - self.light_blocks.len()) as u64;
self.section_uploaded.retain(|key, _| kept(key));
let released = self.sections.remove_columns(&gone);
self.release_meshes(released);
}
/// Gives the removed sections' quads back to their arenas; the sections themselves
/// (their sort centres) drop on a thread of their own when there are many.
fn release_meshes(&mut self, released: Vec<SectionGpu>) {
if let Some(flat) = &mut self.flat {
for id in released.iter().filter_map(|s| s.flat_id) {
flat.remove(id);
@@ -4647,6 +4833,9 @@ impl Renderer {
if let Some(arena) = &mut self.translucent_arena {
arena.release_all(released.iter().filter_map(|s| s.vertex_ranges(true).1));
}
if released.len() >= BULK_REMOVAL_KEYS {
arena::free_later(released);
}
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
@@ -4836,6 +5025,23 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
.as_ref()
.map(entity_skin::EntitySkin::stored)
}
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.native.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -4883,6 +5089,11 @@ impl Renderer {
let world = pack_post::World {
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
lightmap: self.lightmap_view.clone(),
gui_size: self
.hud
.atlas_texture
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
@@ -4925,6 +5136,16 @@ impl Renderer {
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
self.separate_ao = false;
}
/// Sets whether shader pack terrain inputs pass vertex alpha to `hz_Color.a` (`separateAo=true`).
pub fn set_separate_ao(&mut self, separate_ao: bool) {
if self.separate_ao != separate_ao {
self.separate_ao = separate_ao;
if let Some(atlas) = &mut self.pack_atlas {
atlas.separate_ao = separate_ao;
}
}
}
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
@@ -5007,7 +5228,7 @@ impl Renderer {
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
self.pack_atlas = pack_atlas::PackAtlas::new(
let mut atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
@@ -5015,6 +5236,10 @@ impl Renderer {
layers,
self.sprite_textures[0].width(),
);
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_atlas = atlas;
self.queue.submit([encoder.finish()]);
}
// Flat draws whenever the device has them (`render` keeps them on with a pack).
@@ -5679,6 +5904,17 @@ impl Renderer {
0.0,
],
hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
hdr_light: [
frame.sky.sky_light_factor,
// The underwater overlay: the sky and fog are the water's own colour there.
if frame.screen.iter().any(|q| q.world_tint) {
0.0
} else {
frame.sky.sky_light_factor
},
0.0,
0.0,
],
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -6354,12 +6590,19 @@ impl Renderer {
&self.queue,
frame.entities,
frame.entity_spans,
frame.crumbling_sprite,
&mut self.pack_entity_rest,
);
entities = &self.pack_entity_rest;
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting;
}
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -6384,6 +6627,13 @@ impl Renderer {
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -6439,7 +6689,7 @@ impl Renderer {
camera,
);
// Frame generation's made frame draws its own hand, halfway, after the others.
let made_hand_vertex = self.entity_batch.combined.len() as i32;
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
@@ -6456,24 +6706,34 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
mapped_at_creation: false,
});
}
if entity_vertices > 0 {
if !all_entities.is_empty() {
self.uploads.buffer(
&mut encoder,
&self.entity_buffer,
0,
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
bytemuck::cast_slice(all_entities),
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -7737,6 +7997,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
@@ -7745,6 +8006,12 @@ impl Renderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
self.mark_translucent_terrain(
&mut pass,
Some(flat),
solid_draws,
translucent_draws,
);
}
None => {
pass.set_pipeline(translucent_pipeline);
@@ -7757,6 +8024,7 @@ impl Renderer {
solid_draws,
translucent_draws,
);
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -8494,8 +8762,9 @@ impl Renderer {
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
&self.queue,
@@ -9284,6 +9553,37 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
#[test]
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
[(vec![all], false), (vec![all], true)]
);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
@@ -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,
@@ -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);
}
}
+151 -32
View File
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
enum Source {
Color(usize),
Depth,
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
Depth1,
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
Depth2,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
Normals,
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
Specular,
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
/// `.mcmeta` says.
Custom {
@@ -81,10 +87,18 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
@@ -97,9 +111,12 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
}
}
@@ -387,6 +404,8 @@ struct Targets {
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -408,6 +427,7 @@ pub(super) struct World<'a> {
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
pub gui_size: [u32; 2],
/// Set 1 of the clouds' draw: their offset and palette.
pub clouds: Option<&'a wgpu::BindGroupLayout>,
/// The game can hand its rain and snow to the pack.
@@ -492,6 +512,8 @@ pub struct PackPost {
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -510,6 +532,7 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
@@ -533,6 +556,8 @@ pub struct PackPost {
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
dummy_black: wgpu::TextureView,
dummy_normal: wgpu::TextureView,
dummy_3d: wgpu::TextureView,
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
@@ -647,11 +672,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
match name {
"depthtex0" | "gdepthtex" => Source::Depth,
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
"depthtex1" | "depthtex2" => Source::Depth1,
"depthtex1" => Source::Depth1,
"depthtex2" => Source::Depth2,
"noisetex" => Source::Noise,
"texture" | "gtexture" | "tex" => Source::Atlas,
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1073,7 +1100,7 @@ fn bindings(
Source::Atlas => geometry.is_none(),
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 => group == Group::Gbuffers,
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
_ => false,
};
@@ -1084,7 +1111,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
@@ -1341,11 +1368,16 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if let (Some(t), Some(b)) = (&mut state, p.blend)
&& format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
{
t.blend = Some(blend_state(b));
}
@@ -1433,7 +1465,15 @@ fn build_pass(
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
@@ -1893,8 +1933,15 @@ impl PackPost {
post.block,
post.entities_translucent,
post.particles,
post.damaged_block,
];
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
];
let late = [false, false, post.translucent_late, post.particles_late];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
let entity_vertices = (programs.iter().chain(&shadow_programs))
@@ -1910,8 +1957,21 @@ impl PackPost {
cull: false,
});
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
buffers: g.buffers,
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
} else {
build(p, Some(g))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
@@ -2045,13 +2105,20 @@ impl PackPost {
}
}
}
let depth1 = water.is_some()
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
let every_pass = || {
(passes
.iter()
@@ -2126,7 +2193,7 @@ impl PackPost {
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
@@ -2215,6 +2282,8 @@ impl PackPost {
});
}
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
passes,
@@ -2233,6 +2302,7 @@ impl PackPost {
weather_generation: 0,
hand,
depth1,
depth2,
shadow_targets,
shadow_terrain,
shadow_water,
@@ -2243,6 +2313,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
@@ -2266,6 +2337,14 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
device,
queue,
"Shader pack normal stand-in",
1,
&flat_normal,
),
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
@@ -2471,6 +2550,27 @@ impl PackPost {
}
_ => &targets.depth,
},
Source::Depth2 => match &targets.depth2 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => match &targets.depth1 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => &targets.depth,
},
},
Source::Noise => &self.noise,
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
&self.dummy_3d
@@ -2478,6 +2578,8 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -2649,16 +2751,6 @@ impl PackPost {
return;
};
let translucent = list.translucent();
if list == TerrainList::Translucent
&& let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
@@ -2868,6 +2960,7 @@ impl PackPost {
queue: &wgpu::Queue,
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
) {
let shadow = self.shadow_casters();
@@ -2884,6 +2977,7 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -2934,6 +3028,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);
}
@@ -3324,6 +3434,11 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let depth2 = self.depth2.then(|| {
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
let view = t.create_view(&Default::default());
(t, view)
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -3357,6 +3472,7 @@ impl PackPost {
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
depth2,
mips,
});
self.forget_groups();
@@ -4040,7 +4156,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);
@@ -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.
@@ -2226,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 -3
View File
@@ -20,6 +20,8 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
&& (hand || (!covered && shows_world(c, w.rgb))) {
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
+111 -1
View File
@@ -7,6 +7,7 @@ use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use wgpu::util::DeviceExt;
@@ -58,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>,
@@ -483,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> {
@@ -630,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,
@@ -1063,7 +1143,9 @@ impl HudAtlas {
white,
);
Ok(Self {
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1956,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())),
@@ -2006,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 {
@@ -2032,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 {
@@ -2134,7 +2232,7 @@ impl HudRenderer {
operation: wgpu::BlendOperation::Add,
},
},
"fs_main",
"fs_vignette",
false,
);
// RenderPipelines.END_PORTAL: opaque.
@@ -2781,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");
@@ -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>,
@@ -75,6 +76,12 @@ struct CitSlots {
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),
+4
View File
@@ -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;
@@ -48,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;
@@ -89,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;
+11 -17
View File
@@ -562,11 +562,7 @@ fn pack_lighting(
}
}
}
if data.iter().all(|&v| v == data[0]) {
Light::Uniform(data[0])
} else {
Light::Full(data)
}
Light::packed(data)
})
.collect()
}
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
return;
};
let slot = &mut self.slots[slot];
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
Light::Uniform(v) => Box::new([*v; 4096]),
Light::Full(a) => a.clone(),
});
let light = slot
.light
.get_or_insert_with(|| slot.section.light.to_array());
let i = index(x, y, z);
light[i] = f(light[i]);
}
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
drop(slot.section);
let (cx, s, cz) = slot.key;
self.world
.set_section_light(cx, s as usize, cz, Light::Full(light));
.set_section_light(cx, s as usize, cz, Light::packed(light));
}
}
}
@@ -1624,21 +1619,20 @@ mod tests {
current.set(8, MIN_Y, 8, TORCH);
let region = [[None; 3], [None, Some(&current), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
let Light::Full(data) = &lights[0] else {
panic!("torch light varies");
let storage = |light: &Light| match light {
Light::Full(data) => data.as_ptr(),
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
Light::Uniform(_) => panic!("torch light varies"),
};
let result_storage = data.as_ptr();
let result_storage = storage(&lights[0]);
let snapshot = current.clone();
current.set(8, MIN_Y, 8, STONE);
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
let edited_snapshot = current.clone();
apply_lighting(&mut current, lights);
let Light::Full(data) = &current.sections[0].light else {
panic!("torch light varies");
};
assert_eq!(
result_storage,
data.as_ptr(),
storage(&current.sections[0].light),
"returned light storage moves without cloning"
);
current.lit = true;
+6 -1
View File
@@ -214,7 +214,12 @@ impl Mesher {
Paint {
layer: face.layer,
light: span.light,
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
// The HDR tier, as the section mesher writes it, so a light source
// keeps shining across the LOD boundary.
color: crate::mesher::emitting(
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
context.tables.hdr_tier(span.state),
),
translucent: face.translucent,
}
})
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+388 -130
View File
@@ -10,7 +10,7 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -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(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -72,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -98,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
// Place stone with neighbouring blocks to create AO variations.
chunk.set(8, MIN_Y + 8, 8, stone);
chunk.set(9, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: true,
separate_ao: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
assert!(!mesh.solid.is_empty());
for v in &mesh.solid {
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
// Alpha holds the AO factor, which must be non-zero.
assert!(v.color >> 24 > 0);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -149,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -181,7 +291,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
+124 -32
View File
@@ -203,13 +203,37 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
@@ -218,15 +242,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let slots = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(&slots) {
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
@@ -246,33 +269,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
@@ -286,6 +310,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
@@ -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();
}
+254 -25
View File
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1308,6 +1308,10 @@ enum Act {
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
CopyAgentDetails,
/// AI Agent: start or stop the repro recording.
ToggleRecording,
/// AI Agent: write the repro bundle now.
ExportRepro,
}
enum WKind {
Button {
@@ -1435,6 +1439,10 @@ pub enum Event {
Credits,
/// Copy how an agent connects (the AI Agent page).
CopyAgentDetails,
/// The AI Agent page's recording button: start or stop.
ToggleRecording,
/// The AI Agent page's export button.
ExportRepro,
/// The narrator option changed (after its `Changed`): the option's
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
Narrator,
@@ -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()
+487 -25
View File
@@ -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
+182
View File
@@ -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");
+421 -229
View File
@@ -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, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*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(&region), 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(&region), 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, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*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(&region), &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(&region),
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
@@ -1489,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.
@@ -1538,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.
@@ -1600,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)> =
@@ -1627,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)| {
@@ -1680,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 {
@@ -1766,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 {
@@ -1789,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,
@@ -2438,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
@@ -2554,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(
@@ -3143,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
+51 -4
View File
@@ -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.
+221 -17
View File
@@ -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)
);
}
}
+3
View File
@@ -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,6 +143,7 @@
"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",
@@ -164,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.",
@@ -198,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 …",
@@ -286,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...",
@@ -170,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.",
@@ -306,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"
}
@@ -158,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,6 +143,7 @@
"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",
@@ -164,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.",
@@ -198,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...",
@@ -286,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,6 +143,7 @@
"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",
@@ -164,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.",
@@ -198,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…",
@@ -286,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."
}
@@ -157,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.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
@@ -158,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,6 +143,7 @@
"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/кадр",
@@ -164,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.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
@@ -198,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": "Шукаємо світ друга…",
@@ -286,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,6 +137,7 @@
"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",
@@ -158,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.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
@@ -192,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": "正在查找好友的世界…",
@@ -303,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": "仅限本机:此电脑上的代理可用,网络不可用。"
}
+9 -1
View File
@@ -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,
+90 -1
View File
@@ -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),
@@ -217,6 +220,8 @@ pub const COMMANDS: &[Cmd] = &[
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)",
@@ -452,6 +457,23 @@ pub const COMMANDS: &[Cmd] = &[
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();
}
}
_ => {}
}
}
@@ -4,20 +4,14 @@
//! shared table, run through the same `Session` as the CLI.
use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
}
fn tools() -> Vec<Value> {
@@ -143,15 +137,10 @@ impl Session {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -171,52 +160,13 @@ impl Session {
}
pub fn serve(session: &mut Session) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| session.handle(method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -255,28 +205,66 @@ mod tests {
#[test]
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
"tools/call",
&json!({ "name": "goto", "arguments": { "x": 1 } }),
let mut app = |method: &str, params: &Value| s.handle(method, params);
let server = server();
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
// A client that opens with the handshake revision.
let init = server
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
// A modern client: stateless, and the same tools.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let params = json!({ "_meta": Value::Object(meta) });
let list = server
.handle_line(&line("tools/list", params), &mut app)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
let r = server
.handle_line(
&line(
"tools/call",
json!({ "name": "goto", "arguments": { "x": 1 } }),
),
&mut app,
)
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(
r["content"][0]["text"]
r["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("y is required")
);
// No game: a connection error, reported as a tool error.
let r = s
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
let r = server
.handle_line(
&line("tools/call", json!({ "name": "state", "arguments": {} })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(r["result"]["isError"], true);
// Methods this server does not have are the protocol's to refuse.
assert_eq!(
server
.handle_line(&line("nope/nope", json!({})), &mut app)
.unwrap()["error"]["code"],
json!(-32601)
);
}
}
@@ -29,6 +29,17 @@ pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
Some((block_entity_nbt::id(data)?, data))
}
/// `JukeboxBlock.setPlacedBy`: the item's `minecraft:block_entity_data`
/// carries a `RecordItem`, so the placed jukebox holds a record and its
/// `has_record` state has to say so. Vanilla reads the item's data, not the
/// block entity the placement just filled.
pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
@@ -602,6 +613,40 @@ mod tests {
assert!(!op_warning(&chest));
}
/// `JukeboxBlock.setPlacedBy` looks for a `RecordItem` in the placed
/// item's block entity data, whatever the type it names: only that key
/// turns the box's `has_record` on.
#[test]
fn a_jukebox_item_with_a_record_in_its_data_says_so() {
let jukebox = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:jukebox", 1)
}
};
// A plain jukebox, and one whose data is only the type id: no record.
assert!(!carries_record(&crate::items::ItemStack::new(
"minecraft:jukebox",
1
)));
assert!(!carries_record(&jukebox("{id:\"minecraft:jukebox\"}")));
assert!(!carries_record(&jukebox(
"{id:\"minecraft:jukebox\",Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}"
)));
// Creative's Ctrl + Pick Block on a jukebox holding a disc.
assert!(carries_record(&jukebox(
"{id:\"minecraft:jukebox\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
// The key alone counts: `TypedEntityData.contains` never checks the
// type, and the state it sets is the one the block is placed as.
assert!(carries_record(&jukebox(
"{id:\"minecraft:chest\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
)));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+9 -8
View File
@@ -13,14 +13,14 @@ pub use horizon_client_render::{
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects,
sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
trims, ui, vault, weather, widgets, world, worldborder, zip,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+55 -48
View File
@@ -14,11 +14,34 @@ use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
/// A recipe ingredient of a mod's own kind: the entry named a type in a
/// namespace outside `minecraft:`, and the loader kept it for this call.
/// The kind and the JSON the file wrote are here; fill `items` with the
/// item ids the ingredient matches (item ids only: components are not
/// checked here; a match-time hook follows separately). Duplicates are
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
fn dedup_keep_order(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
items.retain(|i| seen.insert(i.clone()));
}
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
@@ -107,6 +130,14 @@ impl Ingredient {
.cloned()
.collect()
}
// A mod's own kind: the hook answers with the item list it
// matches; a bare type name is minecraft's own, not a mod's.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut list = Vec::new();
native_ingredient(k, o, &mut list);
dedup_keep_order(&mut list);
return (!list.is_empty()).then(|| Self::of(list));
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
@@ -1176,48 +1207,6 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
let template = Template::parse(v)?;
template.item.is_some().then(|| template.create())
}
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
/// order: each one's path inside the folder without the extension (`a/b`, as a
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
/// subfolder or an entry that can't be listed is warned and skipped, as
/// vanilla's `PathPackResources` keeps what it found.
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
@@ -1340,14 +1329,14 @@ impl Recipes {
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
for file in &crate::json_folder::read(&root.join("data/minecraft/recipe"))?.files {
sources.push((file.name.clone(), file.bytes()?.to_vec()));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
for (name, p) in recipe_files(&dir)? {
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
for file in &crate::json_folder::read(&dir)?.files {
sources.push((format!("{namespace}:{}", file.name), file.bytes()?.to_vec()));
}
}
}
@@ -1855,6 +1844,7 @@ impl Recipes {
mod tests {
use super::*;
use crate::banner::BannerLayer;
use std::fs;
#[test]
fn result_count_is_bounded_like_vanilla() {
@@ -1913,6 +1903,23 @@ mod tests {
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() {
let items = Items::for_tests(HashMap::new());
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
assert!(
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,)
.is_none()
);
assert!(
Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none()
);
assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none());
let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()];
dedup_keep_order(&mut list);
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
+17
View File
@@ -28,6 +28,10 @@ pub enum DebugAction {
NetworkCharts,
LightmapTexture,
ImprovedTransparency,
/// F3+R: start a repro recording, or stop it and write its bundle.
RecordSession,
/// F3+E: write the bundle now.
ExportRepro,
}
/// The mappings `handleDebugKeys` tests, in its order.
@@ -69,6 +73,14 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
),
];
/// Horizon's own debug keys: fixed, so they never enter the options table that
/// vanilla parity compares (`MAPPINGS`). Off until the AI Agent page enables
/// them; `Game` decides that in `debug_action`.
const HORIZON_ACTIONS: [(&str, DebugAction); 2] = [
("key.keyboard.r", DebugAction::RecordSession),
("key.keyboard.e", DebugAction::ExportRepro),
];
/// GLFW's key actions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyAction {
@@ -215,6 +227,11 @@ impl DebugKeys {
did |= host.debug_action(action, shift);
}
}
for (name, action) in HORIZON_ACTIONS {
if key == name {
did |= host.debug_action(action, shift);
}
}
did
}
/// `KeyboardHandler.tick`.
+21
View File
@@ -91,6 +91,12 @@ pub struct ItemEntity {
pub stuck: Vec3,
/// `Entity.noGravity` (`NoGravity` in its save): `applyGravity` does nothing.
pub no_gravity: bool,
/// `Entity.verticalCollision`: the last move was stopped vertically.
/// `ServerEntity.isFullPrecisionEncodingRequired` reads it for items, whose
/// resting position is exact while a 1/4096 delta is not.
pub vertical_collision: bool,
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
pub horizontal_collision: bool,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -280,6 +286,13 @@ impl ItemEntity {
if !no_physics {
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
}
// Entity.move: what the move was stopped on. They outlive the
// tick, as vanilla's fields do, for the tracker that sends the
// position at the type's update interval.
self.vertical_collision = moved.y != wanted.y;
// `Mth.equal` on x and z (1e-5 as a float), exact on y.
let differs = |a: f64, b: f64| (b - a).abs() >= 9.999_999_747_378_752e-6;
self.horizontal_collision = differs(moved.x, wanted.x) || differs(moved.z, wanted.z);
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
@@ -1504,6 +1517,8 @@ mod fire_environment_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -1651,6 +1666,8 @@ mod merge_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age,
pickup_delay: 0,
bob_offset: 0.0,
@@ -1990,6 +2007,8 @@ mod honey_slide_tests {
previous: at,
velocity: Vec3::new(vx, vy, vz),
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -2120,6 +2139,8 @@ mod slime_bounce_tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -245,6 +245,8 @@ mod tests {
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
+220 -21
View File
@@ -2094,6 +2094,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
previous: position,
velocity: motion(tag),
on_ground: flag(tag, "OnGround"),
vertical_collision: false,
horizontal_collision: false,
// Age -32768 marks items that never despawn; Horizon counts upwards.
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
@@ -2343,6 +2345,20 @@ pub fn fireball_to_nbt(
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
// Fireball.addAdditionalSaveData: the item it is drawn as, one of the loaded
// item or else a fire charge (`setItem` refuses an empty stack and keeps one).
if matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) {
let mut item = tag
.get("Item")
.and_then(item_from_nbt)
.filter(|i| i.count > 0 && &*i.item != "minecraft:air")
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:fire_charge", 1));
item.count = 1;
put(&mut tag, "Item", item_to_nbt(&item));
}
Some(tag)
}
@@ -2370,26 +2386,37 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
const THROWABLE_ITEMS: [&str; 5] = [
"minecraft:snowball",
"minecraft:egg",
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:lingering_potion",
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
if let ProjectileKind::Thrown(t) = kind {
return Some(t.item);
/// The item a throwable flies as: its stack (a potion's), the mod type's
/// item, or the item its entity type is named after.
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
match kind {
ProjectileKind::Thrown(t) => Some(t.item),
ProjectileKind::Potion(stack) => Some(&stack.item),
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
}
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
/// A potion flies as the item its entity type names, which is the item a
/// summoned one has when it names none (`getDefaultItem`).
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
match THROWABLES.iter().find(|(_, i)| *i == id) {
Some((kind, _)) => Some(kind.clone()),
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
if !THROWABLE_ITEMS.contains(&id) {
return crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown);
}
match id {
"minecraft:snowball" => Some(ProjectileKind::Snowball),
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
_ => Some(ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new(id, 1),
))),
}
}
@@ -2427,13 +2454,14 @@ pub fn throwable_to_nbt(
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
// the loaded item stays (a blue egg's, a renamed one's, a potion's
// effects); when there is none, `getItem`'s default is written.
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
let stack = match &p.kind {
ProjectileKind::Potion(stack) => (**stack).clone(),
_ => crate::items::ItemStack::new(item, 1),
};
put(&mut tag, "Item", item_to_nbt(&stack));
}
Some(tag)
}
@@ -2441,7 +2469,14 @@ pub fn throwable_to_nbt(
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = throwable_kind(&kind(tag)?)?;
let mut kind = throwable_kind(&kind(tag)?)?;
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
// and all; a tag without one keeps `getItem`'s default.
if let ProjectileKind::Potion(stack) = &mut kind
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
{
**stack = item;
}
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
@@ -3358,6 +3393,8 @@ mod tests {
previous: Vec3::ZERO,
velocity: Vec3::new(0.0, -0.04, 0.0),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 120,
pickup_delay: 10,
ticks: 0,
@@ -3711,6 +3748,10 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
),
// ZombieNautilus.applyImplicitComponents: the variant only.
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
// Fox and Frog.applyImplicitComponent: `setVariant` from the component
// alone, so a spawn egg carrying one hatches that variant.
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -4567,6 +4608,49 @@ mod item_component_tests {
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `Fox.applyImplicitComponent` and `Frog.applyImplicitComponent`: the
/// variant component alone decides the mob, `setVariant` from the egg.
#[test]
fn a_fox_or_frog_spawn_egg_picks_its_variant_component() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let spawn = |kind: &str, id: u32| {
Mob::new(
id,
Arc::new(data.types[kind].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
)
};
// A red fox, as a spawn in a forest leaves it.
let mut fox = spawn("minecraft:fox", 1);
assert_eq!(fox.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:snow")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 1, "Fox.Variant.SNOW, not red");
// A temperate frog, as a swamp spawn leaves it.
let mut frog = spawn("minecraft:frog", 2);
assert_eq!(frog.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:frog_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:frog/variant", Some(string("minecraft:warm")));
apply_item_components(&mut frog, &egg);
assert_eq!(frog.extra.variant, 1, "minecraft:warm, not temperate");
// An unknown name falls back to the codec's default, red.
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:temperate")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
@@ -4673,6 +4757,121 @@ mod fireball_nbt_tests {
}
}
/// `Fireball.addAdditionalSaveData` / `readAdditionalSaveData`: both fireballs save
/// `Item`, a fire charge by default, and a loaded one stays, as one item.
#[test]
fn a_fireball_saves_its_item() {
let item_of = |tag: &Tag| {
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
(item.item.to_string(), item.count)
};
for id in ["minecraft:fireball", "minecraft:small_fireball"] {
let plain = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&plain)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
// A loaded item stays, as a single one.
let mut loaded = plain.clone();
put(
&mut loaded,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 5)),
);
let p = fireball_from_nbt(&loaded, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&loaded)).unwrap();
assert_eq!(item_of(&back), ("minecraft:diamond".to_owned(), 1), "{id}");
// An empty one falls back to the fire charge.
let mut empty = plain.clone();
put(
&mut empty,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:air", 1)),
);
let p = fireball_from_nbt(&empty, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&empty)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
}
// The wind charges are not `Fireball`s: they save no item.
let wind = Tag::compound([
("id", Tag::String("minecraft:wind_charge".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&wind, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&wind)).unwrap();
assert!(back.get("Item").is_none());
}
#[test]
fn a_summoned_potion_flies_as_the_item_its_compound_names() {
// `ThrowableItemProjectile.getDefaultItem`: the item its entity type is
// named after, so a bare summon is a splash potion with no effects and
// the chunk saves it (ThrowableProjectile is not transient).
for id in ["minecraft:splash_potion", "minecraft:lingering_potion"] {
let bare = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
]);
let p = throwable_from_nbt(&bare, None).unwrap();
assert_eq!(p.kind.id(), id);
assert_eq!(
p.kind,
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(item.potion(), None);
}
// `readAdditionalSaveData`: the `Item` it flies as, `potion_contents`
// and all, so what it splashes is the potion's.
let harming = Tag::compound([
("id", Tag::String("minecraft:splash_potion".into())),
("Pos", doubles(&[24.5, 216.0, 24.5])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:splash_potion", 1)
.with_potion("minecraft:harming"),
),
),
]);
let p = throwable_from_nbt(&harming, None).unwrap();
let ProjectileKind::Potion(stack) = &p.kind else {
panic!("a potion");
};
assert_eq!(stack.potion(), Some("minecraft:harming"));
// The stack stays in the tag it was loaded from.
let tag = throwable_to_nbt(&p, [1; 4], None, Some(&harming)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(item.potion(), Some("minecraft:harming"));
// A lingering potion is its own entity type.
let lingering = Tag::compound([
("id", Tag::String("minecraft:lingering_potion".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(
&crate::items::ItemStack::new("minecraft:lingering_potion", 1)
.with_potion("minecraft:poison"),
),
),
]);
let p = throwable_from_nbt(&lingering, None).unwrap();
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
+13 -6
View File
@@ -541,6 +541,8 @@ pub struct HandSway {
pub y_bob: f32,
pub x_bob_o: f32,
pub y_bob_o: f32,
/// The yaw `tick_wrapped` has followed so far, unwrapped like vanilla's.
y_rot: f32,
}
impl HandSway {
/// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`.
@@ -548,9 +550,14 @@ impl HandSway {
self.y_bob_o = self.y_bob;
self.x_bob_o = self.x_bob;
self.x_bob += (x_rot - self.x_bob) * 0.5;
// Vanilla's yaw runs on without wrapping, so `yRot - yBob` is the short way round. Ours
// is kept in -180..180 (a boat turns its rider through it), so take the short way here.
self.y_bob += wrap_degrees(y_rot - self.y_bob) * 0.5;
self.y_bob += (y_rot - self.y_bob) * 0.5;
}
/// `tick` for a yaw kept in -180..180 (a boat turns its rider through the wrap). Vanilla's
/// yaw runs on without wrapping, so `yRot - yBob` is always the short way round; this
/// follows the wrapped yaw by its short steps to give `tick` the same unwrapped yaw.
pub fn tick_wrapped(&mut self, x_rot: f32, y_rot: f32) {
self.y_rot += wrap_degrees(y_rot - self.y_rot);
self.tick(x_rot, self.y_rot);
}
/// The degrees the hands turn about X, then Y, before either is drawn:
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
@@ -1406,12 +1413,12 @@ mod action_result_tests {
#[test]
fn hand_sway_takes_the_short_way_over_the_yaw_wrap() {
let mut sway = HandSway::default();
sway.tick(0.0, 179.0);
sway.tick_wrapped(0.0, 179.0);
for _ in 0..20 {
sway.tick(0.0, 179.0);
sway.tick_wrapped(0.0, 179.0);
}
// The view is still at 179; one tick later it reads -179 (2 degrees on).
sway.tick(0.0, -179.0);
sway.tick_wrapped(0.0, -179.0);
let [_, y] = sway.rotation(1.0, 0.0, -179.0);
assert!(
y.abs() < 0.5,
File diff suppressed because it is too large. Load diff
+34 -4
View File
@@ -10,6 +10,7 @@ use horizon_client::{
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound as C;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -626,18 +627,44 @@ impl Game<'_> {
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
let mut allowed = Vec::with_capacity(unlocked.len());
let mut refused = Vec::new();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
for recipe in unlocked {
let mut cancel = false;
self.native_player_recipe_discovered(player, &name, recipe, &mut cancel);
if cancel {
refused.push(recipe.clone());
} else {
allowed.push(recipe.clone());
}
}
if !refused.is_empty() {
let removed = self
.level
.recipe_book
.remove_recipes(&world_stations!(self).recipe_index, &refused);
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
}
} else {
allowed.extend_from_slice(unlocked);
}
if self.lan_acting.is_some() {
// A network player's client gets them in ClientboundRecipeBookAddPacket.
self.adv.unlocked.extend_from_slice(unlocked);
self.adv.unlocked.extend_from_slice(&allowed);
} else {
self.recipe_toast(unlocked);
self.recipe_toast(&allowed);
}
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
if allowed.is_empty() || self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
let tree = self.adv.tree.clone();
for r in unlocked {
for r in &allowed {
let list = tree.recipe_criteria(r);
self.trigger_criteria(&tree, &list, |c, _| {
c["recipes"].as_str() == Some(r.as_str())
@@ -681,6 +708,9 @@ impl Game<'_> {
fn award(&mut self, index: usize) {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
self.experience.give(a.rewards.experience);
+31 -8
View File
@@ -466,7 +466,7 @@ impl Game<'_> {
json!({ "kind": kind, "block": block, "entity": entity })
}
fn state_value(&self) -> Value {
pub(super) fn state_value(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
"position": [p.position.x, p.position.y, p.position.z],
@@ -497,10 +497,10 @@ impl Game<'_> {
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
});
json!({
let mut state = json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -527,7 +527,13 @@ impl Game<'_> {
"looking_at": self.looking_at(),
"busy": self.agent.task.as_ref().map(|t| t.name),
"ticks": self.ticks,
})
});
// Who caused the input, and whether a repro recording runs.
let mut input = self.input_trace.summary();
self.repro
.fill_summary(&mut input, self.input_trace.events());
state["input"] = input;
state
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
@@ -535,11 +541,18 @@ impl Game<'_> {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn split_setup_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
if self.split_setup_open() {
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -657,7 +670,7 @@ impl Game<'_> {
}
}
fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
pub(super) fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
let Some(p) = &self.player else {
return json!({ "entities": [] });
};
@@ -711,7 +724,7 @@ impl Game<'_> {
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
}
fn inventory_value(&self) -> Value {
pub(super) fn inventory_value(&self) -> Value {
let armor = ["head", "chest", "legs", "feet"];
json!({
"selected": self.selected + 1,
@@ -742,7 +755,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() || self.split_profiles_open() {
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1002,6 +1015,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
if i == 0 {
self.handle_window_event(
@@ -1012,6 +1026,7 @@ impl Game<'_> {
state: ElementState::Pressed,
button,
},
super::input_trace::InputOrigin::Agent,
);
}
}
@@ -1023,6 +1038,7 @@ impl Game<'_> {
state: ElementState::Released,
button,
},
super::input_trace::InputOrigin::Agent,
);
Ok(())
}
@@ -1043,6 +1059,11 @@ impl Game<'_> {
Ok(Handled::Deferred)
}
/// A deferred agent action (goto, break, wait...) is still running.
pub(super) fn agent_busy(&self) -> bool {
self.agent.task.is_some()
}
/// Agent commands; `Handled::No` when `words[0]` is not one of them.
pub(super) fn agent_command(
&mut self,
@@ -1582,6 +1603,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
for _ in 0..steps.abs() as u32 {
self.handle_window_event(
@@ -1592,6 +1614,7 @@ impl Game<'_> {
delta: MouseScrollDelta::LineDelta(0.0, steps.signum()),
phase: TouchPhase::Moved,
},
super::input_trace::InputOrigin::Agent,
);
}
ok(json!({ "ok": true, "screen": self.screen_label() }))
@@ -1705,7 +1728,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
+141 -26
View File
@@ -86,33 +86,24 @@ impl Game<'_> {
let server = self
.control
.as_ref()
.filter(|c| !c.path().as_os_str().is_empty());
let mut lines = vec![match server {
Some(s) if s.clients() > 0 => {
let ago = self
.agent_last
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
.unwrap_or_default();
format!(
"Connected: {} agent connection{}{ago}",
s.clients(),
if s.clients() == 1 { "" } else { "s" }
)
}
Some(_) => "Waiting for an agent.".into(),
None => "Off: agents can't reach this game.".into(),
}];
if let Some(s) = server {
if self.options.control.is_some() && !self.agent_control_on {
lines.push("Opened for this session by the program that started the game.".into());
}
// The full path is too wide for the page: Copy Connection Details has it.
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
lines.push(format!(
"Socket: {name} (copy the details for its full path)"
));
.filter(|c| !c.path().as_os_str().is_empty())
.map(|s| Socket {
clients: s.clients(),
name: s
.path()
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
});
let mut lines = status_lines(
server,
self.agent_last.map(|t| t.elapsed().as_secs()),
self.options.control.is_some() && !self.agent_control_on,
);
if let Some(line) = self.repro.agent_line(self.input_trace.events()) {
lines.push(line);
}
lines.push("Local only: agents on this computer, never the network.".into());
lines
}
@@ -138,3 +129,127 @@ impl Game<'_> {
)
}
}
/// The control socket as the AI Agent page tells of it.
struct Socket {
/// Agents connected now.
clients: usize,
/// The socket's file name; the full path is too wide for the page (Copy Connection Details
/// has it).
name: String,
}
/// The AI Agent page's lines in the selected language: the state, then where agents connect.
/// `last_command` is the seconds since an agent's last command, `session_only` whether the
/// program that started the game opened the socket and the player's switch did not.
fn status_lines(
server: Option<Socket>,
last_command: Option<u64>,
session_only: bool,
) -> Vec<String> {
use horizon_client::lang::{get, tr};
let mut lines = vec![match &server {
Some(s) if s.clients > 0 => {
let (one, many) = match last_command {
Some(_) => ("one_ago", "many_ago"),
None => ("one", "many"),
};
let key = format!(
"horizon.agent.connected.{}",
if s.clients == 1 { one } else { many }
);
let mut args: Vec<&dyn std::fmt::Display> = Vec::new();
if s.clients != 1 {
args.push(&s.clients);
}
if let Some(ago) = &last_command {
args.push(ago);
}
tr(&key, &args)
}
Some(_) => get("horizon.agent.waiting"),
None => get("horizon.agent.off"),
}];
if let Some(s) = &server {
if session_only {
lines.push(get("horizon.agent.session_opened"));
}
lines.push(tr("horizon.agent.socket", &[&s.name]));
}
lines.push(get("horizon.agent.local_only"));
lines
}
#[cfg(test)]
mod tests {
use super::{Socket, status_lines};
use horizon_client::lang::{Language, scoped};
fn lines(code: &str, clients: usize, ago: Option<u64>, session_only: bool) -> Vec<String> {
let socket = Socket {
clients,
name: "ctl.sock".into(),
};
let (lines, missed) = scoped(Language::load(&[], code), || {
status_lines(Some(socket), ago, session_only)
});
assert!(missed.is_empty(), "{code}: {missed:?}");
lines
}
/// The AI Agent page told its state in English in every language (#1927).
#[test]
fn agent_page_lines_follow_the_language() {
assert_eq!(
lines("de_de", 2, Some(5), false),
[
"Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s",
"Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)",
"Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
]
);
assert_eq!(
lines("pl_pl", 1, None, true),
[
"Połączono: 1 połączenie agenta",
"Otwarte na tę sesję przez program, który uruchomił grę.",
"Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)",
"Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
]
);
let (off, missed) = scoped(Language::load(&[], "fr_fr"), || {
status_lines(None, None, false)
});
assert!(missed.is_empty(), "{missed:?}");
assert_eq!(
off,
[
"Désactivé : les agents ne peuvent pas joindre ce jeu.",
"Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
]
);
}
/// English keeps the sentences it always had: the count, its plural, the time since the last
/// command, and the wait for a first agent.
#[test]
fn agent_page_lines_in_english() {
assert_eq!(
lines("en_us", 2, Some(5), false)[0],
"Connected: 2 agent connections, last command 5 s ago"
);
assert_eq!(
lines("en_us", 1, None, false)[0],
"Connected: 1 agent connection"
);
assert_eq!(
lines("en_us", 1, Some(0), false)[0],
"Connected: 1 agent connection, last command 0 s ago"
);
assert_eq!(
lines("en_us", 3, None, false)[0],
"Connected: 3 agent connections"
);
assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent.");
}
}
@@ -600,6 +600,23 @@ impl Game<'_> {
if held.is_none() && current.is_none() {
return false;
}
// A server's mods hear the swap before it happens, and may stop it.
// A stack of several on an occupied slot goes nowhere (below).
let moves = held
.as_ref()
.is_none_or(|h| h.count <= 1 || current.is_none());
if moves
&& target.is_some()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let stand = self.armor_stands[i].id as i32;
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
let mut cancel = false;
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
if cancel {
return true;
}
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
+5
View File
@@ -130,8 +130,13 @@ impl Game<'_> {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
return;
};
let previous = b.levels;
self.tick_beacon(key, &mut b, false);
let current = b.levels;
self.world.block_entities.put(key, BlockEntity::Beacon(b));
if (previous == 0) != (current == 0) {
self.native_beacon_state_change(self.dim.id(), [key.0, key.1, key.2], current > 0);
}
}
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
/// carries no beam sections, so the client builds them too; effects,
+12 -2
View File
@@ -150,8 +150,18 @@ impl Game<'_> {
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if stored && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
if stored {
let kind = self.mobs[i].kind.id.clone();
let block = self
.res
.facts()
.get(self.world.block(at[0], at[1], at[2]))
.name
.clone();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
}
self.native_entity_enter_block(mob, &kind, at, &block);
}
}
/// `BeehiveBlockEntity.emptyAllLivingFromHive` with no player about.
@@ -714,6 +714,16 @@ impl Game<'_> {
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_available(&self, pos: [i32; 3]) -> bool {
self.be_anims
.gateway_servers
.get(&pos)
.copied()
.unwrap_or_else(|| self.gateway_loaded(pos))
.teleport_cooldown
<= 0
}
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
let initial = self.gateway_loaded(pos);
let entered = self
Loaded 100 of 597 files, more files were not shown because too many files have changed in this diff. Show more