Merge main into the client library split

Ports main's lib.rs (block_edit, cit, lod_net, ram_limit, social_screen) into
client_lib.rs, main.rs's --player-dir logging and ram_limit into entry.rs, and
the horizon-optifine dependency into horizon-client-lib.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 19:02:57 +01:00
commit dea8e3b54c
553 files changed
+53046 -3004

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: mojang republish
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
# gh workflow run "mojang republish" -f dry_run=true
on:
schedule:
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: mojang-republish
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
+2
View File
@@ -30,6 +30,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
@@ -57,6 +58,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
Generated
+7
View File
@@ -1324,6 +1324,7 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1400,6 +1401,7 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1450,6 +1452,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1534,6 +1537,7 @@ dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"log",
"serde",
"serde_json",
]
@@ -1569,6 +1573,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
@@ -1675,6 +1681,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
+25
View File
@@ -66,3 +66,28 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> 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.
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
+7
View File
@@ -281,6 +281,13 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
@@ -39,6 +39,11 @@ pub enum Change {
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
+14 -2
View File
@@ -227,12 +227,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
@@ -281,4 +281,16 @@ mod tests {
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
#[cfg(windows)]
#[test]
fn windows_reports_the_cpu_brand() {
let cpu = Hardware::detect().cpu.expect("a CPU name");
assert!(
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
"family and model, not a name: {cpu:?}"
);
assert_eq!(cpu, cpu.trim());
}
}
+41 -4
View File
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
the report gives each bot's last health. A UUID instead of a NAME: the
entity with that UUID (a boat summoned with it, say)
--attack-after S start attacking S seconds after joining; default 5
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
default no limit
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
@@ -153,9 +156,11 @@ struct Options {
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
/// The players or entity UUIDs to attack, how long after joining to start,
/// and how many rounds.
attack: Option<String>,
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
flood: usize,
no_read: bool,
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
pvp: true,
attack: None,
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
flood: 100,
no_read: false,
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
})
}
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
/// the offline UUID of the player with that name (a name has no `-`).
fn target_uuid(name: &str) -> u128 {
let hex: String = name.chars().filter(|&c| c != '-').collect();
match u128::from_str_radix(&hex, 16) {
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
_ => offline_uuid(name),
}
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
@@ -984,7 +1001,7 @@ fn run_bot(
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.map(|name| (target_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
@@ -1039,6 +1056,7 @@ fn run_bot(
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut rounds = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
@@ -1202,8 +1220,13 @@ fn run_bot(
send(body);
}
}
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
if joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
&& rounds < o.attack_rounds
{
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
// A round counts once a target is known.
rounds += u32::from(!targets.is_empty());
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
@@ -1446,6 +1469,20 @@ mod tests {
})
}
/// `--attack`: a UUID names that entity, anything else a player.
#[test]
fn attack_targets_a_uuid_or_a_player() {
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
assert_eq!(
target_uuid("0000000a-0000-0001-0000-000000000002"),
0x0000000a_0000_0001_0000_000000000002
);
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
// A name of 32 hex digits is a name: a UUID is written with dashes.
let name = "0123456789abcdef0123456789abcdef";
assert_eq!(target_uuid(name), offline_uuid(name));
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
+169
View File
@@ -72,6 +72,19 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -316,6 +329,20 @@ mod tests {
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
// German and French, and their regional variants on the same file
// (Swiss German without "ß").
let de = Language::load(&[&empty], "de_de");
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
let fr = Language::load(&[&empty], "fr_fr");
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
let variant = Language::load(&[&empty], code);
assert_eq!(variant.map(), base.map(), "{code}");
}
let de_ch = Language::load(&[&empty], "de_ch");
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
assert_eq!(de_ch.len(), de.len());
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
@@ -323,6 +350,148 @@ mod tests {
);
}
/// `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"))
.join("../horizon-client/assets/horizon/lang")
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
body.lines()
.filter(|line| line.starts_with(" \""))
.map(|line| {
serde_json::Deserializer::from_str(line.trim_start())
.into_iter::<String>()
.next()
.and_then(Result::ok)
.unwrap_or_else(|| panic!("no key on line {line}"))
})
.collect()
}
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
/// percent sign that starts none of them ("100% is") is plain text.
fn placeholders(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find('%') {
rest = &rest[at + 1..];
let spec = rest
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
.unwrap_or(rest.len());
let after = rest[spec..].chars().next();
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
let end = spec + 1;
found.push(format!("%{}", &rest[..end]));
rest = &rest[end..];
}
}
found.sort();
found
}
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
};
seen += 1;
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
let map: HashMap<String, String> = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
let keys = file_keys(&body);
assert_eq!(
keys.len(),
map.len(),
"{name} repeats a key or has a multi-line entry"
);
for (key, value) in &map {
// Stale: the code no longer asks for it, or it was renamed in en_us only.
let english = en
.get(key)
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
// A translation keeps the fields the code fills in.
assert_eq!(
placeholders(value),
placeholders(english),
"{code}: {key} changes the placeholders of the English text"
);
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
for (at, _) in text.match_indices("\"horizon.") {
let rest = &text[at + 1..];
let Some(end) = rest.find('"') else { continue };
let key = &rest[..end];
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
// between the dots. A key built at run time has a brace; a file name
// has one dot; the server addresses that look like keys are listed.
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
let key_like = key
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
continue;
}
found += 1;
assert!(
en.contains_key(key),
"{} names {key}, which is not in en_us.json",
file.display()
);
}
}
assert!(found > 50, "found only {found} key names");
}
#[test]
fn layers_selected_language_over_english() {
let base = Mem(HashMap::from([
@@ -483,6 +483,21 @@ impl KeyBinds {
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
/// hotbar slots, as the controls bind them now. Answers the key the menu
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
/// swaps instead of reaching the screen.
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
let name = key_name(key)?;
if self.get("key.swapOffhand") == name {
return Some(KeyCode::KeyF);
}
MAPPINGS
.iter()
.filter(|m| m.0.starts_with("key.hotbar."))
.find(|m| self.get(m.0) == name)
.and_then(|m| key_code(Self::default_of(m.0)))
}
}
#[cfg(test)]
@@ -519,6 +534,29 @@ mod tests {
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn container_hotkeys_follow_the_bound_keys() {
let mut keys = KeyBinds::default();
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyF),
Some(KeyCode::KeyF)
);
assert_eq!(
keys.container_hotbar_key(KeyCode::Digit3),
Some(KeyCode::Digit3)
);
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
// The report's key: bound to the off hand, the menu takes it first.
keys.set("key.swapOffhand", "key.keyboard.tab");
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
keys.set("key.hotbar.3", "key.keyboard.t");
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyT),
Some(KeyCode::Digit3)
);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
@@ -549,6 +549,7 @@ pub fn fullscreen_request(
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
+1
View File
@@ -41,6 +41,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
+3
View File
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
+38 -2
View File
@@ -96,6 +96,13 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -235,9 +242,34 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
}
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -259,6 +291,10 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
@@ -1179,6 +1179,15 @@ impl Audio {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
pub fn set_listener_reserve(&mut self, on: bool) {
self.engine.listener_reserve = if on {
crate::sound_engine::HEARD_RESERVE
} else {
0
};
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
+122
View File
@@ -172,6 +172,7 @@ fn color(v: &Value) -> Option<u32> {
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
@@ -387,6 +388,52 @@ impl Biomes {
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
@@ -534,6 +581,81 @@ mod pack_tests {
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -0,0 +1,306 @@
//! Bulk block edits: runs of blocks up columns written straight into the
//! resident sections, a section unpacked and packed once however many of its
//! blocks change, instead of a `World::set` per block. Nothing round them is
//! told (as `fill ... strict`); the light of each chunk changed is worked out
//! again whole, off the game thread, rather than block by block, and each
//! section changed is remeshed once.
use crate::chunk::index;
use crate::fast_hash::FxHashMap;
use crate::world::World;
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
/// `state` where filter `only` (if any) passes the block there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Run {
pub x: i32,
pub z: i32,
pub y0: i32,
pub y1: i32,
pub state: u32,
pub only: Option<u16>,
}
/// What an edit changed.
#[derive(Debug, Default)]
pub struct Outcome {
/// Blocks changed.
pub changed: u64,
/// Chunks with a change.
pub chunks: usize,
/// Changes of the states `tracked` asked for (block entities, watched
/// blocks): position, previous and new state.
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
}
/// Per state answers, worked out once each.
struct Memo(Vec<u8>);
impl Memo {
#[inline]
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
let i = state as usize;
if i >= self.0.len() {
self.0.resize(i + 1, 0);
}
if self.0[i] == 0 {
self.0[i] = 1 + u8::from(ask(state));
}
self.0[i] == 2
}
}
impl World {
/// Make `runs` in order, each seeing the changes of those before it.
/// `matches(filter, state)` says whether a filter passes a state, and
/// `tracked(state)` whether its changes are reported; each is asked once
/// per state. Runs in unloaded chunks and the parts of runs outside each
/// chunk's own column (`Chunk::layout`) are left out.
///
/// Unlike `World::set`, this records nothing per block: no
/// `block_entities.state_changed` (a caller adds or removes block
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
/// for those states through `tracked`), no `block_log` entry (the chunk
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
/// paths are not recomputed for these blocks) and no `light_edits` (the
/// chunk and its neighbours are lit again whole, through `dirty_light`).
pub fn edit_runs(
&mut self,
runs: &[Run],
mut matches: impl FnMut(u16, u32) -> bool,
mut tracked: impl FnMut(u32) -> bool,
) -> Outcome {
let mut out = Outcome::default();
// the runs of each chunk, in order, and the chunks in the order they were first named
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut order = Vec::new();
for (i, r) in runs.iter().enumerate() {
if r.y1 < r.y0 {
continue;
}
let key = (r.x >> 4, r.z >> 4);
by_chunk
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i as u32);
}
let mut filter_memo: Vec<Memo> = Vec::new();
let mut tracked_memo = Memo(Vec::new());
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
let mut changed: Vec<bool> = Vec::new();
for key in order {
let Some(chunk) = self.chunks.get(&key) else {
continue;
};
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
let max_y = chunk.max_y();
let list = &by_chunk[&key];
changed.clear();
changed.resize(sections, false);
values.clear();
values.resize_with(sections, || None);
let mut any = 0u64;
for &i in list {
let r = runs[i as usize];
let (lx, lz) = (r.x & 15, r.z & 15);
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
let mut y = y0;
while y <= y1 {
let s = ((y - min_y) >> 4) as usize;
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
let vals = values[s].get_or_insert_with(|| {
let mut v = Box::new([0u32; 4096]);
chunk.sections[s].unpack(&mut v);
v
});
for yy in y..=top {
let at = index(lx, yy, lz);
let old = vals[at];
if old == r.state {
continue;
}
if let Some(f) = r.only {
let f = f as usize;
if filter_memo.len() <= f {
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
}
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
continue;
}
}
vals[at] = r.state;
changed[s] = true;
any += 1;
if tracked_memo.get(old, &mut tracked)
|| tracked_memo.get(r.state, &mut tracked)
{
out.tracked.push(((r.x, yy, r.z), old, r.state));
}
}
y = top + 1;
}
}
if any == 0 {
continue;
}
out.changed += any;
out.chunks += 1;
let chunk =
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
for s in 0..sections {
if changed[s]
&& let Some(v) = &values[s]
{
chunk.replace_blocks(s, v);
}
}
self.revision = self.revision.wrapping_add(1);
self.modified.insert(key);
if self.block_log.is_some() {
self.bulk_log.insert(key);
}
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
for ds in -1..=1 {
let sy = s as i32 + ds;
if !(0..sections as i32).contains(&sy) {
continue;
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
}
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
for cz in -1..=1 {
for cx in -1..=1 {
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
// stone up to y 63
for z in -16..32 {
for x in -16..32 {
for y in -64..64 {
w.set(x, y, z, BlockStateId(1)).unwrap();
}
}
}
w.dirty_light.clear();
w.dirty_sections.clear();
w.block_log = Some(Vec::new());
w
}
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
Run {
x,
z,
y0,
y1,
state,
only,
}
}
#[test]
fn runs_change_their_blocks_in_order() {
let mut w = world();
let runs = [
run(3, 4, 10, 40, 0, None),
run(3, 4, 10, 12, 5, None),
// only where it is air: over the stone, and the three blocks of 5 stay
run(3, 4, 0, 80, 7, Some(0)),
run(-1, 20, -70, 400, 9, None),
];
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
assert_eq!(w.block(3, 9, 4).0, 1);
assert_eq!(w.block(3, 11, 4).0, 5);
assert_eq!(w.block(3, 13, 4).0, 7);
assert_eq!(w.block(3, 70, 4).0, 7);
assert_eq!(w.block(3, 81, 4).0, 0);
assert_eq!(w.block(3, 0, 4).0, 1);
assert_eq!(w.block(-1, -64, 20).0, 9);
assert_eq!(w.block(-1, 319, 20).0, 9);
assert_eq!(w.block(0, 30, 20).0, 1);
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
assert_eq!(out.changed, 31 + 3 + 45 + 384);
assert_eq!(out.chunks, 2);
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
assert!(w.dirty_sections.contains(&(1, 4, 1)));
// the per-block log is left to the blocks set one at a time
assert!(w.block_log.as_ref().unwrap().is_empty());
}
#[test]
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
let mut w = world();
let out = w.edit_runs(
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|_, _| true,
|_| false,
);
assert_eq!(out.changed, 0);
assert_eq!(out.chunks, 0);
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
}
#[test]
fn tracked_states_are_reported() {
let mut w = world();
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
assert_eq!(out.tracked.len(), 6);
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
}
#[test]
fn heightmaps_follow_the_edit() {
let mut w = world();
let air = |s: u32| s == 0;
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
}
#[test]
fn runs_follow_each_chunks_own_layout() {
use crate::chunk::ColumnLayout;
// a data pack's taller dimension: -128..=511
let tall = ColumnLayout::new(-128, 640).unwrap();
let mut w = World::new(BlockStateId(0), 0);
w.chunks
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
assert_eq!(out.changed, 640);
let chunk = &w.chunks[&(0, 0)];
assert_eq!(chunk.block(2, -128, 3).0, 4);
assert_eq!(chunk.block(2, -65, 3).0, 4);
assert_eq!(chunk.block(2, 320, 3).0, 4);
assert_eq!(chunk.block(2, 511, 3).0, 4);
assert!(w.dirty_sections.contains(&(0, 39, 0)));
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
}
}
@@ -76,6 +76,26 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
/// for the renderer: the mesher lists block entities by their template,
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
(x >> 4, z >> 4)
}
/// Takes `pos` out of its chunk's mod block entity set.
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
let chunk = chunk_of(pos);
if let Some(set) = mods.get_mut(&chunk) {
set.remove(&pos);
if set.is_empty() {
mods.remove(&chunk);
}
}
}
impl std::ops::Deref for BlockEntities {
@@ -190,6 +210,23 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
let far = move |c: i32, at: f64| {
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
at < lo - radius || at > hi + radius
};
self.mods
.iter()
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -202,10 +239,16 @@ impl BlockEntities {
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, pos);
}
old
}
/// The block entity at `pos`, made by `make` when there is none.
@@ -214,15 +257,24 @@ impl BlockEntities {
pos: Pos,
make: impl FnOnce() -> BlockEntity,
) -> &mut BlockEntity {
if !self.map.contains_key(&pos) {
let absent = !self.map.contains_key(&pos);
if absent {
self.add(pos);
}
self.map.entry(pos).or_insert_with(make)
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.cancel(pos);
self.map.remove(pos)
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
@@ -236,6 +288,9 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -247,17 +302,20 @@ impl BlockEntities {
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let mods = &mut self.mods;
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
}
kept
});
}
pub fn clear(&mut self) {
self.map.clear();
self.mods.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -274,9 +332,16 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
if self.map.insert(p, e).is_some() {
let new_is_mod = matches!(e, BlockEntity::Mod(_));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
}
if new_is_mod {
self.mods.entry(chunk_of(p)).or_default().insert(p);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, p);
}
}
for p in order {
self.add(p);
@@ -369,6 +434,38 @@ mod tests {
.collect()
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
found
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
b.insert((1, 64, 1), modded());
b.insert((200, 64, 1), modded());
b.insert((2, 64, 2), hopper());
b.get_or_insert_with((3, 64, 3), modded);
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
// Only mods, and only chunks within reach.
assert_eq!(
near(&b, 0.0, 0.0, 64.0),
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
);
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
// Taken out for a tick, still listed; removed or replaced, gone.
let taken = b.take(&(1, 64, 1)).unwrap();
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
b.put((1, 64, 1), taken);
b.remove(&(3, 64, 3));
b.insert((4, 64, 4), hopper());
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
b.retain(|p, _| p.0 != 1);
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
+3 -1
View File
@@ -205,7 +205,9 @@ impl Brewing {
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
// The data's recipe filters, over the final set.
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
+115 -4
View File
@@ -501,6 +501,19 @@ impl Camera {
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
}
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
/// First-person views do not call `adjust_distance`.
pub fn align_with_entity_distance(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
adjust_distance: impl FnOnce(f32) -> f32,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
@@ -528,10 +541,14 @@ impl Camera {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
let adjusted = adjust_distance(vanilla);
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
adjusted
} else {
vanilla
};
let zoom = self.max_zoom(distance, &mut clip);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
@@ -969,6 +986,100 @@ mod tests {
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn detached_distance_override_keeps_collision_clipping() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: Some(LivingCamera {
scale: 2.0,
camera_distance: 3.0,
sleeping: None,
dead: None,
}),
mount: Some((3.0, 4.0)),
};
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
let mut camera = Camera::default();
let mut calls = 0;
let mut rays = 0;
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
assert_eq!(distance, 12.0);
8.0
},
|_, _| None,
);
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
calls += 1;
assert_eq!(distance, 12.0);
8.0
},
|_, _| {
rays += 1;
Some([0.0, 0.0, z])
},
);
assert_eq!((calls, rays), (1, 8));
assert!((camera.position[2] - z).abs() < 0.2);
}
}
#[test]
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| distance,
|_, _| None,
);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
}
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| 0.0,
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::FirstPerson,
|_| panic!("first person has no detached distance"),
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
+50 -17
View File
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -233,14 +237,10 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
}
#[derive(Default)]
pub struct Chat {
@@ -256,6 +256,11 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -555,7 +560,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -569,28 +574,47 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(ticks, line.added)
time_alpha(fade_ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
for &(i, _, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -598,9 +622,15 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha) in &lines {
for &(i, line, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -612,7 +642,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -622,6 +652,9 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -658,7 +691,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+56 -1
View File
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0, true) {
for draw in chat.layout(361, 0.0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
@@ -508,6 +508,15 @@ impl Chunk {
}
previous
}
/// Section `s`'s block states replaced whole (a bulk edit): its storage
/// compacted, and the heightmaps built again on their next read.
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
+662
View File
@@ -0,0 +1,662 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
}
+10 -2
View File
@@ -1,7 +1,15 @@
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
pub const CLOUD_HEIGHT: f64 = 192.33;
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
pub fn has_color(alpha: f32) -> bool {
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
}
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
pub fn visible(mode: &str, alpha: f32) -> bool {
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
mode != "false" && has_color(alpha)
}
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
@@ -110,7 +118,7 @@ impl Input<'_> {
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
@@ -199,8 +199,9 @@ where
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
/// collides as the living entity it is, empty-handed.
/// player, inventory, effects or fluid-standing behavior; a lava walker or
/// a living entity in leather boots collides as the living entity it is,
/// empty-handed.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -236,7 +237,10 @@ impl SceneCollisions<'_, '_> {
}
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
let y = bounds.min_position().y;
if !self.subject.is_some_and(|s| s.lava_walker) {
if !self
.subject
.is_some_and(|s| s.lava_walker || s.leather_boots)
{
return CollisionContext::position(y);
}
CollisionContext::for_entity(
@@ -0,0 +1,724 @@
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
//! the short list of rules that can touch it; meshing a face then walks only those lists
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
//!
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
use crate::sprites::Sprites;
use horizon_optifine::ctm::{
self, Connect, Method, Orient, Rule, Tile,
connect::{self as geometry, Axes, Axis, Face},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, path::Path};
/// A tile as the mesher draws it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileLayer {
Layer {
layer: u16,
/// The sprite has fractional alpha (drawn in the translucent pass).
translucent: bool,
/// Every texel is opaque (drawn without an alpha test).
opaque: bool,
},
/// Try the next rule.
Skip,
/// The face's own sprite.
Default,
}
/// What a face draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// Its own sprite.
Keep,
Tile(TileLayer),
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
}
#[derive(Clone, Copy, Default)]
struct StateInfo {
/// Index of the state's block (`connect=block`).
block: u32,
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
}
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
by_state: Vec<(u32, u32)>,
by_layer: Vec<(u32, u32)>,
lists: Vec<u16>,
states: Vec<StateInfo>,
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
/// when no rule connects by tile.
face_layers: Vec<[u16; 6]>,
}
/// `list` appended to `lists`, as its range there.
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
let start = lists.len() as u32;
lists.extend_from_slice(list);
(start, list.len() as u32)
}
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
/// itself (as [`Sprites::insert_file`] names a tile).
fn sprite_name(resource: &str) -> String {
let parts = resource
.strip_prefix("assets/")
.and_then(|p| p.split_once('/'))
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
match parts {
Some((ns, path)) => format!("{ns}:{path}"),
None => resource.to_owned(),
}
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
let loaded = rules
.into_iter()
.filter_map(|rule| {
let tiles = rule
.tiles
.iter()
.map(|tile| match tile {
Tile::Skip => Ok(TileLayer::Skip),
Tile::Default => Ok(TileLayer::Default),
Tile::Texture(path) => {
// A tile among the textures is that sprite, under its own name.
let layer = match sprite_name(path) {
name if name != *path => sprites.insert(root, &name)?,
_ => sprites.insert_file(root, path)?,
};
Ok(TileLayer::Layer {
layer: u16::try_from(layer)?,
translucent: sprites.translucent[layer as usize],
opaque: sprites.opaque[layer as usize],
})
}
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
}
}
})
.collect();
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
})
.collect();
}
out
}
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
/// registry skips), `layer` answering a sprite name's layer.
fn build<'a>(
rules: Vec<LoadedRule>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
layer: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut out = Self {
rules,
..Self::default()
};
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
}
if by_layer[l].last() != Some(&i) {
by_layer[l].push(i);
}
}
}
for m in &r.rule.match_blocks {
let list = by_block.entry(m.id.as_str()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
for list in by_layer {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
out.by_state.push((0, 0));
out.states.push(StateInfo::default());
continue;
};
let next = blocks.len() as u32;
let block = *blocks.entry(name).or_insert(next);
let prop = |k: &str| props.get(k).map(String::as_str);
out.states.push(StateInfo {
block,
axis: match prop("axis") {
Some("x") => Some(Axis::X),
Some("y") => Some(Axis::Y),
Some("z") => Some(Axis::Z),
_ => None,
},
upper: prop("half") == Some("upper"),
});
list.clear();
if let Some(candidates) = by_block.get(name) {
list.extend(
candidates
.iter()
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
out
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// 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.
pub fn pick<'b>(
&self,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
self.list(&self.by_layer, layer as usize),
);
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
// both (it is in both lists); one naming either needs that one.
loop {
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
(None, None) => return Pick::Keep,
(Some((&x, rest)), Some((&y, other))) if x == y => {
(by_state, by_layer) = (rest, other);
(x, true, true)
}
(Some((&x, rest)), Some((&y, _))) if x < y => {
by_state = rest;
(x, true, false)
}
(Some((&x, rest)), None) => {
by_state = rest;
(x, true, false)
}
(_, Some((&y, rest))) => {
by_layer = rest;
(y, false, true)
}
};
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)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
r: &LoadedRule,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
if rule.faces & face.bit() == 0
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
{
return None;
}
if let Some(biomes) = &rule.biomes
&& !biome().is_some_and(|b| biomes.matches(b))
{
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
}
Connect::State => other == state,
Connect::Tile => self
.face_layers
.get(other as usize)
.is_some_and(|f| f[face as usize] == layer),
}
};
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.
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
let case = geometry::ctm_index(mask) as u8;
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
Some(&(_, t)) => t,
None => {
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
};
}
}
}
Method::Horizontal => geometry::horizontal(mask()),
Method::Vertical => geometry::vertical(mask()),
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
Method::Top => {
// Only sides join the block above: the top and bottom go on to the next rule.
if matches!(face, Face::Up | Face::Down) {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
}
0
}
Method::Fixed => 0,
Method::Random => {
let face = geometry::symmetric_face(face, rule.symmetry);
let pos = if rule.linked && info.upper {
[pos[0], pos[1] - 1, pos[2]]
} else {
pos
};
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
}
Method::Repeat => {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
_ => return None,
};
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
/// `1000 * (r + 1) + i`.
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
let mut rules: Vec<Rule> = files
.iter()
.map(|(path, text)| {
Rule::parse(
&format!("assets/minecraft/optifine/ctm/{path}"),
&Properties::parse(text),
)
.unwrap()
.unwrap()
})
.collect();
ctm::sort(&mut rules);
rules
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
tiles: rule
.tiles
.iter()
.enumerate()
.map(|(i, t)| match t {
Tile::Skip => TileLayer::Skip,
Tile::Default => TileLayer::Default,
Tile::Texture(_) => TileLayer::Layer {
layer: (1000 * (n + 1) + i) as u16,
translucent: false,
opaque: false,
},
})
.collect(),
rule,
})
.collect()
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let states = [
("minecraft:air", props(&[])),
("minecraft:glass", props(&[])),
("minecraft:stone", props(&[])),
("minecraft:oak_log", props(&[("axis", "y")])),
("minecraft:oak_log", props(&[("axis", "x")])),
("minecraft:oak_door", props(&[("half", "lower")])),
("minecraft:oak_door", props(&[("half", "upper")])),
];
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
)
}
fn tile(layer: u16) -> Pick {
Pick::Tile(TileLayer::Layer {
layer,
translucent: false,
opaque: false,
})
}
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
fn pick_in(
ctm: &ConnectedTextures,
state: u32,
layer: u16,
face: Face,
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)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
#[test]
fn glass_joins_its_neighbours() {
let ctm = world(&[("glass.properties", GLASS)]);
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1001)
);
// Glass all around in the face's plane: the middle tile, 26.
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
.collect();
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
// Stone beside it doesn't join; stone has no rule.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
}
#[test]
fn rule_order_faces_and_skips() {
let ctm = world(&[
(
"a_sides.properties",
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
),
("b_glass.properties", GLASS),
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
]);
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
// Sides: a_sides skips, so z draws.
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
}
#[test]
fn compact_quarters_and_overrides() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
)]);
let layer = |i: u16| TileLayer::Layer {
layer: 1000 + i,
translucent: false,
opaque: false,
};
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[]),
Pick::Quarters([layer(0); 4])
);
// Joined right only is case 1, replaced whole by tile 5.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1005)
);
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
);
}
#[test]
fn logs_turn_with_their_axis() {
let ctm = world(&[(
"log.properties",
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
)]);
// An upright log with a log above: the bottom of a column.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// A log lying along x: on its north face the texture's up points east, so a log to the
// east (same block, other axis state) is above it.
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
tile(1003)
);
}
#[test]
fn top_and_connect_state() {
let ctm = world(&[(
"block_oak_log.properties",
"method=top\ntiles=0\nconnect=state",
)]);
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// The other axis is another state.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
Pick::Keep
);
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
}
#[test]
fn linked_random_follows_the_lower_half() {
let ctm = world(&[(
"door.properties",
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
)]);
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);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
assert!(tiles.len() > 3, "the doors are not all alike");
}
#[test]
fn heights_and_biomes() {
let ctm = world(&[(
"glass.properties",
"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));
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
}
#[test]
fn connect_by_tile_reads_the_neighbours_same_side() {
let mut ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
)]);
// Stone shows the glass sprite (3) on its north side; the door only on its south.
let mut faces = vec![[9u16; 6]; 7];
faces[1][Face::North as usize] = 3;
faces[2][Face::North as usize] = 3;
faces[5][Face::South as usize] = 3;
ctm.face_layers = faces;
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
tile(1003)
);
}
#[test]
fn repeat_and_rows_then_columns() {
let ctm = world(&[(
"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);
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));
assert_eq!(at([0, 63, 0]), tile(1002));
let hv = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
)]);
// Alone in its row, glass above: the column's bottom, tile 4.
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
tile(1004)
);
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1000)
);
}
#[test]
fn skips_defaults_and_narrowed_rules() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
),
(
"b.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
),
(
"c.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
),
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
(
"e.properties",
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
),
]);
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
// glass itself, which stops d.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
let both = world(&[(
"e.properties",
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
)]);
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
// `top` passes the top face on.
let top = world(&[
("block_glass.properties", "method=top\ntiles=0"),
(
"z.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
),
]);
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn sprite_names() {
assert_eq!(
sprite_name("assets/minecraft/textures/block/dirt.png"),
"minecraft:block/dirt"
);
assert_eq!(
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
"assets/minecraft/optifine/ctm/glass/0.png"
);
}
}
+29 -2
View File
@@ -212,7 +212,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
command_output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
} else {
None
};
@@ -235,6 +235,8 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
horizon_telemetry::system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -354,7 +356,8 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
);
crate::logging::flush();
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
@@ -386,6 +389,30 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
+5 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -583,7 +583,7 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
@@ -591,7 +591,8 @@ fn content_id(file: &str) -> Option<String> {
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -628,6 +629,7 @@ impl DataStore {
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"tags",
"trade_set",
"villager_trade",
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
@@ -141,6 +141,8 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
/// `visual/cloud_height`.
pub cloud_height: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -309,6 +311,10 @@ impl Environment {
.base
.get("minecraft:visual/cloud_fog_end_distance")
.map_or(2048.0, |v| v[0]),
cloud_height: self
.base
.get("minecraft:visual/cloud_height")
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
}
}
}
+156 -15
View File
@@ -96,8 +96,13 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Positional sounds leave a few static sound channels free for UI sounds
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
pub ui_sound_reserve: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// No particles from breaking or mining a block (other particles stay).
pub hide_block_particles: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -106,6 +111,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -113,6 +122,8 @@ pub struct Extras {
pub agent_control: 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,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,14 +149,19 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
ui_sound_reserve: false,
own_name_tag: false,
hide_block_particles: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -213,14 +229,19 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"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),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +249,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:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\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:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -254,15 +275,20 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.ui_sound_reserve),
bool_text(self.own_name_tag),
bool_text(self.hide_block_particles),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -383,64 +409,139 @@ pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
MouseTweaks,
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("Inventory"),
Err("horizon.extras.header.particles"),
Ok(HideBlockParticles),
Err("horizon.extras.header.inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Multiplayer"),
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
pub fn caption_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright",
Item::DynamicLights => "horizon.extras.dynamic_lights",
Item::OwnNameTag => "horizon.extras.own_name_tag",
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
}
}
/// The language key of the description shown as the row's tooltip.
pub fn description_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright.tooltip",
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
pub fn caption_text(self) -> String {
crate::lang::get_or(self.caption_key(), self.caption())
}
/// The description in the current language (English when the key is missing).
pub fn description_text(self) -> String {
crate::lang::get_or(self.description_key(), self.description())
}
/// The English caption: the fallback of [`Item::caption_text`], and the text of
/// `horizon.extras.*` in en_us.json.
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
}
/// What the row says under its caption.
/// The English description: the fallback of [`Item::description_text`].
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
@@ -450,9 +551,15 @@ impl Item {
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::HideBlockParticles => {
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -465,12 +572,19 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -481,10 +595,15 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -494,13 +613,18 @@ impl Item {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -509,8 +633,8 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
@@ -521,13 +645,18 @@ impl Item {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
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::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -549,7 +678,8 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -575,11 +705,22 @@ mod tests {
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
Item::HideBlockParticles.cycle(&mut e, false);
assert!(e.hide_block_particles);
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
let only = Extras::parse("hideBlockParticles:true\n");
assert!(only.hide_block_particles && !only.own_name_tag);
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+77 -1
View File
@@ -112,6 +112,8 @@ struct Region {
cursor: (u32, u32),
row: u32,
pending: Vec<Patch>,
/// A glyph did not fit since the last `Font::begin_frame`.
full: bool,
}
struct Cache {
/// Every font by id, the default one first (the common lookup).
@@ -546,6 +548,7 @@ impl Region {
self.row = 0;
}
if y + h > region.1 {
self.full = true;
return None;
}
let (ax, ay) = (x, region.0 + y);
@@ -603,6 +606,7 @@ impl Font {
cursor: (0, 0),
row: 0,
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
@@ -669,8 +673,19 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
cache.region.full = false;
self.generation.set(next_generation());
}
/// Call once per frame before any text is laid out. When the dynamic
/// region ran out of room (vanilla's glyph textures grow instead), the
/// glyphs are forgotten so they are rasterized again from the start; it
/// waits for the frame boundary because quads already queued hold the old
/// rects.
pub fn begin_frame(&self) {
if self.cache.borrow().region.full {
self.reset_cache();
}
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
@@ -706,7 +721,16 @@ impl Font {
if let Some(g) = set.glyphs.get(&c) {
return *g;
}
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.glyphs.insert(c, glyph);
glyph
}
@@ -1418,4 +1442,56 @@ mod tests {
font.reset_cache();
assert_eq!(font.glyph('a').advance, 2.0);
}
#[test]
fn a_full_glyph_region_recovers_at_the_next_frame() {
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
let sheet = png(16, 8, |x, _| x % 8 < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"full",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
let a = font.glyph('a');
assert!(a.rect.is_some());
// No room for 'b': missing for now, and asked again next time.
assert!(font.glyph('b').rect.is_none());
assert!(font.glyph('b').rect.is_none());
assert_eq!(font.epoch(), epoch);
font.begin_frame();
assert_ne!(font.epoch(), epoch);
let b = font.glyph('b');
assert!(b.rect.is_some());
assert_eq!(b.advance, a.advance);
// Nothing overflowed since: the next frame keeps the glyphs.
let epoch = font.epoch();
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
#[test]
fn a_character_without_a_provider_does_not_reset_the_region() {
let sheet = png(8, 8, |x, _| x < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"nofill",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
assert!(font.glyph('z').rect.is_none());
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
}
File diff suppressed because it is too large. Load diff
@@ -299,6 +299,7 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if self.collect(tick, active) == 0 {
return;
@@ -341,7 +342,7 @@ impl SpriteAnimator {
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
@@ -859,7 +860,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick, None);
animator.animate(&mut encoder, &mut uploads, tick, None, None);
writes.push(
animator
.sprites
+306 -18
View File
@@ -1,12 +1,21 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
//! Translucent quads can have an arena of their own (`Arena::translucent`),
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
pub const GRAIN: u64 = 256;
/// The largest page, in bytes.
/// The largest page of the section arena, and of arenas shaders bind whole
/// as storage, in bytes.
pub const PAGE_BYTES: u64 = 256 << 20;
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
@@ -118,7 +127,7 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -127,16 +136,54 @@ impl Arena {
} else {
wgpu::BufferUsages::INDEX
};
Self::with_usage(device, 4, capacity, usage)
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
}
/// Sorted indices of the translucent arena's quads: pages up to
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::INDEX;
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
page_bytes: u64,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
// Pages of `page_bytes`, or less where the device's buffers (or the
// storage bindings that read them whole) are smaller.
let limits = device.limits();
let mut bytes = limits.max_buffer_size.min(page_bytes);
if usage.contains(wgpu::BufferUsages::STORAGE) {
bytes = bytes.min(limits.max_storage_buffer_binding_size);
}
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
@@ -151,11 +198,16 @@ impl Arena {
video_memory_full: false,
}
}
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
/// A buffer of `capacity` vertices in video memory or, when that is full,
/// `spill` allows it and so does the device, in system memory: Vulkan
/// drivers fail an allocation rather than page like OpenGL ones, and
/// wgpu's default error handler panics. `None` when neither has room.
fn create(
&mut self,
device: &wgpu::Device,
capacity: u64,
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -167,9 +219,10 @@ impl Arena {
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
if !spill
|| !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
@@ -234,7 +287,7 @@ impl Arena {
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
let grown = self.grown(current, len);
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, encoder, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
@@ -250,9 +303,9 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None if capacity > len => (self.create(device, len, true)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
@@ -392,8 +445,17 @@ impl Arena {
}
}
}
/// The capacity the newest page grows to for a range of `len`: double, but
/// by at most `PAGE_BYTES` past that size, so a large page is never left
/// half empty (nor held twice during the copy) by hundreds of MiB.
fn grown(&self, current: u64, len: u64) -> u64 {
let step = current.min(PAGE_BYTES / self.stride);
(current + step).max(current + len).min(self.page_limit)
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
/// memory only: moving all of it to system memory would slow every frame
/// that draws it, so a new page takes the range instead (and may spill).
fn grow(
&mut self,
device: &wgpu::Device,
@@ -402,7 +464,8 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let spill = capacity * self.stride <= PAGE_BYTES;
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -474,6 +537,26 @@ mod tests {
.ok()
}
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.ok()
}
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
/// the adapter's own let large pages past 256 MiB.
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
let devices = [device(), device_at_limits()];
devices.into_iter().flatten().collect()
}
/// `Arena::alloc`, with the copies of a grow submitted at once.
fn alloc(
arena: &mut Arena,
@@ -588,6 +671,119 @@ mod tests {
}
}
/// The renderer's half of that compaction (`move_off_page`) moves what each
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
page,
start,
translucent_at,
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
for split in [true, false] {
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Page 0: two sections' grains and two free ones; page 1: one taken.
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
arena.release(0, rest, 2 * GRAIN);
// Split, a grain of solid quads each (the first's translucent ones, and
// all of a water section's, in the translucent arena); else a grain of
// solid and translucent quads, and one of translucent quads alone.
let mut sections = if split {
vec![
section((0, a), (0, 0), 64, 64),
section((0, 0), (0, GRAIN), 0, 64),
section((0, b), (0, 0), 64, 0),
]
} else {
vec![
section((0, a), (0, a + 128), 32, 32),
section((0, b), (0, b), 0, 64),
]
};
// A range's first and last vertex hold its section's number, and the
// first translucent one of a mixed range that plus 100.
let write = |(page, at): (u32, u64), value: usize| {
let bytes = (value as u32).to_le_bytes();
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
};
for (i, s) in sections.iter().enumerate() {
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
write((page, start), i);
write((page, start + len - 1), i);
if !split && s.solid > 0 && s.translucent > 0 {
write(s.translucent_at, 100 + i);
}
}
}
let before: Vec<_> = sections
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
before[i],
"split {split}"
);
continue;
};
assert_eq!(page, 1, "split {split}");
assert_eq!(word((page, start)), i as u32);
assert_eq!(word((page, start + len - 1)), i as u32);
if !split {
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
if s.solid > 0 {
assert_eq!(word(s.translucent_at), 100 + i as u32);
}
}
}
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
assert!(!arena.has_page(0), "split {split}");
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -630,6 +826,96 @@ mod tests {
assert!(arena.pages.is_empty());
}
/// Every arena's pages stay within its cap and the device's limits: the
/// whole-page storage bindings of flat draws and shader packs included.
#[test]
fn pages_stay_within_their_caps_and_the_device_limits() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let bytes = |a: &Arena| a.page_vertices() * a.stride;
for (device, _) in &devices {
let limits = device.limits();
for storage in [false, true] {
for arena in [
Arena::new(device, 24, GRAIN, storage),
Arena::indices(device, GRAIN, storage),
] {
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
let binding = limits.max_storage_buffer_binding_size;
assert!(!storage || bytes(&arena) <= binding);
}
}
for large in [
Arena::translucent(device, 24, GRAIN),
Arena::translucent_indices(device, GRAIN),
] {
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
}
}
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let mut exercised = false;
for (device, _) in &devices {
let limits = device.limits();
let large = limits.max_buffer_size > PAGE_BYTES;
exercised |= large;
for flat in [false, true] {
for split in [false, true] {
let case = format!("large {large}, flat {flat}, split {split}");
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
}
}
}
if !exercised {
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
}
}
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
#[test]
fn large_pages_grow_by_bounded_steps() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
// Exercise the policy past the test device's own buffer limit.
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let step = PAGE_BYTES / 24;
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
assert_eq!(arena.grown(step, GRAIN), 2 * step);
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
assert_eq!(
arena.grown(arena.page_limit - GRAIN, GRAIN),
arena.page_limit
);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -638,7 +924,9 @@ mod tests {
};
for mut arena in [
Arena::new(&device, 28, GRAIN, false),
Arena::translucent(&device, 24, GRAIN),
Arena::indices(&device, GRAIN, false),
Arena::translucent_indices(&device, GRAIN),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
+21 -14
View File
@@ -528,7 +528,11 @@ impl Cull {
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn test_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
@@ -539,6 +543,7 @@ impl Cull {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -580,23 +585,25 @@ impl Cull {
self.current = Some(self.next);
}
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -79,6 +81,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -258,13 +258,20 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
let mut pass = begin(
encoder,
"Entity outline",
&t.outline,
clear,
timing.as_deref_mut(),
);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -280,7 +287,7 @@ impl EntityOutline {
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
@@ -293,6 +300,7 @@ fn begin<'e>(
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -305,6 +313,7 @@ fn begin<'e>(
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
})
+42 -5
View File
@@ -62,7 +62,13 @@ pub struct Call {
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -286,7 +292,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -297,6 +303,7 @@ impl Flat {
&wgpu::PipelineLayout,
&str,
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
@@ -354,11 +361,35 @@ impl Flat {
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"fs_solid",
None,
back,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"fs_opaque",
None,
back,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"fs_solid",
None,
back,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
@@ -366,6 +397,7 @@ impl Flat {
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"vs_terrain_flat",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
@@ -490,6 +522,7 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
@@ -668,7 +701,7 @@ impl Flat {
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
@@ -700,13 +733,17 @@ impl Flat {
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn flatten_phase2(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
@@ -1,9 +1,572 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
//! makes a frame halfway between each two real ones. The world, without the hand and
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
//! present thread shows the made frame, then the real one half a real frame later.
//!
//! The renderer draws into frames of its own while this is on; the present thread
//! copies each to the swapchain, which it alone acquires and presents.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
mod optical_flow;
use glam::{DMat4, DVec3, Mat4};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
const FRAMES: usize = 6;
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
/// showing what moved on its own in blue (its motion vectors in red and green).
pub(super) fn generated_only() -> bool {
debug().is_some()
}
fn debug() -> Option<&'static str> {
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
DEBUG
.get_or_init(|| {
std::env::var("HORIZON_FRAMEGEN_DEBUG")
.ok()
.filter(|v| v == "generated" || v == "motion")
})
.as_deref()
}
pub struct FrameGen {
pub on: bool,
/// The present thread failed to show a frame (or died): until the renderer has
/// acquired a frame itself, the normal path runs and handles the surface.
pub paused: bool,
gpu: Option<Gpu>,
presenter: Option<Presenter>,
/// When the last real frame was handed over, and the time between real frames.
last: Option<Instant>,
interval: Duration,
/// The last frame's unjittered `view_proj` and camera.
previous: Option<(DMat4, DVec3)>,
/// The last frame's hand vertices and the bob and view they were drawn with.
hand: Vec<crate::mesher::Vertex>,
hand_view: Mat4,
/// Offscreen, the frame the made frame is drawn into.
offscreen_generated: Option<wgpu::Texture>,
/// Motion vectors for what moves on its own, made on the first frame.
motion: Option<motion::ObjectMotion>,
/// Real frames and made ones handed to the screen since `shown_since`, and the share
/// of real frames that had a made frame over the last half second (the F3 screen's),
/// `None` while off.
real: u32,
made: u32,
shown_since: Instant,
pub made_share: Option<f32>,
}
/// What runs on the GPU for one screen size.
struct Gpu {
size: [u32; 2],
flow: optical_flow::OpticalFlow,
interpolation: interpolation::Interpolation,
/// This frame's world at the screen's size, and bindings that copy it or the
/// interpolation's output (`Post::finish_with`).
world: wgpu::TextureView,
world_group: wgpu::BindGroup,
output_group: wgpu::BindGroup,
/// The next interpolation has no previous frame.
reset: bool,
}
/// This frame's camera, for the interpolation.
pub struct Camera {
/// The world's projection this frame (with TAA's jitter) and without the jitter.
pub jittered: Mat4,
pub view_proj: Mat4,
pub depth: wgpu::TextureView,
pub render_size: [u32; 2],
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's, `None` without one.
pub reproject: Option<Mat4>,
pub near: f32,
pub far: f32,
pub fov_y: f32,
}
impl FrameGen {
pub fn new() -> Self {
Self {
on: false,
paused: false,
gpu: None,
presenter: None,
last: None,
interval: Duration::from_millis(16),
previous: None,
hand: Vec::new(),
hand_view: Mat4::IDENTITY,
offscreen_generated: None,
motion: None,
real: 0,
made: 0,
shown_since: Instant::now(),
made_share: None,
}
}
/// Ends frame generation's frames and present thread: before the surface is
/// reconfigured or the renderer acquires from it itself.
pub fn stop(&mut self) {
self.presenter = None;
self.made_share = None;
(self.real, self.made) = (0, 0);
self.last = None;
self.previous = None;
self.hand.clear();
if let Some(motion) = &mut self.motion {
motion.reset();
}
if let Some(gpu) = &mut self.gpu {
gpu.reset = true;
}
}
/// The hand for the made frame, appended to `out`: each vertex halfway between where
/// it was on screen last frame and where it is now (the hand swings and bobs on its
/// own, so the world's interpolation can't move it), in this frame's space
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
/// shape (another item, the first frame).
pub fn halfway_hand(
&mut self,
hand: &[crate::mesher::Vertex],
view: Mat4,
out: &mut Vec<crate::mesher::Vertex>,
) {
let same = self.hand.len() == hand.len()
&& self
.hand
.iter()
.zip(hand)
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
if same {
let back = view.inverse();
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
let then = self.hand_view.transform_point3(before.pos.into());
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
crate::mesher::Vertex {
pos: back.transform_point3(mid).into(),
..*now
}
}));
} else {
out.extend_from_slice(hand);
}
self.hand.clear();
self.hand.extend_from_slice(hand);
self.hand_view = view;
}
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
/// entities' textures. Before `generate`, every frame.
pub fn track_entities(
&mut self,
device: &wgpu::Device,
atlas: &wgpu::TextureView,
entities: &[crate::mesher::Vertex],
spans: &[(u32, u64)],
camera: DVec3,
) {
self.motion
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
.track(entities, spans, camera);
}
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
/// (relative to `camera`); `None` on the first frame.
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
let current = view_proj.as_dmat4();
let previous = self.previous.replace((current, camera));
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
}
/// Frames to draw the real and the made frame into, from the present thread's
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
/// one of its own. `None` once the present thread failed or died: it is stopped and
/// frame generation `paused`.
pub fn frames(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Option<&Arc<wgpu::Surface<'static>>>,
offscreen: Option<&wgpu::Texture>,
window: &Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Option<(wgpu::Texture, wgpu::Texture)> {
let Some(surface) = surface else {
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
if self
.offscreen_generated
.as_ref()
.is_none_or(|t| t.size() != real.size())
{
self.offscreen_generated = Some(frame(device, config));
}
let generated = self.offscreen_generated.clone().expect("made above");
return Some((real, generated));
};
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
self.presenter = None;
}
let presenter = self.presenter.get_or_insert_with(|| {
Presenter::start(device, queue, surface.clone(), window.clone(), config)
});
// A closed channel: the thread died.
let pair = if presenter.failed() {
None
} else {
presenter.free.recv().ok().zip(presenter.free.recv().ok())
};
if pair.is_none() {
self.stop();
self.paused = true;
}
pair
}
/// The world's target at the screen's size this frame, where `Post::finish` draws.
pub fn world(
&mut self,
device: &wgpu::Device,
post: &super::world_post::Post,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
let size = [config.width, config.height];
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
let world = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation world"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format.remove_srgb_suffix(),
// COPY_SRC: the interpolation keeps it as the previous frame.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default());
let interpolation = interpolation::Interpolation::new(device, size);
self.gpu = Some(Gpu {
size,
flow: optical_flow::OpticalFlow::new(device, size),
world_group: post.bind(device, &world),
output_group: post.bind(device, interpolation.output()),
interpolation,
world,
reset: true,
});
}
self.gpu.as_ref().expect("made above").world.clone()
}
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
/// frame's world onto `generated`. Returns false when there is no made frame (the
/// first after a reset): `generated` then holds this frame's world too.
#[allow(clippy::too_many_arguments)]
pub fn generate(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
post: &super::world_post::Post,
screen: &wgpu::TextureView,
generated: &wgpu::TextureView,
camera: &Camera,
) -> bool {
let gpu = self.gpu.as_mut().expect("world() ran this frame");
post.finish_with(encoder, screen, &gpu.world_group, None);
let reset = gpu.reset || camera.reproject.is_none();
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
let object_motion = self
.motion
.as_mut()
.expect("track_entities() ran this frame")
.draw(
device,
queue,
encoder,
&camera.depth,
camera.render_size,
camera.jittered,
camera.view_proj,
reproject * camera.view_proj,
);
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
gpu.interpolation.dispatch(
device,
queue,
encoder,
&interpolation::Inputs {
color: &gpu.world,
depth: &camera.depth,
render_size: camera.render_size,
jitter: camera.jitter,
reproject,
object_motion: &object_motion,
near: camera.near,
far: camera.far,
fov_y: camera.fov_y,
reset,
flow_vectors: gpu.flow.vectors(),
scene_change: gpu.flow.scene_change(),
},
);
gpu.reset = false;
if debug() == Some("motion") {
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
return true;
}
if reset {
post.finish_with(encoder, generated, &gpu.world_group, None);
} else {
post.finish_with(encoder, generated, &gpu.output_group, None);
}
!reset
}
/// Hand this frame's pair to the present thread, after the frame's submission: the
/// made frame (when `made`) goes first.
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
let now = Instant::now();
if let Some(last) = self.last {
// A real frame's time, smoothed: the made frame shows for half of it.
let dt = (now - last).min(Duration::from_millis(250));
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
}
self.last = Some(now);
self.real += 1;
self.made += u32::from(made && !generated_only());
if now - self.shown_since >= Duration::from_millis(500) {
self.made_share = Some(self.made as f32 / self.real as f32);
(self.real, self.made, self.shown_since) = (0, 0, now);
}
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
return;
};
let pair = if generated_only() {
Pair {
first: None,
second: generated,
spare: Some(real),
interval: self.interval,
}
} else if made {
Pair {
first: Some(generated),
second: real,
spare: None,
interval: self.interval,
}
} else {
Pair {
first: None,
second: real,
spare: Some(generated),
interval: self.interval,
}
};
let _ = send.send(pair);
}
}
/// A frame to draw into, like the swapchain's.
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation frame"),
size: wgpu::Extent3d {
width: config.width,
height: config.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &config.view_formats,
})
}
/// One real frame's frames for the present thread: `first` (the made frame) now,
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
struct Pair {
first: Option<wgpu::Texture>,
second: wgpu::Texture,
spare: Option<wgpu::Texture>,
interval: Duration,
}
/// The thread that copies frames to the swapchain and presents them on time.
struct Presenter {
config: wgpu::SurfaceConfiguration,
send: Option<mpsc::SyncSender<Pair>>,
free: mpsc::Receiver<wgpu::Texture>,
/// The surface gave something other than a frame: the renderer takes it back.
failed: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Presenter {
fn start(
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Arc<wgpu::Surface<'static>>,
window: Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Self {
// One pair waiting at most: the renderer then waits as for a swapchain image.
let (send, receive) = mpsc::sync_channel::<Pair>(1);
let (give, free) = mpsc::channel();
for _ in 0..FRAMES {
let _ = give.send(frame(device, config));
}
let (device, queue) = (device.clone(), queue.clone());
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
let failed = Arc::new(AtomicBool::new(false));
let failed_here = failed.clone();
let thread = thread::Builder::new()
.name("Frame generation present".into())
.spawn(move || {
let show = |texture: &wgpu::Texture| {
if failed_here.load(Ordering::Relaxed) {
return;
}
// Anything but a frame (outdated, lost, occluded...): the renderer
// stops this thread and acquires itself, handling it as it does
// without frame generation. A suboptimal frame still shows.
let target = match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
failed_here.store(true, Ordering::Relaxed);
t
}
_ => {
failed_here.store(true, Ordering::Relaxed);
return;
}
};
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_texture(
texture.as_image_copy(),
target.texture.as_image_copy(),
target.texture.size(),
);
queue.submit([encoder.finish()]);
if vsync {
window.pre_present_notify();
}
queue.present(target);
};
while let Ok(pair) = receive.recv() {
if let Some(first) = pair.first {
let shown = Instant::now();
show(&first);
let _ = give.send(first);
// ponytail: sleep pacing; a spin for the last half millisecond
// if it shows uneven on some systems.
let due = shown + pair.interval / 2;
if let Some(wait) = due.checked_duration_since(Instant::now()) {
thread::sleep(wait);
}
}
show(&pair.second);
let _ = give.send(pair.second);
if let Some(spare) = pair.spare {
let _ = give.send(spare);
}
}
})
.expect("spawn the frame generation present thread");
Self {
config: config.clone(),
send: Some(send),
free,
failed,
thread: Some(thread),
}
}
/// The surface failed it or the thread died (a panic).
fn failed(&self) -> bool {
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
impl Drop for Presenter {
fn drop(&mut self) {
self.send = None;
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::Vertex;
use glam::Vec3;
fn vertex(pos: Vec3) -> Vertex {
Vertex {
pos: pos.into(),
..bytemuck::Zeroable::zeroed()
}
}
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
/// another item shows as it is.
#[test]
fn the_made_frame_s_hand_is_halfway_on_screen() {
let mut fg = FrameGen::new();
let mut out = Vec::new();
fg.halfway_hand(
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
fg.halfway_hand(
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
// The camera turned: the vertex turned with it, the same on screen.
let turned = Mat4::from_rotation_y(0.3);
let on_screen = Vec3::new(0.4, 0.0, -1.0);
let pos = turned.inverse().transform_point3(on_screen);
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
let mut other = vertex(Vec3::ZERO);
other.uv = [0.5, 0.5];
fg.halfway_hand(&[other], turned, &mut out);
assert_eq!(out[3].pos, [0.0; 3]);
}
}
@@ -10,8 +10,8 @@
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
@@ -442,6 +445,7 @@ impl Interpolation {
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
@@ -644,10 +648,12 @@ mod tests {
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
@@ -682,6 +688,35 @@ mod tests {
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
@@ -731,6 +766,7 @@ mod tests {
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
@@ -776,7 +812,8 @@ mod tests {
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
@@ -795,7 +832,26 @@ mod tests {
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
@@ -24,7 +24,8 @@
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
@@ -85,6 +86,8 @@ struct Params {
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
+17 -4
View File
@@ -366,7 +366,11 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -387,6 +391,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
color_attachments: &attachments,
..Default::default()
});
@@ -397,7 +402,11 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -413,6 +422,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
color_attachments: &attachments,
..Default::default()
});
@@ -434,6 +444,7 @@ impl Hdr {
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
@@ -443,7 +454,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -494,7 +505,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
Some(texture)
}
@@ -528,6 +539,7 @@ impl Hdr {
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -541,6 +553,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
color_attachments: &attachments,
..Default::default()
});
+1 -1
View File
@@ -1,4 +1,4 @@
//! Far meshes share the terrain arena and passes, with their actual bounds
//! Far meshes share the terrain arenas and passes, with their actual bounds
//! rather than the 16³ boxes used by the near-section residency walk.
use super::{Frustum, Renderer, SectionGpu};
use crate::{
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -34,7 +42,7 @@ struct HorizonMaterialOut {
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
@@ -79,26 +87,89 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
/ f32(textureDimensions(horizon_pbr_normals).x);
let l = max(lod - log2(scale), 0.0);
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
}
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
}
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let s = horizon_terrain_sprite_sampler;
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
switch layer / horizon_terrain_SPRITE_SPLIT {
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
}
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the texture: its texels, as the sampler would pick.
let texels = in.uv * horizon_block_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
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;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
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);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -115,6 +186,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -123,6 +204,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -134,12 +217,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -150,18 +235,60 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// Where a corner of terrain is drawn without a vertex stage.
pub(super) const DISPLACE_VANILLA: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
return pos;
}
";
/// The vertex stage's call, with its inputs from terrain's vertex.
pub(super) const DISPLACE_PACK: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`, and the vertex stage in its module when `vertex`.
fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
if vertex {
DISPLACE_PACK
} else {
DISPLACE_VANILLA
},
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -174,14 +301,18 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
vertex: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -220,6 +351,18 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -245,11 +388,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
}),
multiview_mask: None,
cache: None,
@@ -294,6 +437,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -346,14 +490,20 @@ mod tests {
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -14,6 +14,11 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
@@ -27,20 +32,33 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref());
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|_| NativeShadow::new(device, terrain, &frame))
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
NativeShadow::new(
device,
terrain,
&frame,
&s.solid_materials,
vertex,
constants,
)
})
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
@@ -55,6 +73,35 @@ impl NativePack {
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -67,10 +114,12 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| {
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -79,6 +128,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -87,6 +137,10 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
@@ -95,6 +149,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
@@ -14,11 +14,61 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
enum Writes {
/// One of the chain's two targets.
Chain(usize),
/// A pack target (`[[post]] output`).
Target(usize),
/// The screen: the last pass.
Screen,
}
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
/// none).
#[derive(Clone, Copy)]
struct PassPlan {
writes: Writes,
reads: Option<usize>,
}
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
/// output writes that and reads the chain's picture, which goes on past it.
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
let mut current = None;
outputs
.iter()
.enumerate()
.map(|(i, output)| {
let reads = current;
let writes = match output {
_ if i + 1 == outputs.len() => Writes::Screen,
Some(t) => Writes::Target(*t),
None => {
let slot = usize::from(current == Some(0));
current = Some(slot);
Writes::Chain(slot)
}
};
PassPlan { writes, reads }
})
.collect()
}
/// A pack target's size: `scale` of the world's `size`, at least a texel.
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
}
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
/// The pack's textures.
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
@@ -40,6 +90,12 @@ pub struct NativePost {
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// What each pass writes and reads (`[[post]] output`).
plan: Vec<PassPlan>,
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
/// size.
scales: Vec<f32>,
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -47,9 +103,16 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
/// The pack's `[[textures]]` and their samplers, bound for every stage.
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
pub block_light: super::block_light::BlockLight,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
@@ -57,6 +120,9 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
@@ -151,10 +217,14 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any.
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -162,20 +232,49 @@ impl NativeFrame {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries = vec![
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
pbr_entry(12),
pbr_entry(13),
];
let block_light_entries = [
wgpu::BindGroupLayoutEntry {
binding: 9,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 10,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
entries.extend(block_light_entries);
entries.extend(pack_texture_entries(textures.len()));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -192,19 +291,54 @@ impl NativeFrame {
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
})
};
let pbr = match pbr {
Some(maps) => maps.each_ref().map(array_view),
None => {
// Two layers: one would make a 2D texture on the OpenGL backend.
let blank = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack LabPBR (none)"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
[array_view(&blank), array_view(&blank)]
}
};
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&shadow_map,
&opaque_depth,
&opaque_color,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -241,54 +375,74 @@ impl NativeFrame {
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
shadow: &super::native_shadow::ShadowMap,
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
textures: &[(wgpu::TextureView, wgpu::Sampler)],
block_light: &super::block_light::BlockLight,
pbr: &[wgpu::TextureView; 2],
) -> wgpu::BindGroup {
let opaque_depth = opaque_depth.create_view(&Default::default());
let opaque_color = opaque_color.create_view(&Default::default());
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&opaque_depth),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(&opaque_color),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&pbr[0]),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::TextureView(&pbr[1]),
},
];
entries.extend([
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&block_light.view),
},
wgpu::BindGroupEntry {
binding: 10,
resource: block_light.grid.as_entire_binding(),
},
]);
entries.extend(pack_texture_group_entries(textures));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -321,6 +475,14 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -338,8 +500,10 @@ impl NativeFrame {
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.shadow_map,
&self.opaque_depth,
&self.opaque_color,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -424,6 +588,24 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -437,6 +619,20 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -461,26 +657,44 @@ impl NativePost {
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
manifest: &horizon_shader_api::Manifest,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
.post
.iter()
.map(|p| {
let name = p.output.as_deref()?;
targets.iter().position(|t| t.0 == name)
})
.collect();
let plan = plan(&outputs);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut entries = vec![
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -519,7 +733,11 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
format: if plan[i].writes == Writes::Screen {
format
} else {
INTERMEDIATE
},
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -543,6 +761,7 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
@@ -557,6 +776,9 @@ impl NativePost {
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
scales: targets.iter().map(|t| t.1).collect(),
named: None,
plan,
format,
})
}
@@ -569,11 +791,43 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
.iter()
.map(|&scale| {
let size = scaled(size, scale);
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack target"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
})
.collect();
self.named = Some((size, views));
self.groups = None;
}
let chain = self
.plan
.iter()
.any(|p| matches!(p.writes, Writes::Chain(_)));
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -610,8 +864,11 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
})
{
return;
@@ -624,66 +881,91 @@ impl NativePost {
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
],
entries: &entries,
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -692,7 +974,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -706,8 +988,9 @@ impl NativePost {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -729,14 +1012,17 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
.map(|p| p.writes)
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
targets[slot].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
@@ -871,8 +1157,125 @@ pub fn frame_inputs(
}
}
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
pub fn pack_textures(
device: &wgpu::Device,
queue: &wgpu::Queue,
report: &horizon_shader_api::Report,
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
report
.textures
.iter()
.map(|(t, image)| {
let texture = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: Some(&t.id),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&image.rgba,
);
let filter = if t.linear {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
};
let address = if t.repeat {
wgpu::AddressMode::Repeat
} else {
wgpu::AddressMode::ClampToEdge
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&t.id),
address_mode_u: address,
address_mode_v: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
});
(texture.create_view(&Default::default()), sampler)
})
.collect()
}
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_SAMPLER_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
]
})
.collect()
}
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
.flat_map(|(i, (view, sampler))| {
[
wgpu::BindGroupEntry {
binding: TEXTURE_BINDING + i as u32,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: TEXTURE_SAMPLER_BINDING + i as u32,
resource: wgpu::BindingResource::Sampler(sampler),
},
]
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
use super::{Writes, plan};
// Bloom into a target, a pass of the chain, a second target, the picture.
let p = plan(&[Some(0), None, Some(1), None, None]);
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
assert!(writes[0] == Writes::Target(0));
assert!(writes[1] == Writes::Chain(0));
assert!(writes[2] == Writes::Target(1));
assert!(writes[3] == Writes::Chain(1));
assert!(writes[4] == Writes::Screen);
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
}
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
@@ -934,7 +1337,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -943,6 +1346,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -990,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -1053,8 +1457,16 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
@@ -1089,7 +1501,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
history_reset,
@@ -6,6 +6,19 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
@@ -1,5 +1,5 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
@@ -41,22 +41,26 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@@ -67,6 +71,40 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -191,14 +229,36 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
@@ -213,6 +273,7 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
@@ -221,6 +282,7 @@ impl ShadowMap {
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
@@ -245,8 +307,8 @@ impl ShadowMap {
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
}
}
}
@@ -261,16 +323,120 @@ pub struct NativeShadow {
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries.
fn shader_source() -> String {
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
super::native_material::DISPLACE_PACK,
),
None => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
@@ -283,14 +449,22 @@ impl NativeShadow {
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
) -> Result<Self, String> {
let text = shader_source();
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
let mut constants = super::native_post::present_constants(&module, constants);
constants.extend([
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
]);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
@@ -420,6 +594,7 @@ impl NativeShadow {
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
@@ -428,6 +603,7 @@ impl NativeShadow {
flat,
translucent,
flat_translucent,
entity,
group,
})
}
@@ -439,7 +615,29 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
let text = shader_source();
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -454,6 +652,7 @@ mod tests {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
@@ -474,6 +673,7 @@ mod tests {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
+15 -3
View File
@@ -620,6 +620,7 @@ impl Oit {
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
@@ -645,7 +646,7 @@ impl Oit {
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
@@ -680,6 +681,7 @@ impl Oit {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
@@ -692,6 +694,7 @@ impl Oit {
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
@@ -710,7 +713,7 @@ impl Oit {
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
@@ -720,16 +723,18 @@ impl Oit {
depth,
(label, group, &then),
(clouds, cloud_depth),
timing.as_deref_mut(),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -749,6 +754,7 @@ impl Oit {
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
#[allow(clippy::too_many_arguments)]
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -764,6 +770,7 @@ impl Oit {
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
@@ -774,6 +781,7 @@ impl Oit {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
@@ -798,6 +806,7 @@ impl Oit {
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(terrain) = &t.terrain_depth else {
return;
@@ -810,6 +819,7 @@ impl Oit {
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
@@ -840,6 +850,7 @@ impl Oit {
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
@@ -852,6 +863,7 @@ impl Oit {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
@@ -217,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -3117,8 +3117,7 @@ impl PackPost {
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
self.shadow_targets.as_ref()?;
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
if !self.has_shadow() {
return None;
}
let shadow = &self.uniforms.shadow;
@@ -3139,6 +3138,12 @@ impl PackPost {
self.terrain.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
pub fn has_shadow(&self) -> bool {
self.shadow_targets.is_some()
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
}
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
/// What the world's background is when the pack draws the sky: colortex0's clear colour
/// (`None`: the fog colour, its default).
@@ -1,5 +1,6 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
@@ -139,6 +140,12 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
}
}
/// The compute pass's twin of `stamps`.
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
wgpu::ComputePassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
@@ -35,10 +35,35 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
pub right_handed: bool,
pub on_ground: bool,
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
/// logical height, sky light and ceiling.
pub min_y: i32,
pub height: i32,
pub logical_height: i32,
pub has_skylight: bool,
pub has_ceiling: bool,
/// `isElytraFlying`: the player is fall flying.
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
/// has alpha (`LevelExtractor`); a dimension without clouds has no height, which the uniform
/// reports as NaN. NaN is not vanilla's own value: packs map it to "no clouds".
pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
if crate::clouds::has_color(cloud_color_alpha) {
height
} else {
f32::NAN
}
}
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -173,6 +198,15 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
"heightLimit",
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"isElytraFlying",
"cloudHeight",
];
/// Values carried from frame to frame.
@@ -380,6 +414,13 @@ impl FrameUniforms {
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
"bedrockLevel" => Value::i(&[inputs.min_y]),
"heightLimit" => Value::i(&[inputs.height]),
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -439,6 +480,8 @@ impl FrameUniforms {
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
@@ -533,6 +576,25 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
assert_eq!(cloud_height(0.8, 120.0), 120.0);
assert!(cloud_height(0.0, 192.33).is_nan());
}
#[test]
fn elytra_and_cloud_height_uniforms_are_written() {
assert!(NAMES.ends_with(&["isElytraFlying", "cloudHeight"]));
let mut out = vec![0u8; 16];
Value::i(&[1]).write("bool", &mut out, 0);
assert_eq!(i32::from_ne_bytes(out[0..4].try_into().unwrap()), 1);
Value::f(&[192.33]).write("float", &mut out, 4);
assert_eq!(f32::from_ne_bytes(out[4..8].try_into().unwrap()), 192.33);
Value::f(&[f32::NAN]).write("float", &mut out, 8);
assert!(f32::from_ne_bytes(out[8..12].try_into().unwrap()).is_nan());
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
@@ -0,0 +1,228 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
}
self.pending.store(false, Ordering::Release);
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
+169 -2
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -84,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -138,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
@@ -299,6 +299,16 @@ impl Post {
Mat4::from_translation(jitter.extend(0.0))
}
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
/// without TAA.
pub fn jitter_pixels(&self) -> [f32; 2] {
let Some(size) = self.size().filter(|_| self.taa_on) else {
return [0.0; 2];
};
let j = self.taa_this_frame.jitter;
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
@@ -437,14 +447,15 @@ impl Post {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
@@ -460,6 +471,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
@@ -467,7 +479,7 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
self.finish_with(encoder, screen, &taa.show[i], timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -476,6 +488,7 @@ impl Post {
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -488,6 +501,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
timestamp_writes: timing.and_then(|t| t.writes("Post")),
color_attachments: &attachments,
..Default::default()
});
@@ -503,7 +517,12 @@ impl Post {
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
pub(super) fn reprojection(
previous: DMat4,
previous_camera: DVec3,
current: DMat4,
camera: DVec3,
) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
+328 -8
View File
@@ -79,6 +79,47 @@ impl Preset {
}
}
/// Entity occlusion strategy. The synchronous mode preserves the original
/// same-frame CPU test; asynchronous results only hide a mesh when their
/// camera, bounds and occluder evidence still match.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum EntityCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl EntityCullingMode {
pub const ALL: [EntityCullingMode; 3] = [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
// Keep graphics files written before the mode selector readable.
"true" | "synchronous" | "sync" => Self::Synchronous,
"false" | "off" => Self::Off,
"asynchronous" | "async" => Self::Asynchronous,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -246,6 +287,91 @@ impl FarDetail {
}
}
/// Frame generation: a frame made between each two real ones.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FrameGeneration {
#[default]
Off,
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
Fsr31,
}
impl FrameGeneration {
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
pub fn name(self) -> &'static str {
match self {
FrameGeneration::Off => "off",
FrameGeneration::Fsr31 => "fsr3.1",
}
}
pub fn label(self) -> &'static str {
match self {
FrameGeneration::Off => "Off",
FrameGeneration::Fsr31 => "FSR 3.1",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|f| f.name() == s)
.unwrap_or_default()
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -325,6 +451,19 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -348,6 +487,46 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -358,16 +537,26 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -383,6 +572,8 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
pub frame_generation: FrameGeneration,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -409,6 +600,11 @@ pub struct Graphics {
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -421,13 +617,16 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: false,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -437,6 +636,7 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -446,6 +646,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -487,13 +688,18 @@ impl Graphics {
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -512,6 +718,7 @@ impl Graphics {
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -532,6 +739,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -548,18 +756,21 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.entity_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -576,6 +787,7 @@ impl Graphics {
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -586,6 +798,7 @@ impl Graphics {
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
@@ -706,13 +919,16 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: true,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -722,6 +938,7 @@ mod tests {
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -731,10 +948,28 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
);
assert_eq!(
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
@@ -761,11 +996,96 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
+148 -20
View File
@@ -2,7 +2,10 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -27,12 +30,14 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -41,6 +46,7 @@ pub enum H {
Antialiasing,
Upscaling,
ScreenshotQuality,
FrameGeneration,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -186,6 +192,12 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -194,12 +206,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Quality"),
v(
@@ -261,6 +273,11 @@ pub fn rows(level: Level) -> Vec<Row> {
Low,
"Animates water, lava, fire and other moving textures.",
),
h(
H::DitherCutoutMips,
Low,
"Keeps fine plants in high-res packs from fading far away.",
),
Row::Header("Display"),
fov,
fps,
@@ -285,6 +302,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::FrameGeneration,
Medium,
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
),
h(
H::HdrSettings,
None,
@@ -440,6 +462,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -509,7 +541,8 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -525,6 +558,7 @@ impl H {
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
@@ -553,6 +587,7 @@ impl H {
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -583,7 +618,7 @@ impl H {
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
@@ -641,11 +676,17 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
H::Antialiasing => {
@@ -670,6 +711,9 @@ impl H {
back,
)
}
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -728,11 +772,11 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
@@ -795,6 +839,7 @@ impl Item {
| H::Pacing
| H::PresentMode
| H::Workers
| H::FrameGeneration
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
@@ -1030,9 +1075,12 @@ impl GraphicsUi {
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
label = Text::translate(
"Shortcut: %s",
&[Text::literal("> ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <")],
);
}
let blocked = item.blocked(s);
let live = blocked.is_none();
@@ -1817,16 +1865,92 @@ mod tests {
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
for enabled in [false, true] {
s.horizon.entity_culling = enabled;
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, enabled);
assert_eq!(s.horizon.entity_culling, mode);
}
}
@@ -1838,7 +1962,11 @@ mod tests {
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for enabled in [false, true] {
for mode in [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
@@ -1852,10 +1980,10 @@ mod tests {
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
#[test]
@@ -18,6 +18,7 @@
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
text::Text,
ui,
@@ -64,14 +65,19 @@ impl Step {
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
/// The English name, which is also its key in the language table.
fn name_key(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
fn name(self) -> String {
lang::get(self.name_key())
}
/// The English hint, which is also its key in the language table.
fn hint_key(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
@@ -139,10 +145,16 @@ impl Calibration {
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
let step = self.current();
let nits = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -160,14 +172,12 @@ impl Calibration {
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
let title = Text::literal(lang::tr(
"HDR Calibration %s/%s: %s",
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
@@ -192,10 +202,12 @@ impl Calibration {
self.dragging,
);
let last = self.step + 1 == STEPS.len();
let save = lang::get_or("selectWorld.edit.save", "Save");
let next = lang::get("Next");
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
+148 -28
View File
@@ -83,6 +83,8 @@ pub struct HudAtlas {
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -118,6 +120,7 @@ pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("image too large")?];
@@ -212,6 +215,28 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
];
fn walked_minecraft_texture(name: &str) -> bool {
WALKED_TEXTURE_FOLDERS
.iter()
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id,
@@ -239,11 +264,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
if have.contains(name.as_str()) {
continue;
}
// A vanilla file is packed under its own name already: only a
// renamed one is new. Other namespaces' files (content mods) are
// never in the walked directories.
// A file under a walked folder is packed under its own name already:
// only a renamed one is new. Other namespaces' files (content mods)
// are never in those folders, nor are extra directories a pack adds
// to the blocks atlas (`custom/...`).
if let SpriteOrigin::File(texture) = &origin
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
&& walked_minecraft_texture(name.as_str())
{
continue;
}
@@ -346,24 +373,7 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(
root,
defaults,
&[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
],
true,
)?;
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
@@ -479,6 +489,7 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
random_textures: Default::default(),
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -544,6 +555,17 @@ impl HudAtlas {
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
@@ -556,17 +578,49 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
let random_textures = if folders.contains(&"entity") {
horizon_optifine::random_textures::RandomTextures::scan(root)
} else {
Default::default()
};
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
@@ -760,6 +814,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
@@ -2000,9 +2055,11 @@ impl HudRenderer {
batch,
size,
0..batch.vertices.len() as u32,
None,
);
}
/// Draw part of the prepared batch, preserving its blend/depth runs.
#[allow(clippy::too_many_arguments)]
pub fn draw_range(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -2011,6 +2068,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
vertices: std::ops::Range<u32>,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
if vertices.is_empty() || size.contains(&0) {
return;
@@ -2041,6 +2099,7 @@ impl HudRenderer {
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -2100,6 +2159,40 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn decode_bytes_ignores_crc_checksum_error() {
let mut encoded = Vec::new();
{
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
])
.unwrap();
writer.finish().unwrap();
}
let idat_pos = encoded
.windows(4)
.position(|w| w == b"IDAT")
.expect("IDAT chunk");
let len = u32::from_be_bytes([
encoded[idat_pos - 4],
encoded[idat_pos - 3],
encoded[idat_pos - 2],
encoded[idat_pos - 1],
]) as usize;
let crc_pos = idat_pos + 4 + len;
encoded[crc_pos] ^= 0xFF;
let (w, h, rgba) = decode_bytes(&encoded).expect("should decode despite bad CRC");
assert_eq!(w, 2);
assert_eq!(h, 2);
assert_eq!(rgba.len(), 16);
}
#[test]
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
let mut batch = HudBatch::new([320, 240]);
@@ -2312,6 +2405,33 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn a_pack_directory_outside_the_walked_folders_still_packs() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("blocks.json"),
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
)
.unwrap();
write_strip(
&custom.join("test.png"),
16,
16,
&[[40, 180, 80, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["custom/test"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
+3
View File
@@ -8,6 +8,7 @@ pub mod auth;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_render;
@@ -18,9 +19,11 @@ pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod crash;
pub mod datapack;
+60 -13
View File
@@ -592,18 +592,21 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
changed.keyed(chunk.layout());
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
&& a == b
{
continue;
}
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
let classes = match (old, &light) {
(Light::Uniform(a), Light::Uniform(b)) if a == b => continue,
// (most sections of a chunk relit for a neighbour's edit keep their light: one comparison of the whole)
(Light::Full(a), Light::Full(b)) => LightChanges::classes_differing(a, b),
_ => {
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
}
}
classes
}
}
};
if classes != 0 {
changed.add((chunk.x, sy as i32, chunk.z), classes);
Arc::make_mut(section).light = light;
@@ -847,7 +850,7 @@ pub struct LightChanges {
impl LightChanges {
/// Changes in chunks of `layout`: what the relighter or a light job adds
/// after this (they all come from one level).
fn keyed(&mut self, layout: ColumnLayout) {
pub fn keyed(&mut self, layout: ColumnLayout) {
debug_assert!(self.sections.is_empty() || self.layout == layout);
self.layout = layout;
}
@@ -858,7 +861,7 @@ impl LightChanges {
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
}
/// Cells of section `key` (x, section index, z) in the given classes.
fn add(&mut self, key: Pos, classes: u32) {
pub fn add(&mut self, key: Pos, classes: u32) {
*self.sections.entry(key).or_default() |= classes;
}
/// The class of a cell in its section: per axis 0 on the low face, 1
@@ -871,6 +874,25 @@ impl LightChanges {
};
axis(x) + 3 * axis(y) + 9 * axis(z)
}
/// The classes (bit `class`) of the cells, in a section's index order (`y << 8 | z << 4 | x`), where `a` and
/// `b` differ.
pub fn classes_differing<T: PartialEq>(a: &[T; 4096], b: &[T; 4096]) -> u32 {
let mut classes = 0;
// (a row of 16 along x at a time: the rows alike, most of them, cost one comparison)
let (rows_a, rows_b) = (a.as_chunks::<16>().0, b.as_chunks::<16>().0);
for (row, (ra, rb)) in rows_a.iter().zip(rows_b).enumerate() {
if ra == rb {
continue;
}
let (y, z) = ((row >> 4) as i32, (row & 15) as i32);
for x in 0..16 {
if ra[x] != rb[x] {
classes |= 1 << Self::class(x as i32, y, z);
}
}
}
classes
}
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
@@ -1156,6 +1178,31 @@ mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
#[test]
fn changed_cells_dirty_only_the_sections_within_a_block() {
let dirty = |cells: &[(usize, usize, usize)]| {
let a = [0u8; 4096];
let mut b = a;
for &(x, y, z) in cells {
b[y << 8 | z << 4 | x] = 1;
}
let mut changes = LightChanges::default();
changes.add((0, 5, 0), LightChanges::classes_differing(&a, &b));
let mut out = Vec::new();
changes.dirty_sections(|p| out.push(p));
out.sort_unstable();
out
};
assert_eq!(dirty(&[]), Vec::<Pos>::new());
assert_eq!(dirty(&[(5, 5, 5), (9, 1, 14)]), vec![(0, 5, 0)]);
assert_eq!(dirty(&[(0, 5, 5)]), vec![(-1, 5, 0), (0, 5, 0)]);
assert_eq!(
dirty(&[(15, 15, 5)]),
vec![(0, 5, 0), (0, 6, 0), (1, 5, 0), (1, 6, 0)]
);
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
+277 -25
View File
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -494,7 +497,20 @@ struct Plan {
/// selection is rebuilt, without sorting the whole set.
by_level: [Vec<TileKey>; MAX_LEVEL as usize + 1],
elevation: i32,
camera: [i32; 2],
/// The squared distance within which no coarser tile stands in for
/// missing detail (`stands_in`): the seam with the near chunks waits
/// for its own tiles instead of showing broad boxes beside them.
near: i64,
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
/// load first.
const SEAM_CHUNKS: i32 = 3;
const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
@@ -518,6 +534,9 @@ impl Plan {
let size = 16 << level;
let mut result = Self {
elevation,
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -589,7 +608,7 @@ impl Plan {
if complete {
return true;
}
if ready.contains_key(&key) {
if ready.contains_key(&key) && self.stands_in(key) {
// Its mesh is clipped around every selected finer tile.
out.push(key);
return true;
@@ -602,6 +621,26 @@ impl Plan {
false
}
}
/// Whether a split tile may draw for its missing children: only away
/// from the near chunks, where its cells are small on screen.
fn stands_in(&self, key: TileKey) -> bool {
key.reach(self.camera) >= self.near
}
/// The coarse tile sampled to cover `leaf` while it loads: two levels
/// up (16 times fewer tiles, each as cheap as a leaf to sample), one
/// where that would reach the seam, none beside it.
fn backdrop(&self, leaf: TileKey) -> Option<TileKey> {
let mut backdrop = None;
let mut key = leaf;
for _ in 0..2 {
key = key.parent();
if !self.nodes.contains(&key) || !self.stands_in(key) {
break;
}
backdrop = Some(key);
}
backdrop
}
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
/// at level 0, also `filled`) rather than by a coarser ancestor standing
/// in for it, or is past the far terrain.
@@ -625,6 +664,47 @@ impl Plan {
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
}
}
/// The tiles to sample, `Wanted`'s order best first. First the planned
/// tiles at the seam with the near chunks, nearest first: coarse tiles
/// there cost a far tile's work for broad boxes that fine tiles replace
/// moments later. Then a coarse backdrop (`Plan::backdrop`) out to the
/// horizon, then every other planned tile nearest first, each replacing its
/// part of the backdrop: the horizon fills in early and sharpens from the
/// player outward, whichever way they look.
fn wanted_tiles(
plan: &Plan,
ready: &FxHashMap<TileKey, impl Sized>,
camera: [i32; 2],
) -> impl Iterator<Item = Wanted> {
let wanted = |class, key: TileKey| Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
};
let mut backdrop = FxHashSet::default();
let mut tiles: Vec<_> = plan
.nodes
.iter()
.filter(|key| !plan.split.contains(key) && !ready.contains_key(key))
.map(|&key| {
if let Some(coarse) = plan.backdrop(key)
&& !ready.contains_key(&coarse)
{
backdrop.insert(coarse);
}
let first = key.reach(camera) < plan.first;
wanted(if first { SEAM } else { REST }, key)
})
.collect();
tiles.extend(backdrop.into_iter().map(|key| wanted(BACKDROP, key)));
tiles.into_iter()
}
/// `Wanted::class`es, best first.
const SEAM: u8 = 0;
const BACKDROP: u8 = 1;
const REST: u8 = 2;
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
@@ -791,9 +871,11 @@ impl Ord for MeshQueued {
.then(other.order.cmp(&self.order))
}
}
/// A tile the sampler should produce next, nearest first.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
/// A tile the sampler should produce next: by `class`
/// (`wanted_tiles`), then nearest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Wanted {
class: u8,
distance: i64,
x: i32,
z: i32,
@@ -843,6 +925,8 @@ pub struct Lod {
near_loading: bool,
/// When `request_samples` last started a job while held.
held_sample_at: std::time::Instant,
/// Tiles whose sampling failed, and when they may be asked again.
retry: FxHashMap<TileKey, std::time::Instant>,
/// Each tile's samples, shared with mesh jobs in flight.
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
/// Fully covered subtrees, by tile.
@@ -1179,6 +1263,7 @@ impl Lod {
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
@@ -1411,14 +1496,15 @@ impl Lod {
let present = self.ready.contains_key(&key);
// Covered level-0 stand-ins complete their parent but are never drawn.
let ready = present && !(key.level == 0 && self.covered.contains(&(key.x, key.z)));
// Mirrors `Plan::select`: a ready split tile is a complete
// stand-in even while its children are still incomplete, so no
// second fallback is drawn above it.
// Mirrors `Plan::select`: a ready split tile away from the seam is a
// complete stand-in even while its children are still incomplete,
// so no second fallback is drawn above it.
let (complete, active) = if self.plan.split.contains(&key) {
let children = key.children().into_iter().all(|child| {
!self.plan.nodes.contains(&child)
|| self.complete.get(&child).copied().unwrap_or(false)
});
let ready = ready && self.plan.stands_in(key);
(children || ready, ready && !children)
} else {
(present, ready)
@@ -1609,7 +1695,16 @@ impl Lod {
}
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
self.pending.remove(&key);
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
if samples.len() != CELLS * CELLS {
let now = std::time::Instant::now();
if self.retry.len() > 4096 {
self.retry.retain(|_, at| *at > now);
}
self.retry.insert(key, now + RETRY);
return;
}
self.retry.remove(&key);
if self.plan.nodes.contains(&key) {
let mut samples = samples;
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
for i in 0..256 {
@@ -2161,8 +2256,16 @@ impl Lod {
if free == 0 {
return;
}
let now = std::time::Instant::now();
if self.held() {
self.held_sample_at = std::time::Instant::now();
self.held_sample_at = now;
}
// A tile whose sampling failed waits out `RETRY`; once it has, the
// queue is rebuilt so the tile is asked again.
let retrying = self.retry.len();
self.retry.retain(|_, at| *at > now);
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// The queue is rebuilt when the view moves a chunk, not scanned and
// sorted on every frame.
@@ -2183,12 +2286,12 @@ impl Lod {
z: wanted.z,
level: wanted.level,
};
// Final tiles only: a coarse tile under the near area is work
// that finer tiles replace moments later.
// Final tiles and the backdrop only (`wanted_tiles`).
if self.pending.contains(&key)
|| self.ready.contains_key(&key)
|| self.plan.split.contains(&key)
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
{
continue;
}
@@ -2203,17 +2306,19 @@ impl Lod {
}
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
self.wanted.clear();
for &key in &self.plan.nodes {
if self.plan.split.contains(&key) || self.ready.contains_key(&key) {
continue;
}
self.wanted.push(std::cmp::Reverse(Wanted {
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
}));
}
// A failed tile waits out its retry delay (`retry`) before it is asked again.
let retry = &self.retry;
self.wanted.extend(
wanted_tiles(&self.plan, &self.ready, camera)
.filter(|w| {
!retry.contains_key(&TileKey {
x: w.x,
z: w.z,
level: w.level,
})
})
.map(std::cmp::Reverse),
);
}
#[cfg(test)]
@@ -2607,14 +2712,14 @@ mod tests {
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
};
// 24 chunks out, now past the render distance: its planned tile was
// split before, so never sampled, and a root stands in for it.
// split before, so never sampled. The ready root reaches the seam, so
// it stands in for nothing: the column stays until its tile is in.
let column = (24, 0);
let mut selected = Vec::new();
for &root in &after.roots {
after.select(root, &ready, &mut selected);
}
let drawn = selected.iter().filter(|key| contains(key, column));
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
assert!(!selected.iter().any(|key| contains(key, column)));
assert!(!after.replaces(column, &ready, |_| true));
let planned = TileKey {
x: 12,
@@ -3258,6 +3363,153 @@ mod tests {
Ok(())
}
#[test]
fn far_cells_past_render_distance_12_start_one_block_wide() {
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
// keeps 1 to the near terrain's edge and 2 to 256.
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
assert_eq!(key.size() / 16, 1, "{key:?} reduced {reduced}");
} else if distance < two {
assert!(key.size() / 16 <= 2, "{key:?} reduced {reduced}");
}
}
}
}
/// Loads a render distance 12 view one sample job at a time, in
/// `wanted_tiles`' order. Counts the jobs, and their density lattice
/// points (each job's cost), before every far column within two chunks
/// of the near terrain is drawn as finely as the plan wants it (one
/// block wide in Full): until then nothing is drawn there. And before
/// every far column is drawn, by its tile or a coarse stand-in: until
/// then the far terrain ends short of the horizon. Checks that no coarse
/// tile is ever drawn at the seam.
fn jobs_until_fine_seam(camera: [i32; 2], reduced: bool) -> ([(usize, i32); 2], usize) {
let (detail, distance) = (12, 128);
let center = (camera[0].div_euclid(16), camera[1].div_euclid(16));
let mut covered = FxHashSet::default();
for z in center.1 - detail..=center.1 + detail {
for x in center.0 - detail..=center.0 + detail {
if crate::streaming::in_view_distance(center, detail, x, z) {
covered.insert((x, z));
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
.find(|key| plan.nodes.contains(key) && !plan.split.contains(key))
};
let mut seam = Vec::new();
for z in (camera[1] - 256..camera[1] + 256).step_by(2) {
for x in (camera[0] - 256..camera[0] + 256).step_by(2) {
let chunk = (x.div_euclid(16), z.div_euclid(16));
let near = (-2..=2)
.any(|dz| (-2..=2).any(|dx| covered.contains(&(chunk.0 + dx, chunk.1 + dz))));
if !covered.contains(&chunk) && near {
seam.push((x, z, leaf(x, z).unwrap()));
}
}
}
let mut horizon = Vec::new();
for z in (camera[1] - 2048..camera[1] + 2048).step_by(64) {
for x in (camera[0] - 2048..camera[0] + 2048).step_by(64) {
if !covered.contains(&(x.div_euclid(16), z.div_euclid(16))) && leaf(x, z).is_some()
{
horizon.push((x, z));
}
}
}
let mut ready: FxHashMap<TileKey, ()> = FxHashMap::default();
for key in &plan.nodes {
if key.level == 0 && covered.contains(&(key.x, key.z)) {
ready.insert(*key, ());
}
}
let leaves = plan
.nodes
.iter()
.filter(|k| !plan.split.contains(k))
.count();
let (mut cost, mut fine, mut reached) = (0, None, None);
for jobs in 0.. {
let mut selected = Vec::new();
for &root in &plan.roots {
plan.select(root, &ready, &mut selected);
}
let active: FxHashSet<_> = selected.into_iter().collect();
let drawn = |x: i32, z: i32, levels: u8| {
(0..=levels).any(|level| {
active.contains(&TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
})
};
for &(x, z, leaf) in &seam {
assert!(drawn(x, z, leaf.level) || !drawn(x, z, MAX_LEVEL));
}
if fine.is_none() && seam.iter().all(|&(x, z, leaf)| drawn(x, z, leaf.level)) {
fine = Some((jobs, cost));
}
if reached.is_none() && horizon.iter().all(|&(x, z)| drawn(x, z, MAX_LEVEL)) {
reached = Some((jobs, cost));
}
if let (Some(fine), Some(reached)) = (fine, reached) {
return ([fine, reached], leaves);
}
let next = wanted_tiles(&plan, &ready, camera).min().unwrap();
let next = TileKey {
x: next.x,
z: next.z,
level: next.level,
};
// `lod_grid`'s density lattice: one point per max(stride, 4) blocks.
let stride = next.size() / 16;
let lattice = 16 * stride / stride.max(4) + 1;
cost += lattice * lattice;
ready.insert(next, ());
}
unreachable!()
}
#[test]
fn the_far_terrain_next_to_the_near_chunks_loads_first() {
// Measured: 270 to 301 jobs, all of them fine tiles (7500 to 8500
// lattice points). A coarse tile first costs up to 289 points; the
// order that filled a ring ahead first and refined broad tiles every
// other job took 54000 to 119000 for the seam in view alone.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([(jobs, cost), _], _) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 320, "{jobs} jobs");
assert!(cost < 9000, "{cost} lattice points");
}
}
}
#[test]
fn a_coarse_backdrop_reaches_the_horizon_early() {
// Sampling only planned tiles, nearest first, reached the horizon
// with the last of ~3000 jobs in Full (500000 lattice points), ~1050
// in Reduced (200000): for minutes on a slow machine the far terrain
// ended in a hard edge. The backdrop adds ~230 coarse tiles (~80 in
// Reduced), about a tenth more work in all.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([_, (jobs, cost)], leaves) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 1000 && jobs < leaves, "{jobs} jobs of {leaves}");
assert!(cost < 100_000, "{cost} lattice points");
}
}
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
+8 -1
View File
@@ -160,7 +160,14 @@ impl Mesher {
for slot in &mut faces {
let Some(face) = matching.next() else { break };
*slot = Some(Style {
layer: face.layer as u16 | if leaves { OPAQUE_LAYER } else { 0 },
// As the mesher marks them: opaque leaves and faces over opaque
// texels skip the cutout and draw in the opaque pass.
layer: face.layer as u16
| if leaves || face.opaque {
OPAQUE_LAYER
} else {
0
},
tint: if face.quad.tint_index >= 0 {
tint
} else {
@@ -59,6 +59,55 @@ pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// `--player-dir DIR` (#4952), set once at start: one player's own files, when several games
/// play in one game folder at the same time (the launcher's `play --account`).
static PLAYER: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes [`player_dir`] return `dir` for the rest of the run (`--player-dir`), creating it.
/// What is a player's own goes there: `options.txt` and the settings files beside it,
/// `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`, `hotbar.nbt` and
/// `screenshots/`. What the game folder's players share stays in it: saves, resource and
/// shader packs, mods and their config, caches.
pub fn set_player_dir(dir: PathBuf) {
let _ = fs::create_dir_all(&dir);
let _ = PLAYER.set(dir);
}
/// The folder of this player's own files: `--player-dir`, else the game folder (the working
/// directory, where the launcher runs the game).
pub fn player_dir() -> PathBuf {
PLAYER
.get()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
/// `--player-dir`, if given.
pub fn player_dir_override() -> Option<&'static Path> {
PLAYER.get().map(PathBuf::as_path)
}
/// Where the files all players share live, for settings kept beside `options.txt` in
/// `options_dir` (the enabled mods): that folder, unless `--player-dir` moved `options.txt`
/// out of the game folder.
pub fn shared_dir(options_dir: &Path) -> PathBuf {
shared_dir_of(PLAYER.get().map(PathBuf::as_path), options_dir, || {
std::env::current_dir().unwrap_or_default()
})
}
/// [`shared_dir`] for a given `--player-dir` and game folder.
fn shared_dir_of(
player: Option<&Path>,
options_dir: &Path,
game_dir: impl FnOnce() -> PathBuf,
) -> PathBuf {
match player {
Some(player) if player == options_dir => game_dir(),
_ => options_dir.to_path_buf(),
}
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
@@ -567,6 +616,20 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn a_player_dir_keeps_the_shared_files_in_the_game_folder() {
let (game, player) = (Path::new("/games/shared"), Path::new("/games/players/alex"));
// options.txt moved to the player's folder: the enabled mods stay the game folder's.
assert_eq!(shared_dir_of(Some(player), player, || game.into()), game);
// No --player-dir, or a splitscreen profile's own folder: beside options.txt as before.
assert_eq!(shared_dir_of(None, game, || unreachable!()), game);
let profile = Path::new("/games/shared/splitscreen/profiles/P2");
assert_eq!(
shared_dir_of(Some(player), profile, || unreachable!()),
profile
);
}
#[test]
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
let (mut floods, start) = (Floods::default(), Instant::now());
@@ -172,6 +172,7 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
@@ -224,6 +225,7 @@ impl MenuBlur {
})];
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()
});
+72 -9
View File
@@ -4,6 +4,7 @@
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::{
@@ -11,6 +12,7 @@ use crate::{
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_core::{
@@ -231,6 +233,9 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Blocks that give off light (world position, state id), for the colour of block light
/// Horizon's shader packs read (`gfx::block_light`).
pub lights: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
@@ -248,6 +253,7 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
@@ -278,6 +284,7 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
@@ -288,6 +295,7 @@ impl SectionMesh {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
@@ -464,6 +472,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -641,6 +652,12 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
s.solid_render,
s.full_collision,
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -680,6 +697,14 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// Whether `state` is a full opaque cube: the only block leaf culling
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
/// that only partly fill their block are not.
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
self.info
.get(state.0 as usize)
.is_some_and(|i| i.opaque_cube)
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
@@ -854,14 +879,18 @@ impl Padded {
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
@@ -1073,6 +1102,10 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// Sprite layers a shader pack moves into the translucent pass, by layer.
pub translucent_layers: &'a [bool],
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -1102,6 +1135,11 @@ impl Mesher<'_> {
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
.leaf
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
}
let mut tints = Tints::new(
region,
self.biomes,
@@ -1124,6 +1162,10 @@ impl Mesher<'_> {
out.block_entities
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.emission > 0 {
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();
@@ -1162,13 +1204,21 @@ impl Mesher<'_> {
None => [x as f32, y as f32, z as f32],
};
let mut visible = [false; 6];
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& self
.visibility
.should_render_face(
@@ -1193,6 +1243,7 @@ impl Mesher<'_> {
face,
ao,
state,
interior_leaf,
);
}
}
@@ -1231,6 +1282,7 @@ impl Mesher<'_> {
face: &BakedFace,
ao: bool,
state: &Info,
interior_leaf: bool,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
@@ -1260,11 +1312,28 @@ impl Mesher<'_> {
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
let target = if face.translucent {
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let g = gray[i] as u32;
let rgb = match tint {
@@ -1275,13 +1344,7 @@ impl Mesher<'_> {
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
},
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
@@ -0,0 +1,656 @@
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
//!
//! The mask is built on the mesh worker from the same padded block snapshot the
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
//! open (air, fluids, partial models, anything else) or unknown (a missing
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
//! hide or solidify anything; unknown and open cells always expose a face.
//!
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
//! enclosed leaves into the opaque layer without cutout testing. `Solid
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
/// A cell of the padded snapshot, as far as leaf culling cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Cell {
/// Air, a fluid, a partial model, glass or any other non-occluding block.
Open,
/// `LeavesBlock`.
Leaf,
/// A full opaque cube (see [`opaque_cube`]).
Opaque,
/// Missing chunk, missing section or the mask's outer shell.
Unknown,
}
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
/// chests and models that only partly fill their block are not.
pub(super) fn opaque_cube(
shape: RenderShape,
can_occlude: bool,
solid_render: bool,
full_collision: bool,
) -> bool {
shape == RenderShape::Model && can_occlude && solid_render && full_collision
}
const UNVISITED: u8 = u8::MAX;
const STRIDE_X: isize = 1;
const STRIDE_Z: isize = P as isize;
const STRIDE_Y: isize = (P * P) as isize;
const ADJACENT_STRIDES: [isize; 6] = [
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
];
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
/// between sections and modes; `Off` does no work at all.
#[derive(Default)]
pub(super) struct LeafMask {
cells: Vec<Cell>,
depth: Vec<u8>,
queue: Vec<u16>,
active: bool,
}
impl LeafMask {
/// Classify the padded snapshot and, for the interior modes, measure how
/// deep each leaf sits below the canopy surface.
pub(super) fn prepare(
&mut self,
mode: LeafCullingMode,
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
) {
self.active = mode != LeafCullingMode::Off;
if !self.active {
return;
}
// Most sections hold no leaves at all; do not classify 8,000 cells to
// learn that. The centre is the only part this mesh can change.
let mut leaves = false;
'scan: for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
if info
.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves)
{
leaves = true;
break 'scan;
}
}
}
}
if !leaves {
self.active = false;
return;
}
if self.cells.len() != P * P * P {
self.cells.resize(P * P * P, Cell::Open);
}
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
self.cells[i] = if !known(region, base_y, x, y, z) {
Cell::Unknown
} else {
match info.get(pad.blocks[i] as usize) {
None => Cell::Unknown,
Some(state) if state.leaves => Cell::Leaf,
Some(state) if state.opaque_cube => Cell::Opaque,
Some(_) => Cell::Open,
}
};
}
}
}
// Solid is a purely local six-neighbour test. Reserve the breadth-first
// search for Solid Aggressive, whose one-open-side rule needs to know
// whether a leaf is connected to the canopy surface through other
// leaves.
if mode == LeafCullingMode::SolidAggressive {
self.depths();
}
}
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
fn depths(&mut self) {
if self.depth.len() != self.cells.len() {
self.depth.resize(self.cells.len(), UNVISITED);
} else {
self.depth.fill(UNVISITED);
}
self.queue.clear();
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
if self.cells[i] != Cell::Leaf {
continue;
}
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Open | Cell::Unknown)
});
if touches_open {
self.depth[i] = 0;
self.queue.push(i as u16);
}
}
}
}
let mut head = 0;
while head < self.queue.len() {
let i = self.queue[head] as usize;
head += 1;
let next_depth = self.depth[i].saturating_add(1);
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
self.depth[j] = next_depth;
self.queue.push(j as u16);
}
}
}
}
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
///
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
/// with a single open side joins the opaque core, so the cutout shell is
/// one layer thinner. Unknown neighbours never qualify, however many sides
/// they leave open. A leaf the search never reached sits inside a closed
/// volume: interior for both.
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
if !self.active {
return false;
}
let aggressive = match mode {
LeafCullingMode::Solid => false,
LeafCullingMode::SolidAggressive => true,
_ => return false,
};
// A section boundary is not proof of a closed volume: the neighbouring
// section may not be drawn yet, and its data is not ours to assume.
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
return false;
}
let i = pi(x, y, z);
if self.cells.get(i) != Some(&Cell::Leaf) {
return false;
}
if !aggressive {
return ADJACENT_STRIDES.iter().all(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
});
}
let depth = self.depth[i];
if depth == UNVISITED || depth >= 1 {
return true;
}
let mut open = 0;
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
match self.cells[j] {
Cell::Unknown => return false,
Cell::Open => {
open += 1;
if open > 1 {
return false;
}
}
Cell::Leaf | Cell::Opaque => {}
}
}
open == 1
}
}
/// The cells this section's mesh owns. A face on this boundary belongs to the
/// neighbouring section as much as to this one; leaf culling leaves it alone.
#[inline]
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
/// for the same reason. The mask's outer shell is unknown too, so nothing is
/// inferred past the data the worker snapshotted.
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
return false;
}
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
}
#[cfg(test)]
impl LeafMask {
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
/// Only a known leaf on the other side counts: open cells, partial models,
/// missing neighbours and the mask's edge stay visible.
pub(super) fn hides_face(
&self,
mode: LeafCullingMode,
x: i32,
y: i32,
z: i32,
d: horizon_core::geometry::Direction,
) -> bool {
if !self.active || mode != LeafCullingMode::Hollow {
return false;
}
let [dx, dy, dz] = d.normal();
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
return false;
}
let i = pi(x, y, z);
let ni = pi(nx, ny, nz);
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
}
#[inline]
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
checked(x, y, z)
.and_then(|i| self.cells.get(i))
.copied()
.unwrap_or(Cell::Unknown)
}
}
#[cfg(test)]
#[inline]
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
Some(pi(x, y, z))
} else {
None
}
}
#[cfg(test)]
#[inline]
fn unpi(i: usize) -> (i32, i32, i32) {
let x = (i % P) as i32 - B;
let z = (i / P % P) as i32 - B;
let y = (i / (P * P)) as i32 - B;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
use horizon_core::geometry::Direction;
use horizon_world::fluid::FluidState;
use std::time::Instant;
fn info(leaves: bool, opaque: bool) -> Info {
Info {
solid: opaque,
full: opaque,
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: true,
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
offset: None,
legacy_solid: false,
overlay: leaves,
leaves,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
}
}
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
fn states() -> Vec<Info> {
vec![
info(false, false),
info(true, false),
info(false, true),
info(false, false),
]
}
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
Padded {
blocks: (0..P * P * P)
.map(|i| {
let (x, y, z) = unpi(i);
fill(x, y, z)
})
.collect(),
light: vec![0; P * P * P],
}
}
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
for y in min..=max {
for z in min..=max {
for x in min..=max {
pad.blocks[pi(x, y, z)] = state;
}
}
}
}
fn loaded() -> Chunk {
Chunk::empty(0, 0)
}
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
let mut mask = LeafMask::default();
mask.prepare(mode, pad, states, &region(chunk), 0);
mask
}
#[test]
fn only_a_full_model_cube_is_opaque() {
let combinations = [
(RenderShape::Model, true, true, true),
(RenderShape::Model, true, true, false),
(RenderShape::Model, true, false, true),
(RenderShape::Model, false, true, true),
(RenderShape::Model, false, false, false),
(RenderShape::Invisible, true, true, true),
(RenderShape::Animated, true, true, true),
];
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
assert!(opaque_cube(
shape,
can_occlude,
solid_render,
full_collision
));
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
assert!(
!opaque_cube(shape, can_occlude, solid_render, full_collision),
"{shape:?} {can_occlude} {solid_render} {full_collision}"
);
}
}
#[test]
fn solid_marks_the_cell_inside_a_three_cube() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
// Aggressive also takes the six face-centre leaves, whose only open
// side faces out of the cube. Corners and edges keep their cutout.
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
}
#[test]
fn aggressive_takes_the_next_shell_layer() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
let surface = |v: i32| v == 6 || v == 10;
for [x, y, z] in [
[6, 8, 8],
[10, 8, 8],
[6, 6, 8],
[6, 6, 6],
[7, 8, 8],
[8, 8, 8],
[7, 7, 7],
[8, 8, 7],
] {
let expected = !surface(x) && !surface(y) && !surface(z);
assert_eq!(
solid.interior(LeafCullingMode::Solid, x, y, z),
expected,
"solid {x} {y} {z}"
);
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
let expected = open <= 1;
assert_eq!(
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
expected,
"aggressive {x} {y} {z}"
);
}
}
#[test]
fn hollow_hides_only_leaf_to_leaf_faces() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
// Glass (3, a partial model), water and a slab are open: their leaves
// must keep the face.
pad.blocks[pi(10, 8, 8)] = 3;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
// The other modes never hide a quad.
for mode in [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
] {
let mask = built(mode, &pad, &states, &chunk);
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
}
}
#[test]
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
pad.blocks[pi(8, 8, 8)] = 0;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
// The corner away from the cavity is still two layers in.
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
}
#[test]
fn an_opaque_neighbour_does_not_expose_a_leaf() {
let states = states();
let mut pad = padded(|_, _, _| 0);
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
}
pad.blocks[pi(8, 8, 8)] = 1;
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
}
#[test]
fn a_leaf_inside_a_closed_volume_is_interior() {
let states = states();
// A leaf cube inside opaque rock: the search has no opening to start
// from, so every leaf stays unvisited and counts as interior.
let mut pad = padded(|_, _, _| 2);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
if mode == LeafCullingMode::SolidAggressive {
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
}
#[test]
fn missing_chunks_and_sections_are_unknown() {
let states = states();
let pad = padded(|_, _, _| 1);
let chunk = loaded();
let mut around = [[Some(&chunk); 3]; 3];
around[1][2] = None; // east chunk
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
// The leaf at the edge keeps its face toward the missing chunk, even
// though `Padded::fill` copied the centre's edge leaf there.
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
// A leaf one block inside is still between two known leaves.
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
// A section above the column is unknown too: the same leaf one
// section up has no data, so it can never be interior.
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&short), 16);
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
// The outer shell has no data beyond it.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &region(&chunk), 0);
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
// A section boundary is never enclosed, even with every chunk loaded:
// the neighbouring section may not have been meshed yet.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&chunk), 0);
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
}
#[test]
fn off_does_no_work_and_hides_nothing() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Off, &pad, &states, &region(&chunk), 0);
assert!(!mask.active);
assert!(mask.cells.is_empty());
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
}
#[test]
fn a_reused_mask_reclassifies_every_cell() {
let states = states();
let chunk = loaded();
let mut mask = LeafMask::default();
let mut leaves = padded(|_, _, _| 0);
cube(&mut leaves, 7, 9, 1);
mask.prepare(
LeafCullingMode::Hollow,
&leaves,
&states,
&region(&chunk),
0,
);
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
let mut other = padded(|_, _, _| 0);
cube(&mut other, 10, 12, 1);
mask.prepare(LeafCullingMode::Hollow, &other, &states, &region(&chunk), 0);
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
}
#[test]
#[ignore = "microbenchmark; run with --ignored --nocapture"]
fn mask_build_benchmark() {
let states = states();
let mut pad = padded(|x, y, z| {
// Alternating leaf slabs make the search visit most of the volume.
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
1
} else {
0
}
});
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let region = region(&chunk);
let iterations = 1000;
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mut mask = LeafMask::default();
let started = Instant::now();
for _ in 0..iterations {
mask.prepare(mode, &pad, &states, &region, 0);
}
let per = started.elapsed() / iterations;
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
assert!(mask.active);
if mode == LeafCullingMode::SolidAggressive {
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
}
}
}
@@ -0,0 +1,294 @@
//! Leaf culling through the real mesher: face counts, the opaque layer and the
//! conservative cases (cavities, partial neighbours, missing chunks, column
//! ends). Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, ColumnLayout},
graphics::LeafCullingMode,
models,
};
struct Fixture {
assets: Assets,
biomes: Biomes,
render: RenderAssets,
visibility: horizon_world::block_render::FaceVisibility,
tables: MeshTables,
}
fn fixture() -> Fixture {
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 = models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
Fixture {
assets,
biomes,
render,
visibility,
tables,
}
}
fn state(f: &Fixture, name: &str) -> u32 {
f.assets.registry.default_state(name).unwrap().0
}
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
for z in min..=max {
for x in min..=max {
chunk.set(x, y, z, state);
}
}
}
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
let mut chunk = Chunk::empty(0, 0);
let state = state(f, name);
for y in base..base + (max - min + 1) {
leaves_cube(&mut chunk, min, max, y, state);
}
chunk
}
fn mesh(
f: &Fixture,
_chunk: &Chunk,
region: [[Option<&Chunk>; 3]; 3],
sy: i32,
mode: LeafCullingMode,
) -> SectionMesh {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
biomes: &f.biomes,
visibility: f.visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: mode,
};
mesher.mesh(&region, sy)
}
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
fn quads(mesh: &SectionMesh) -> (usize, usize) {
let mut opaque = 0;
let mut cutout = 0;
for quad in mesh.solid.as_chunks::<4>().0 {
let flags = quad
.iter()
.map(|v| v.layer & OPAQUE_LAYER != 0)
.collect::<Vec<_>>();
assert!(
flags.iter().all(|flag| *flag == flags[0]),
"a quad mixed opaque and cutout vertices: {flags:?}"
);
if flags[0] {
opaque += 1;
} else {
cutout += 1;
}
}
assert_eq!(mesh.solid.len() % 4, 0);
(opaque, cutout)
}
const BASE: i32 = MIN_Y + 4;
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn off_draws_every_fancy_leaf_face() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
assert_eq!(quads(&mesh), (0, 162));
assert_eq!(mesh.translucent.len(), 0);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn solid_draws_the_fully_enclosed_leaf_opaque() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// Only the centre has all six sides covered: its six quads go opaque.
assert_eq!(quads(&solid), (6, 156));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
assert_eq!(quads(&aggressive), (42, 120));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
let f = fixture();
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The 3x3x3 inside the surface: 27 blocks, six quads each.
assert_eq!(quads(&solid), (162, 588));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
assert_eq!(quads(&aggressive), (486, 264));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn hollow_drops_only_the_faces_two_leaves_share() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn mixed_species_count_as_one_leaf_volume() {
let f = fixture();
let oak = state(&f, "minecraft:oak_leaves");
let birch = state(&f, "minecraft:birch_leaves");
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
for z in 6..=8 {
for x in 6..=8 {
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
}
}
}
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
assert_eq!(quads(&solid), (6, 156));
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
let f = fixture();
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
chunk.set(7, BASE + 2, 7, 0);
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// Five-cube shell (150) plus the six faces around the one-block cavity.
assert_eq!(quads(&hollow), (0, 156));
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The six leaves around the cavity are exposed, so they stay cutout; the
// other 20 inner leaves draw solid. The cavity block itself has no quads.
assert_eq!(quads(&solid), (120, 624));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
// A loaded east chunk whose x=0 column continues the canopy.
let mut east = Chunk::empty(1, 0);
for y in BASE..BASE + 4 {
for z in 12..=15 {
east.set(0, y, z, leaf);
}
}
let mut region = around(&chunk);
region[1][2] = Some(&east);
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
let mut region = around(&chunk);
region[1][2] = None;
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
// The east faces of the x=15 leaves are on the section boundary: a loaded
// neighbour does not let Hollow drop them, and a missing one does not
// change the mesh either.
assert_eq!(joined.solid.len(), missing.solid.len());
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
assert!(joined.solid.len() < off.solid.len());
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn the_top_of_a_one_section_column_stays_visible() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let layout = ColumnLayout::new(0, 16).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for y in 13..16 {
leaves_cube(&mut chunk, 5, 7, y, leaf);
}
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// y=15 is the column's top: the faces toward y=16 are unknown, so they
// stay, and the y=15 leaves are not enclosed.
assert!(!hollow.solid.is_empty());
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
assert!(
solid
.solid
.iter()
.filter(|v| v.pos[1] == 16.0)
.all(|v| v.layer & OPAQUE_LAYER == 0)
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_world_without_leaves_meshes_identically_in_every_mode() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
}
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
for (a, b) in off.solid.iter().zip(&other.solid) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
for (a, b) in off.translucent.iter().zip(&other.translucent) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
}
}
@@ -81,6 +81,8 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
@@ -156,6 +158,8 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
(&empty, None),
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
sturdy: 0,
+123
View File
@@ -38,6 +38,21 @@ pub struct Settings {
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub item: bool,
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
pub dither_cutouts: bool,
}
/// The `ditherCutoutMips` setting, for textures loaded from now on.
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
pub fn set_dither_cutouts(on: bool) -> bool {
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
}
/// The `ditherCutoutMips` setting.
pub fn dither_cutouts() -> bool {
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
}
/// A square or rectangular RGBA8 image, rows top to bottom.
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
}
out.push(data);
}
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
dither_cutout_levels(&mut out);
}
out
}
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
const DITHERED_CUTOUT_SIZE: u32 = 64;
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
/// its texels are in or out, about as many in as the base has.
fn dither_cutout_levels(levels: &mut [Image]) {
const BAYER: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
let mut alpha: Vec<f32> = levels[0]
.pixels
.iter()
.map(|p| f32::from(p[3]) / 255.0)
.collect();
for level in &mut levels[1..] {
let (nw, nh) = (level.width as usize, level.height as usize);
let mut next = vec![0.0; nw * nh];
for y in 0..nh {
for x in 0..nw {
let at = |dx: usize, dy: usize| {
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
};
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
next[y * nw + x] = mean;
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
}
}
alpha = next;
(w, h) = (nw, nh);
}
}
#[cfg(test)]
mod dither_tests {
use super::*;
#[test]
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
let cutout = |side: u32| Image {
width: side,
height: side,
pixels: (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect(),
};
let settings = |dither_cutouts| Settings {
has_transparent: true,
dither_cutouts,
..Settings::default()
};
assert_ne!(
generate(cutout(128), 4, settings(false)),
generate(cutout(128), 4, settings(true))
);
assert_eq!(
generate(cutout(32), 4, settings(false)),
generate(cutout(32), 4, settings(true))
);
}
#[test]
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
// Blades one texel wide, one column in four: too fine for the mips to hold.
let side = 128u32;
let pixels = (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect();
let base = Image {
width: side,
height: side,
pixels,
};
let settings = Settings {
has_transparent: true,
dither_cutouts: true,
..Settings::default()
};
let mips = generate(base, 4, settings);
for level in &mips[3..] {
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
let coverage = passing / level.pixels.len() as f32;
assert!((0.15..0.35).contains(&coverage), "{coverage}");
}
}
}
+164 -46
View File
@@ -1236,8 +1236,9 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack.
NativePackOptions(usize),
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
/// first the one the button opens.
NativePackOptions(usize, u8),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1263,6 +1264,8 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1525,6 +1528,8 @@ 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 {
@@ -1570,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1633,30 +1639,64 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
_ => return None,
})
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
/// The options of the Horizon pack `s.shader_packs[i]`, two a row.
fn native_pack_option_rows(s: &Settings, i: usize) -> Vec<Row> {
/// A list's slot with nothing in it: text takes no clicks.
fn blank_slot(w: f32) -> Widget {
Widget {
x: 0.0,
y: 0.0,
w,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
list: false,
row: 0,
}
}
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
/// else every option two a row.
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
use horizon_shader_api::PageItem;
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let mut widgets = pack
.options
.iter()
.zip(&pack.values)
.enumerate()
.map(|(o, (option, v))| {
let value = crate::shader_pack_list::show_value(&option.kind, *v);
let count = pack.options.len().min(pack.values.len());
let (columns, items) = match pack.pages.get(usize::from(at)) {
Some(page) => (usize::from(page.columns), page.items.clone()),
None => (2, (0..count).map(PageItem::Option).collect()),
};
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &PageItem| match *item {
PageItem::Option(o) if o < count => {
let option = &pack.options[o];
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, 150.0, label, Act::NativePackOption(i, o))
});
let mut rows = vec![Row::Note(pack.title.clone())];
while let Some(a) = widgets.next() {
rows.push(Row::Buttons(a, widgets.next()));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
let page = Page::NativePackOptions(i, p as u8);
button(0.0, 0.0, width, label, Act::Push(page))
}
_ => blank_slot(width),
};
let mut rows = Vec::new();
if at == 0 {
rows.push(Row::Note(pack.title.clone()));
}
rows.extend(
items
.chunks(columns)
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
);
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
@@ -1667,19 +1707,7 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
x: 0.0,
y: 0.0,
w: 150.0,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
list: false,
row: 0,
};
let blank = || blank_slot(150.0);
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
use horizon_shaders::ScreenItem;
@@ -1744,13 +1772,25 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
if let WKind::Button {
act: Act::NativePackOption(pack, option),
..
} = &wd.kind
{
let option = s.shader_packs.get(*pack)?.options.get(*option)?;
return (!option.description.is_empty()).then(|| Text::literal(&option.description));
let pack_text = match &wd.kind {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
} => Some(
&s.shader_packs
.get(*pack)?
.pages
.get(usize::from(*at))?
.description,
),
_ => None,
};
if let Some(text) = pack_text {
return (!text.is_empty()).then(|| Text::literal(text));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
@@ -1761,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description()));
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = horizon_page_tooltip(*page)
@@ -1936,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
impl OptionsUi {
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
}
fn settings_search_active(&self) -> bool {
@@ -2095,6 +2135,14 @@ impl OptionsUi {
_ => None,
}
}
/// The name of a Horizon pack's options page past its first, as the page's title.
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
let Page::NativePackOptions(pack, at @ 1..) = page else {
return None;
};
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
Some(Text::literal(&page.name))
}
fn title(page: Page) -> &'static str {
match page {
Page::Main => "options.title",
@@ -2113,7 +2161,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2124,6 +2172,7 @@ impl OptionsUi {
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -2213,11 +2262,19 @@ 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::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
let extra = |item: crate::extras::Item| {
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
{
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
@@ -2279,6 +2336,17 @@ impl OptionsUi {
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
None,
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -2572,7 +2640,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -3024,7 +3092,8 @@ impl OptionsUi {
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
text: Self::pack_page_title(s, page)
.unwrap_or_else(|| tr(Self::title(page))),
color: u32::MAX,
centered: true,
},
@@ -3109,7 +3178,9 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i) => Some(("controls.reset", Act::NativePackReset(i))),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3906,7 +3977,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -4829,7 +4900,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -4870,6 +4941,24 @@ impl OptionsUi {
mod tests {
use super::*;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
#[test]
fn only_a_focused_search_box_takes_text() {
let mut ui = OptionsUi::default();
assert!(!ui.typing(), "the main page has no text box");
ui.show(Page::Language);
assert!(ui.typing(), "the language list's search takes the keys");
ui.show(Page::Video);
assert!(!ui.typing(), "the settings search is not focused");
ui.settings_search.focused = true;
assert!(ui.typing(), "the settings search, once focused, does");
ui.settings_search.focused = false;
ui.show(Page::Packs);
assert!(!ui.typing(), "the pack screen's search is not open yet");
}
#[test]
fn accessibility_sliders_and_toggles_survive_options_round_trip() {
let mut s = Settings::default();
@@ -5153,6 +5242,22 @@ mod tests {
},
}],
values: vec![1.0],
pages: vec![
horizon_shader_api::OptionPage {
id: "main".into(),
name: "P".into(),
description: String::new(),
columns: 2,
items: vec![horizon_shader_api::PageItem::Page(1)],
},
horizon_shader_api::OptionPage {
id: "more".into(),
name: "More".into(),
description: String::new(),
columns: 1,
items: vec![horizon_shader_api::PageItem::Option(0)],
},
],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
@@ -5166,7 +5271,11 @@ mod tests {
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0));
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
// The option is on the pack's second page, behind a button on its first.
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
@@ -5174,9 +5283,17 @@ mod tests {
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
ui.shift = false;
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.back(&mut s);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
@@ -6715,6 +6832,7 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Agent,
]
);
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -926,6 +926,8 @@ impl PostEffects {
let mut decoder = png::Decoder::new(std::io::BufReader::new(
std::fs::File::open(&file).map_err(|e| format!("{}: {e}", file.display()))?,
));
// As the other resource-pack images: stb_image never checks chunk CRCs.
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut data = vec![0; reader.output_buffer_size().ok_or("a huge image")?];
@@ -1024,6 +1026,7 @@ impl PostEffects {
size: [u32; 2],
depth: Option<&wgpu::TextureView>,
req: &Request,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
let mut applied = Vec::new();
@@ -1099,6 +1102,7 @@ impl PostEffects {
(&work.1, size),
0.0,
format,
timing.as_deref_mut(),
);
self.staging = Some(staging);
for id in &applied {
@@ -1113,6 +1117,7 @@ impl PostEffects {
depth,
req.menu_blur_radius,
format,
timing.as_deref_mut(),
);
self.chains.insert(id.clone(), Some(chain));
}
@@ -1126,6 +1131,7 @@ impl PostEffects {
(target, size),
0.0,
format,
timing,
);
self.blit = Some(blit);
self.work = Some(work);
@@ -1144,6 +1150,7 @@ impl PostEffects {
main_depth: Option<&wgpu::TextureView>,
menu_radius: f32,
format: wgpu::TextureFormat,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -1203,6 +1210,7 @@ impl PostEffects {
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect depth clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -1221,6 +1229,7 @@ impl PostEffects {
let c = t.clear.map(f64::from);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect target clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &chain.textures[i - 1].1,
resolve_target: None,
@@ -1274,6 +1283,7 @@ impl PostEffects {
&inputs,
(view(pass.output), sizes[pass.output]),
format,
timing.as_deref_mut(),
);
continue;
}
@@ -1287,6 +1297,7 @@ impl PostEffects {
(view(pass.output), sizes[pass.output]),
menu_radius,
format,
timing.as_deref_mut(),
);
}
}
@@ -1303,6 +1314,7 @@ impl PostEffects {
inputs: &[(&wgpu::TextureView, [u32; 2])],
output: (&wgpu::TextureView, [u32; 2]),
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
let Some(pipeline) = translated.pipelines.get(&format) else {
return;
@@ -1353,6 +1365,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1381,6 +1394,7 @@ impl PostEffects {
output: (&wgpu::TextureView, [u32; 2]),
menu_radius: f32,
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
// SamplerInfo: OutSize, then each input's size.
let info = [
@@ -1441,6 +1455,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1715,6 +1730,7 @@ mod tests {
menu_blur_radius: 5.0,
globals: Globals::default(),
},
None,
);
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -1845,6 +1861,7 @@ mod tests {
menu_blur_radius: 0.0,
globals: Globals::default(),
},
None,
));
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -827,6 +827,14 @@ impl TextField {
);
}
}
if self.focused {
// `TextInputManager.setTextInputArea`, in GUI pixels: the caret
// the input method's composition and its candidate window go to.
// The chat and in-bed chat boxes and the pack screen's search are
// `TextField`s; the other fields are an `EditBox` or a multiline
// box, which report their own.
crate::preedit::report_caret(cursor_x as f32, ty as f32, 10.0);
}
}
}
+4 -2
View File
@@ -332,6 +332,7 @@ impl Settings {
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -340,9 +341,10 @@ impl Settings {
.parent()
.map(crate::extras::Extras::load)
.unwrap_or_default();
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
.map(crate::mod_list::ModList::load)
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
Ok(bytes) => Self {
@@ -649,7 +651,7 @@ impl Settings {
self.horizon.save(dir);
self.controller.save(dir);
self.extras.save(dir);
self.mods.save(dir);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
/// Volume of a sound source (master excluded).
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, PackOption};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -26,6 +26,8 @@ pub struct Entry {
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
/// Its options page's `[[pages]]`; empty for one page of every option.
pub pages: Vec<OptionPage>,
}
impl Entry {
@@ -88,8 +90,10 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let (title, options) =
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
file,
@@ -97,6 +101,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -109,6 +114,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
@@ -25,6 +25,9 @@ pub const UI: usize = 10;
/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static.
pub const STATIC_CHANNELS: usize = 247;
pub const STREAMING_CHANNELS: usize = 8;
/// Static channels only sounds played to the listener (no position) may take
/// while the `uiSoundReserve` extra is on.
pub const HEARD_RESERVE: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayResult {
@@ -65,6 +68,9 @@ pub enum Ticking {
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -230,6 +236,25 @@ impl SoundInstance {
true,
)
}
/// A native mod's music layer: a looping, unattenuated track on the music
/// source that starts silent (`MusicLayers` fades it in).
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
let mut s = Self::new(
id,
MUSIC,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
s.can_start_silent = true;
s.ticking = Ticking::MusicLayer;
s
}
/// `SimpleSoundInstance.forJukeboxSong`.
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
Self::new(
@@ -519,7 +544,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No => {}
Ticking::No | Ticking::MusicLayer => {}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -792,6 +817,9 @@ pub struct SoundEngine {
pub source_volumes: [f32; 11],
/// A `SoundEventListener` is registered.
pub listening: bool,
/// Static channels positional sounds leave free for the ones played to the
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
@@ -819,6 +847,7 @@ impl SoundEngine {
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
source_volumes: [1.0; 11],
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
}
}
@@ -1100,7 +1129,15 @@ impl SoundEngine {
let looping = instance.looping && instance.delay <= 0;
let streaming = sound.stream;
let pool = usize::from(streaming);
let channel = if self.used[pool] >= self.limits[pool] {
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
// them (a crater's worth of blocks breaking) can't stop the ones played to the listener.
let positional = !instance.relative && instance.attenuation != Attenuation::None;
let reserved = if positional && !streaming {
self.listener_reserve
} else {
0
};
let channel = if self.used[pool] + reserved >= self.limits[pool] {
None
} else {
backend.create(streaming)
@@ -1549,4 +1586,47 @@ mod tests {
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
}
#[test]
fn world_sounds_leave_channels_for_the_listener_only_with_the_extra() {
use test_backend::Null;
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"block.stone.break": {"sounds": ["dig/stone1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
let random = || LegacyRandom::new(0);
let block = |i: usize| {
let at = [i as f64, 64.0, 0.0];
let source = crate::settings::sound_source("minecraft:block.stone.break");
SoundInstance::at(
"minecraft:block.stone.break",
source,
1.0,
1.0,
random(),
at,
)
};
let click = || SoundInstance::for_ui("minecraft:ui.button.click", 1.0, 0.25, random());
for reserve in [0, HEARD_RESERVE] {
let mut engine = SoundEngine::new(registry.clone());
engine.listener_reserve = reserve;
let mut backend = Null::default();
// Positional sounds take the static channels the reserve leaves; then their plays fail.
let started = (0..STATIC_CHANNELS)
.filter(|&i| {
engine.play_new(block(i), false, &mut backend).1 == PlayResult::Started
})
.count();
assert_eq!(started, STATIC_CHANNELS - reserve);
let heard = engine.play_new(click(), false, &mut backend).1;
assert_eq!(
heard == PlayResult::Started,
reserve > 0,
"reserve {reserve}"
);
}
}
}
@@ -13,6 +13,11 @@ use crate::{
ui,
};
/// A string of this page: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
lang::get_or(key, english)
}
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
const SHOWN: usize = 4;
pub mod store;
@@ -132,7 +137,12 @@ impl SplitProfiles {
};
if let Some(name) = &self.forgetting {
return vec![
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
button(
120.0,
tr("horizon.splitscreen.profiles.forget", "Forget"),
self.editable(name),
Target::Forget,
),
button(
144.0,
lang::get_or("gui.cancel", "Cancel"),
@@ -147,7 +157,12 @@ impl SplitProfiles {
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
let top = field_top(h);
return vec![
button(top + 25.0, "Create".into(), ok, Target::Create),
button(
top + 25.0,
tr("horizon.splitscreen.profiles.create", "Create"),
ok,
Target::Create,
),
button(
top + 50.0,
lang::get_or("gui.back", "Back"),
@@ -166,7 +181,7 @@ impl SplitProfiles {
{
let mut label = name.clone();
if self.current.as_ref() == Some(name) {
label.push_str(" - you");
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
@@ -177,7 +192,8 @@ impl SplitProfiles {
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
format!("Forget {name}"),
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
@@ -186,19 +202,24 @@ impl SplitProfiles {
if self.names.len() > SHOWN {
buttons.push((
[x, y, 98.0, 20.0],
"Previous".into(),
tr("horizon.splitscreen.profiles.previous", "Previous"),
self.page() > 0,
Target::Previous,
));
buttons.push((
[x + 102.0, y, 98.0, 20.0],
"Next".into(),
tr("horizon.splitscreen.profiles.next", "Next"),
(self.page() + 1) * SHOWN < self.names.len(),
Target::Next,
));
y += 24.0;
}
buttons.push(button(y, "New Profile...".into(), true, Target::New));
buttons.push(button(
y,
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
true,
Target::New,
));
let cancel = lang::get_or("gui.cancel", "Cancel");
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
buttons
@@ -220,30 +241,71 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
STYLE.title(
batch,
atlas,
&Text::literal(tr(
"horizon.splitscreen.profiles.forget_title",
"Forget profile?",
)),
);
line(batch, name, 52.0, u32::MAX);
line(batch, "It leaves this instance's list.", 76.0, grey);
line(batch, "World progress and settings are kept.", 90.0, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_leaves",
"It leaves this instance's list.",
),
76.0,
grey,
);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_kept",
"World progress and settings are kept.",
),
90.0,
grey,
);
if let Some(why) = &self.refused {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(batch, atlas, &Text::literal("New Profile"));
STYLE.title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
);
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
let label = "Name (3 to 16 letters, digits, _)";
batch.text(atlas, label, x, top - 14.0, grey, true);
let label = tr(
"horizon.splitscreen.profiles.name_hint",
"Name (3 to 16 letters, digits, _)",
);
batch.text(atlas, &label, x, top - 14.0, grey, true);
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
field.draw(batch, atlas, None);
} else {
let title = match self.current {
Some(_) => "Switch profile",
None => "Who's playing?",
Some(_) => tr(
"horizon.splitscreen.profiles.switch_title",
"Switch profile",
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
line(batch, "Offline profiles belong to this instance", y, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.offline_note",
"Offline profiles belong to this instance",
),
y,
grey,
);
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let why = self.refused.as_deref().unwrap_or_default();
@@ -308,8 +370,12 @@ impl SplitProfiles {
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let words = match &self.place {
Some(place) => format!("Move where you play: {place}"),
None => "Move where you play".to_owned(),
Some(place) => tr(
"horizon.splitscreen.profiles.move_place",
"Move where you play: %s",
)
.replacen("%s", place, 1),
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
+289 -42
View File
@@ -85,18 +85,93 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Still textures by their pixels and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
pub specular: Vec<u8>,
pub pbr_size: u32,
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
/// Largest sprite array (all layers of the top mip level): resource packs
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
/// Horizon: resource packs with bigger textures are sampled down to fit.
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
/// resources loaded from now on.
pub fn set_texture_budget(mib: u32) {
let bytes = match mib {
0 => DEFAULT_TEXTURE_BUDGET,
mib => u64::from(mib) << 20,
};
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
/// square RGBA layers take at most `bytes`.
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
size /= 2;
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
@@ -156,7 +231,7 @@ impl Sprites {
}
/// Lay every layer out at one size: the largest texture's (resource packs
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
@@ -170,13 +245,18 @@ impl Sprites {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
let size = side_within(largest, layers, budget);
if pixels {
log::info!(
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
budget >> 20
);
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.pbr = Vec::new();
self.size = size;
return;
}
@@ -192,6 +272,30 @@ impl Sprites {
animation.frames = resized[animation.layer as usize].clone();
}
self.size = size;
self.normals.clear();
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
}
}
}
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
@@ -222,6 +326,7 @@ impl Sprites {
.collect();
let layer = u32::try_from(self.native.len())?;
self.opaque.push(true);
self.pbr.push([None, None]);
self.translucent.push(false);
self.mips.push(crate::mipmap::Settings::default());
self.layers.insert(name.to_owned(), layer);
@@ -242,12 +347,28 @@ impl Sprites {
decoded
}
};
self.register(name, decoded)
self.register(root, name, decoded)
}
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
/// that does not load is an error: the caller drops what needed it. Such files bypass the
/// disk cache, whose fingerprint covers only `textures/`.
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = Self::decode_file(&root.join(resource), resource)?;
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
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>>,
@@ -263,40 +384,19 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let (png_width, png_height, pixels) = read_rgba(path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
(u32::try_from(width)?, u32::try_from(height)?)
} else {
(info.width, info.height)
(png_width, png_height)
};
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
return Err(format!("sprite {name} has invalid frame dimensions").into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
let columns = info.width / width;
let count = columns * (info.height / height);
let columns = png_width / width;
let count = columns * (png_height / height);
// Layers are square; a frame of another shape is nearest-resampled to
// its longer side so UVs (normalized per layer) are unchanged.
let side = width.max(height);
@@ -309,7 +409,7 @@ impl Sprites {
let source_row = y + row * height / side;
for column in 0..side {
let offset =
((source_row * info.width + x + column * width / side) * 4) as usize;
((source_row * png_width + x + column * width / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
@@ -339,7 +439,12 @@ impl Sprites {
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
fn register(
&mut self,
root: &Path,
name: &str,
decoded: crate::sprite_cache::Decoded,
) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
@@ -356,6 +461,13 @@ impl Sprites {
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
};
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
@@ -364,10 +476,15 @@ impl Sprites {
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
side,
[
settings.strategy as u32,
@@ -401,12 +518,67 @@ impl Sprites {
self.mips.push(settings);
self.opaque.push(opaque);
self.translucent.push(translucent);
self.pbr.push(pbr);
self.layers.insert(name.to_owned(), layer);
self.native.push((side, frames, initial));
Ok(layer)
}
}
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -452,6 +624,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
mod tests {
use super::*;
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
// Never under vanilla's size, whatever the budget.
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
[200, 100, 0, 255],
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[None, None],
],
..Default::default()
};
sprites.finish();
assert_eq!(sprites.pbr_size, 16);
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
assert_eq!(sprites.specular[..4], NO_SPECULAR);
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
pbr: vec![[None, None]],
..Default::default()
};
sprites.finish();
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
@@ -459,6 +679,33 @@ mod tests {
}
}
#[test]
fn files_outside_textures_load_by_path() {
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let mut sprites = Sprites::default();
let layer = sprites.insert_file(&root, tile).unwrap();
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
assert!(sprites.translucent[layer as usize]);
// A missing tile is an error, not the missing texture.
assert!(
sprites
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
.is_err()
);
assert_eq!(sprites.count(), 1);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
+193 -2
View File
@@ -6,6 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -149,6 +150,8 @@ pub trait LodSampler: Send {
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
self.column(x, z)
}
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
/// when sampling failed and should be tried again later.
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
let mut columns = Vec::with_capacity(side * side);
for dz in 0..side {
@@ -194,7 +197,7 @@ impl<G: ChunkGenerator> Saves for G {
}
}
/// Per-job mesher inputs that change with the dimension or the options.
#[derive(Clone, Copy)]
#[derive(Clone)]
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
@@ -202,6 +205,8 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
translucent_layers: Arc<[bool]>,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -436,6 +441,11 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
/// translucent`), by layer; empty without one.
pub translucent_layers: Arc<[bool]>,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
light_tables: Arc<LightTables>,
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
@@ -770,6 +780,8 @@ impl Streaming {
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,
@@ -851,6 +863,8 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: Arc::from([]),
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -902,9 +916,56 @@ impl Streaming {
}
}
}
/// A resident column a server sent again whole (it changed it in bulk):
/// its new blocks taken, the sections that did not change kept as they
/// were, and its light and its neighbours' worked out again here, off
/// the game thread, as light is for the blocks a server changes one at a
/// time; the sections that changed remesh (with theirs) once lit. Until
/// then the sections that changed show the light the packet carried (a
/// Horizon server sends the light from before its relight, then the
/// new light; another server's is already right, which matters where
/// the neighbours this light needs aren't all here).
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
let key = (chunk.x, chunk.z);
let Some(old) = world.chunks.get(&key).cloned() else {
return self.insert_remote(world, chunk);
};
if !self.lit.contains(&key) {
return self.insert_remote(world, chunk);
}
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Self::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Self::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
}
/// A server's light update for a resident column: the sections whose
/// light changed and their neighbours remesh
/// (`setSectionDirtyWithNeighbors`).
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
/// biomes remesh the same way.
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
for &sy in sections {
for dz in -1..=1 {
@@ -1710,6 +1771,8 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
translucent_layers: self.translucent_layers.clone(),
leaf_culling: self.leaf_culling,
},
),
);
@@ -2361,16 +2424,143 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
}
candidates.sort_unstable();
}
/// What `keep_unchanged_sections` found changed.
#[derive(Debug, Default, PartialEq)]
struct Resent {
/// Any section's blocks changed (in a way light or the mesher sees).
blocks: bool,
/// The sections whose blocks did not change but whose biomes did.
biomes: Vec<usize>,
/// The sections whose blocks did not change but whose light the packet changes.
light: Vec<usize>,
}
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
/// changed keep `old`'s light (until lit again) and their cells' classes go in
/// `cells`.
fn keep_unchanged_sections(
old: &Chunk,
new: &mut Chunk,
cells: &mut crate::light::LightChanges,
) -> Resent {
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
let mut out = Resent::default();
let (x, z) = (new.x, new.z);
for s in 0..new.sections.len().min(old.sections.len()) {
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
old.sections[s].unpack(&mut a);
new.sections[s].unpack(&mut b);
let classes = crate::light::LightChanges::classes_differing(&a, &b);
if classes != 0 {
cells.add((x, s as i32, z), classes);
}
classes == 0
};
if !same {
out.blocks = true;
continue;
}
let biomes = new.sections[s].biomes.clone();
let changed = !same_values(&old.sections[s].biomes, &biomes);
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
.then(|| new.sections[s].light.clone());
new.sections[s] = old.sections[s].clone();
if changed {
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
out.biomes.push(s);
}
if let Some(light) = light {
Arc::make_mut(&mut new.sections[s]).light = light;
out.light.push(s);
}
}
out
}
/// 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)
}
}
}
/// Whether two biome containers (64 cells) hold the same values.
fn same_values(
a: &horizon_core::paletted::PalettedContainer,
b: &horizon_core::paletted::PalettedContainer,
) -> bool {
if a.same_storage(b) {
return true;
}
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
a.unpack(&mut x);
b.unpack(&mut y);
x == y
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
let mut old = Chunk::empty(2, -3);
old.set(1, 10, 1, 1);
old.set(1, 40, 1, 1);
old.set_biome_cell(0, 0, 0, 1);
let mut new = old.clone();
// a new biome under the same blocks (section 0), and a block changed (section 6)
new.set_biome_cell(0, 0, 0, 3);
new.set(1, 40, 1, 2);
let mut cells = crate::light::LightChanges::default();
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
assert_eq!(
kept,
Resent {
blocks: true,
biomes: vec![0],
light: vec![],
}
);
assert_eq!(new.biome(0, -64, 0), 3);
assert_eq!(new.block(1, -54, 1).0, 0);
assert_eq!(new.block(1, 40, 1).0, 2);
// the sections alike are shared with the old column
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
// nothing changed: nothing to do
let mut same = old.clone();
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
assert_eq!(kept, Resent::default());
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
// the same blocks under new light: the packet's light, as vanilla applies it
let mut lit = old.clone();
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
assert_eq!(
kept,
Resent {
light: vec![2],
..Resent::default()
}
);
assert_eq!(lit.sections[2].light.get(0), 0xF0);
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
}
#[test]
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
let mut mesh = SectionMesh::default();
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
mesh.block_entities.push(([1, 2, 3], 7));
mesh.lights.push(([1, 2, 3], 7));
mesh.visibility = 0;
mesh.sides = crate::mesher::Sides::unsorted(16);
let capacities = mesh.capacity_bytes();
@@ -2387,6 +2577,7 @@ mod tests {
assert!(reused.solid.is_empty());
assert!(reused.translucent.is_empty());
assert!(reused.block_entities.is_empty());
assert!(reused.lights.is_empty());
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
assert_eq!(reused.sides, crate::mesher::Sides::default());
assert_eq!(pool.bytes, 0);
+85 -6
View File
@@ -92,19 +92,29 @@ fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
// `tiled`: a distant tile's face takes its sprite again per block, so its gradients span many
// sprites while `uv` stays inside one (see `tile_uv` below).
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
let at = (center + offset) * pixel;
if tiled {
// An anisotropic sampler lays its taps along those gradients, past the sprite's edge,
// where the clamped sampler reads that edge texel again: long streaks on the far tiles.
// Asking for the level the gradients give takes the one tap an anisotropy of 1 would.
let level = max(length(du), length(dv)) / min(pixel.x, pixel.y);
return sprite_level(at, layer, max(0.0, log2(level)));
}
return sprite_grad(at, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
@@ -124,7 +134,7 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -137,12 +147,15 @@ fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
let du = dpdx(tiled);
let dv = dpdy(tiled);
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
return sample_rgss(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
struct TerrainOut {
@@ -295,6 +308,56 @@ fn fade_code(w: f32) -> u32 {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid terrain draws in two passes from the same draws: first the quads with no
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
}
fn left_out() -> TerrainOut {
var out: TerrainOut;
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
@vertex fn vs_terrain_opaque(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_cutout(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_flat_opaque(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
let origin = slot_origins[block_slots[vertex >> 8u]];
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_flat_cutout(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
let origin = slot_origins[block_slots[vertex >> 8u]];
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
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;
@@ -344,6 +407,13 @@ fn brightness(c: vec3<f32>) -> f32 {
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
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);
return solid_finish(in, texel, color);
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
@@ -360,6 +430,10 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
// terrain.fsh: the fade-in mixes toward the fog colour.
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return solid_finish(in, texel, color);
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
// brightest. Only pixels as bright as their sprite's threshold may: its
@@ -517,6 +591,11 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
// 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.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
let dist = disc_distance(d, -4.0);
@@ -100,6 +100,9 @@ pub struct World {
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
/// The chunks changed in bulk (`edit_runs`) since the log was last
/// drained, kept while `block_log` is: sent to the players whole.
pub bulk_log: FxHashSet<(i32, i32)>,
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
@@ -208,6 +211,7 @@ impl World {
weather: Weather::default(),
block_log: None,
path_writes: None,
bulk_log: Default::default(),
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
+1
View File
@@ -40,6 +40,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Trigger",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
"horizon.options.invert_x": "Blick-X-Achse umkehren",
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
"horizon.options.sneak_button": "Schleichtaste",
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.menu_navigation": "Menünavigation",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
"horizon.options.lightbar.health": "Gesundheit",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Zeiger",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"horizon.options.search": "Einstellungen durchsuchen …",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
"horizon.pages.controller": "Controller-Einstellungen",
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
"horizon.pages.extras": "Horizon-Extras",
"horizon.pages.settings": "Horizon-Einstellungen",
"horizon.pages.agent": "KI-Agent",
"horizon.pages.split_profiles": "Wer spielt?",
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
"horizon.pages.open.graphics": "Grafik …",
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
"horizon.pages.open.shader_packs": "Shader-Pakete …",
"horizon.pages.open.controller": "Controller-Einstellungen …",
"horizon.pages.open.extras": "Extras …",
"horizon.pages.open.mods": "Mods …",
"horizon.pages.open.agent": "KI-Agent …",
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
"horizon.pages.open.controller_binds": "Controller-Belegung …",
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
"horizon.mods.stopped": " (angehalten)",
"horizon.mods.not_built": " (nicht gebaut)",
"horizon.mods.dev": " (Entwicklung)",
"horizon.mods.settings": "Einstellungen",
"horizon.mods.open_folder": "Modordner öffnen",
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader-Optionen …",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
"horizon.multiplayer.join_friend": "Freund beitreten",
"horizon.multiplayer.invite_code": "Einladungscode",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD-Weite",
"Far Detail": "Ferndetails",
"Cave Culling": "Höhlen-Culling",
"Entity Culling": "Objekt-Culling",
"Chunk Updates": "Chunk-Updates",
"Upload Budget": "Upload-Budget",
"Sky": "Himmel",
"Fog": "Nebel",
"Weather": "Wetter",
"Animated Textures": "Animierte Texturen",
"Present Mode": "Ausgabemodus",
"Frame Pacing": "Bildtakt",
"Worker Threads": "Arbeitsthreads",
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
"UI Brightness": "GUI-Helligkeit",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Performance Overlay": "Leistungsanzeige",
"Full Detail Distance": "Detailweite",
"On": "An",
"Off": "Aus",
"Unlimited": "Unbegrenzt",
"Auto": "Auto",
"Custom": "Benutzerdefiniert",
"Performance": "Leistung",
"Balanced": "Ausgewogen",
"Quality": "Qualität",
"Ultra": "Ultra",
"Ultra Performance": "Ultra-Leistung",
"Native": "Nativ",
"Full": "Voll",
"Reduced": "Reduziert",
"Efficient": "Sparsam",
"Precise": "Präzise",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS und Diagramm",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Keine Kosten",
"Low cost": "Geringe Kosten",
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"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.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
"%s KiB/frame": "%s KiB/Bild",
"%s nits": "%s Nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
"Brightens dark places.": "Hellt dunkle Orte auf.",
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
"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.",
"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 …",
"horizon.loading.opening_world": "Welt wird geöffnet",
"horizon.loading.cancelling_world": "Wird abgebrochen",
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
"horizon.extras.header.lighting": "Beleuchtung",
"horizon.extras.header.third_person": "Dritte Person",
"horizon.extras.header.inventory": "Inventar",
"horizon.extras.header.multiplayer": "Mehrspieler",
"horizon.extras.fullbright": "Maximale Helligkeit",
"horizon.extras.dynamic_lights": "Dynamisches Licht",
"horizon.extras.own_name_tag": "Eigenes Namensschild",
"horizon.extras.auto_reconnect": "Neu verbinden",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
"horizon.extras.sort": "Sortier-Schaltflächen",
"horizon.extras.sort_key": "Sortiertaste",
"horizon.extras.no_ai": "Keine KI",
"horizon.extras.agent_control": "Agentensteuerung",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
"Calibrate...": "Kalibrieren …",
"Calibrate Anyway...": "Trotzdem kalibrieren …",
"Use Automatic Setup": "Automatische Einrichtung verwenden",
"Shortcut: %s": "Tastenkürzel: %s",
"Next": "Weiter",
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
"horizon.splitscreen.profiles.forget": "Entfernen",
"horizon.splitscreen.profiles.create": "Erstellen",
"horizon.splitscreen.profiles.you": " - du",
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
"horizon.splitscreen.profiles.previous": "Zurück",
"horizon.splitscreen.profiles.next": "Weiter",
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
"horizon.splitscreen.profiles.new_title": "Neues Profil",
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
"horizon.splitscreen.profiles.move": "Spielort wechseln",
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
"horizon.friends.title": "Mit Freunden spielen",
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
"horizon.friends.copied": "Kopiert",
"horizon.friends.copy_code": "Code kopieren",
"horizon.friends.stop_sharing": "Teilen beenden",
"horizon.friends.none_yet": "Noch keine Freunde hier.",
"horizon.friends.here_now": "Gerade hier: %s",
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
"horizon.friends.start_sharing": "Teilen starten",
"horizon.friends.troubleshooting": "Fehlerbehebung …",
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
"horizon.osk.shift": "Umschalt",
"horizon.osk.space": "Leertaste",
"horizon.osk.delete": "Löschen",
"horizon.osk.type": "Eingeben",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Schließen",
"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 …"
}
@@ -34,6 +34,8 @@
"horizon.pages.extras": "Horizon Extras",
"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.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
@@ -41,6 +43,7 @@
"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.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.",
@@ -48,6 +51,7 @@
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
@@ -209,5 +213,98 @@
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
"horizon.extras.header.particles": "Particles",
"horizon.extras.header.inventory": "Inventory",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multiplayer",
"horizon.extras.header.sound": "Sound",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Dynamic lights",
"horizon.extras.own_name_tag": "Own name tag",
"horizon.extras.hide_block_particles": "Hide block-break particles",
"horizon.extras.auto_reconnect": "Auto-reconnect",
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
"horizon.extras.sort": "Sort buttons",
"horizon.extras.sort_key": "Sort key",
"horizon.extras.no_ai": "No AI",
"horizon.extras.agent_control": "Agent Control",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
"Calibrate...": "Calibrate...",
"Calibrate Anyway...": "Calibrate Anyway...",
"Use Automatic Setup": "Use Automatic Setup",
"Shortcut: %s": "Shortcut: %s",
"Next": "Next",
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
"horizon.splitscreen.profiles.forget": "Forget",
"horizon.splitscreen.profiles.create": "Create",
"horizon.splitscreen.profiles.you": " - you",
"horizon.splitscreen.profiles.forget_named": "Forget %s",
"horizon.splitscreen.profiles.previous": "Previous",
"horizon.splitscreen.profiles.next": "Next",
"horizon.splitscreen.profiles.new_button": "New Profile...",
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
"horizon.splitscreen.profiles.new_title": "New Profile",
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
"horizon.splitscreen.profiles.switch_title": "Switch profile",
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
"horizon.splitscreen.profiles.move": "Move where you play",
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
"horizon.friends.title": "Play with Friends",
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
"horizon.friends.copied": "Copied",
"horizon.friends.copy_code": "Copy Code",
"horizon.friends.stop_sharing": "Stop Sharing",
"horizon.friends.none_yet": "No friends here yet.",
"horizon.friends.here_now": "Here now: %s",
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
"horizon.friends.start_sharing": "Start Sharing",
"horizon.friends.troubleshooting": "Troubleshooting...",
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Space",
"horizon.osk.delete": "Delete",
"horizon.osk.type": "Type",
"horizon.osk.symbols": "Symbols",
"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."
}
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
}
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Manette",
"horizon.options.vibration": "Vibrations",
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
"horizon.options.lightbar": "Barre lumineuse",
"horizon.options.look_sensitivity": "Sensibilité de la vue",
"horizon.options.invert_x": "Inverser l'axe X de la vue",
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
"horizon.options.sneak_button": "Bouton pour s'accroupir",
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.menu_navigation": "Navigation dans les menus",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointeur",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Packs de shaders",
"horizon.pages.shader_options": "Options des shaders",
"horizon.pages.controller": "Options de la manette",
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
"horizon.pages.extras": "Extras Horizon",
"horizon.pages.settings": "Options Horizon",
"horizon.pages.agent": "Agent IA",
"horizon.pages.split_profiles": "Qui joue ?",
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
"horizon.pages.open.graphics": "Graphismes...",
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
"horizon.pages.open.shader_packs": "Packs de shaders...",
"horizon.pages.open.controller": "Options de la manette...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent IA...",
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
"horizon.pages.open.controller_binds": "Boutons manette...",
"horizon.pages.open.horizon_settings": "Options Horizon...",
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
"horizon.mods.stopped": " (arrêté)",
"horizon.mods.not_built": " (non compilé)",
"horizon.mods.dev": " (dév.)",
"horizon.mods.settings": "Options",
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Options des shaders...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
"horizon.agent.copy_details": "Copier les détails de connexion",
"horizon.multiplayer.join": "Rejoindre",
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
"horizon.multiplayer.join_friend": "Rejoindre un ami",
"horizon.multiplayer.invite_code": "Code d'invitation",
"horizon.multiplayer.relay": "Relais (facultatif)",
"Preset": "Préréglage",
"LOD Distance": "Distance LOD",
"Far Detail": "Détails lointains",
"Cave Culling": "Occlusion des grottes",
"Entity Culling": "Occlusion des entités",
"Chunk Updates": "MàJ chunks",
"Upload Budget": "Transfert",
"Sky": "Ciel",
"Fog": "Brouillard",
"Weather": "Météo",
"Animated Textures": "Textures animées",
"Present Mode": "Présentation",
"Frame Pacing": "Rythme des images",
"Worker Threads": "Threads",
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
"UI Brightness": "Luminosité UI",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Performance Overlay": "Infos de perfs",
"Full Detail Distance": "Dist. détail",
"On": "Oui",
"Off": "Non",
"Unlimited": "Illimité",
"Auto": "Auto",
"Custom": "Personnalisé",
"Performance": "Performances",
"Balanced": "Équilibré",
"Quality": "Qualité",
"Ultra": "Ultra",
"Ultra Performance": "Ultra perfs",
"Native": "Native",
"Full": "Complet",
"Reduced": "Réduit",
"Efficient": "Économe",
"Precise": "Précis",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS et graphique",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Aucun coût",
"Low cost": "Coût faible",
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"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.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
"Draws rain and snow.": "Dessine la pluie et la neige.",
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
"Brightens dark places.": "Éclaircit les endroits sombres.",
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
"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.",
"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...",
"horizon.loading.opening_world": "Ouverture du monde",
"horizon.loading.cancelling_world": "Annulation en cours",
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Éclairage",
"horizon.extras.header.third_person": "Troisième personne",
"horizon.extras.header.inventory": "Inventaire",
"horizon.extras.header.multiplayer": "Multijoueur",
"horizon.extras.fullbright": "Pleine luminosité",
"horizon.extras.dynamic_lights": "Lumières dynamiques",
"horizon.extras.own_name_tag": "Mon pseudo",
"horizon.extras.auto_reconnect": "Reconnexion auto",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
"horizon.extras.sort": "Boutons de tri",
"horizon.extras.sort_key": "Touche de tri",
"horizon.extras.no_ai": "Sans IA",
"horizon.extras.agent_control": "Contrôle par agent",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
"Calibrate...": "Calibrer...",
"Calibrate Anyway...": "Calibrer quand même...",
"Use Automatic Setup": "Utiliser la configuration automatique",
"Shortcut: %s": "Raccourci : %s",
"Next": "Suivant",
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
"horizon.splitscreen.profiles.forget": "Oublier",
"horizon.splitscreen.profiles.create": "Créer",
"horizon.splitscreen.profiles.you": " - vous",
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
"horizon.splitscreen.profiles.previous": "Précédent",
"horizon.splitscreen.profiles.next": "Suivant",
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
"horizon.friends.title": "Jouer avec des amis",
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
"horizon.friends.copied": "Copié",
"horizon.friends.copy_code": "Copier le code",
"horizon.friends.stop_sharing": "Arrêter le partage",
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
"horizon.friends.here_now": "Présents : %s",
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
"horizon.friends.start_sharing": "Commencer le partage",
"horizon.friends.troubleshooting": "Dépannage...",
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
"horizon.osk.shift": "Maj.",
"horizon.osk.space": "Espace",
"horizon.osk.delete": "Effacer",
"horizon.osk.type": "Saisir",
"horizon.osk.symbols": "Symboles",
"horizon.osk.close": "Fermer",
"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..."
}
@@ -12,11 +12,282 @@
"horizon.options.button_glyphs": "Symbole przycisków",
"horizon.options.menu_navigation": "Nawigacja po menu",
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.options.chat_delay": "Opóźnienie czatu",
"horizon.options.lightbar.health": "Zdrowie",
"horizon.options.lightbar.dimension": "Wymiar",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wskaźnik",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"horizon.options.search": "Szukaj ustawień…",
"horizon.pages.mods": "Mody",
"horizon.pages.shader_packs": "Paczki shaderów",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.controller": "Ustawienia kontrolera",
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
"horizon.pages.extras": "Dodatki Horizon",
"horizon.pages.settings": "Ustawienia Horizon",
"horizon.pages.agent": "Agent AI",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
"horizon.pages.open.graphics": "Grafika…",
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
"horizon.pages.open.shader_packs": "Paczki shaderów…",
"horizon.pages.open.controller": "Ustawienia kontrolera…",
"horizon.pages.open.extras": "Dodatki…",
"horizon.pages.open.mods": "Mody…",
"horizon.pages.open.agent": "Agent AI…",
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
"horizon.mods.stopped": " (zatrzymany)",
"horizon.mods.not_built": " (niezbudowany)",
"horizon.mods.dev": " (dev)",
"horizon.mods.settings": "Ustawienia",
"horizon.mods.open_folder": "Otwórz folder z modami",
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
"horizon.agent.copy_details": "Kopiuj dane połączenia",
"horizon.multiplayer.join": "Dołącz",
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
"horizon.multiplayer.invite_code": "Kod zaproszenia",
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
"Preset": "Profil",
"LOD Distance": "Zasięg LOD",
"Far Detail": "Dalekie detale",
"Cave Culling": "Pomijanie jaskiń",
"Entity Culling": "Pomijanie bytów",
"Chunk Updates": "Aktual. chunków",
"Upload Budget": "Wysyłka",
"Sky": "Niebo",
"Fog": "Mgła",
"Weather": "Pogoda",
"Animated Textures": "Animowane tekstury",
"Present Mode": "Prezentacja",
"Frame Pacing": "Tempo klatek",
"Worker Threads": "Wątki robocze",
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
"UI Brightness": "Jasność interfejsu",
"Battery Mode": "Tryb baterii",
"Graphics API": "API grafiki",
"Performance Overlay": "Nakładka wydajności",
"Full Detail Distance": "Zasięg detali",
"On": "Wł.",
"Off": "Wył.",
"Unlimited": "Bez limitu",
"Auto": "Auto",
"Custom": "Własne",
"Performance": "Wydajność",
"Balanced": "Zrównoważone",
"Quality": "Jakość",
"Ultra": "Ultra",
"Ultra Performance": "Ultra wydajność",
"Native": "Natywna",
"Full": "Pełne",
"Reduced": "Ograniczone",
"Efficient": "Oszczędne",
"Precise": "Dokładne",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS i wykres",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Bez kosztu",
"Low cost": "Niski koszt",
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"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ć.",
"%s chunks": "%s chunków",
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
"%s KiB/frame": "%s KiB/klatka",
"%s nits": "%s nitów",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nitów)",
"%.2f nits": "%.2f nitów",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
"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.",
"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…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie",
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
"horizon.extras.header.inventory": "Ekwipunek",
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
"horizon.extras.fullbright": "Pełna jasność",
"horizon.extras.dynamic_lights": "Dynamiczne światła",
"horizon.extras.own_name_tag": "Własny nick",
"horizon.extras.auto_reconnect": "Ponowne łączenie",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
"horizon.extras.sort": "Przyciski sortowania",
"horizon.extras.sort_key": "Klawisz sort.",
"horizon.extras.no_ai": "Bez AI",
"horizon.extras.agent_control": "Sterowanie agentem",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
"Calibrate...": "Kalibruj…",
"Calibrate Anyway...": "Kalibruj mimo to…",
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
"Shortcut: %s": "Skrót: %s",
"Next": "Dalej",
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
"horizon.splitscreen.profiles.forget": "Zapomnij",
"horizon.splitscreen.profiles.create": "Utwórz",
"horizon.splitscreen.profiles.you": " - ty",
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
"horizon.splitscreen.profiles.previous": "Poprzednia",
"horizon.splitscreen.profiles.next": "Dalej",
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
"horizon.splitscreen.profiles.new_title": "Nowy profil",
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
"horizon.friends.title": "Graj ze znajomymi",
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
"horizon.friends.copied": "Skopiowano",
"horizon.friends.copy_code": "Kopiuj kod",
"horizon.friends.stop_sharing": "Przestań udostępniać",
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
"horizon.friends.here_now": "Obecnie: %s",
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
"horizon.friends.start_sharing": "Zacznij udostępniać",
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Spacja",
"horizon.osk.delete": "Usuń",
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij"
}
@@ -202,5 +202,11 @@
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
"horizon.pages.shader_options": "Настройки шейдеров",
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
}
@@ -196,5 +196,11 @@
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
}
@@ -12,11 +12,282 @@
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.options.chat_delay": "Затримка чату",
"horizon.options.lightbar.health": "Здоров’я",
"horizon.options.lightbar.dimension": "Вимір",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Курсор",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
"horizon.options.search": "Шукати налаштування…",
"horizon.pages.mods": "Моди",
"horizon.pages.shader_packs": "Пакети шейдерів",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.controller": "Налаштування геймпада",
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
"horizon.pages.extras": "Додатки Horizon",
"horizon.pages.settings": "Налаштування Horizon",
"horizon.pages.agent": "ШІ-агент",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
"horizon.pages.open.graphics": "Графіка…",
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
"horizon.pages.open.controller": "Налаштування геймпада…",
"horizon.pages.open.extras": "Додатки…",
"horizon.pages.open.mods": "Моди…",
"horizon.pages.open.agent": "ШІ-агент…",
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
"horizon.mods.stopped": " (зупинено)",
"horizon.mods.not_built": " (не зібрано)",
"horizon.mods.dev": " (розробка)",
"horizon.mods.settings": "Налаштування",
"horizon.mods.open_folder": "Відкрити теку з модами",
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
"horizon.agent.copy_details": "Копіювати дані з’єднання",
"horizon.multiplayer.join": "Приєднатися",
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
"horizon.multiplayer.join_friend": "Приєднатися до друга",
"horizon.multiplayer.invite_code": "Код запрошення",
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
"Preset": "Шаблон",
"LOD Distance": "Дальність LOD",
"Far Detail": "Деталі вдалині",
"Cave Culling": "Відсікання печер",
"Entity Culling": "Відсік. сутностей",
"Chunk Updates": "Оновл. чанків",
"Upload Budget": "Вивантаження",
"Sky": "Небо",
"Fog": "Туман",
"Weather": "Погода",
"Animated Textures": "Анімовані текстури",
"Present Mode": "Виведення",
"Frame Pacing": "Темп кадрів",
"Worker Threads": "Робочі потоки",
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
"UI Brightness": "Яскравість UI",
"Battery Mode": "Режим батареї",
"Graphics API": "API графіки",
"Performance Overlay": "Накладка швидкодії",
"Full Detail Distance": "Дальність деталей",
"On": "Увімк.",
"Off": "Вимк.",
"Unlimited": "Без обмежень",
"Auto": "Авто",
"Custom": "Власний",
"Performance": "Продуктивність",
"Balanced": "Збалансований",
"Quality": "Якість",
"Ultra": "Ультра",
"Ultra Performance": "Ультрапродукт.",
"Native": "Нативна",
"Full": "Повна",
"Reduced": "Знижена",
"Efficient": "Економний",
"Precise": "Точний",
"Auto (VSync)": "Авто (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS і графік",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Без витрат",
"Low cost": "Низькі витрати",
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"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, щоб користуватися цим.",
"%s chunks": "%s чанків",
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
"%s KiB/frame": "%s KiB/кадр",
"%s nits": "%s ніт",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
"Size of menus and the HUD.": "Розмір меню та HUD.",
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
"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.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
"Draws rain and snow.": "Малює дощ і сніг.",
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
"Brightens dark places.": "Освітлює темні місця.",
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
"Sways the view while walking.": "Розхитує вид під час ходьби.",
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
"Darkens the screen's edges.": "Затемнює краї екрана.",
"Shows when the world saves.": "Показує, коли світ зберігається.",
"Shows attack cooldown.": "Показує перезарядку атаки.",
"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). Після рестарту.",
"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": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування",
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
"horizon.extras.header.inventory": "Інвентар",
"horizon.extras.header.multiplayer": "Гра в мережі",
"horizon.extras.fullbright": "Повна яскравість",
"horizon.extras.dynamic_lights": "Динамічне світло",
"horizon.extras.own_name_tag": "Власний нік",
"horizon.extras.auto_reconnect": "Перепідключення",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
"horizon.extras.sort": "Кнопки сортування",
"horizon.extras.sort_key": "Сортувати",
"horizon.extras.no_ai": "Без ШІ",
"horizon.extras.agent_control": "Керування агентом",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
"Calibrate...": "Калібрувати…",
"Calibrate Anyway...": "Все одно калібрувати…",
"Use Automatic Setup": "Використати автоналаштування",
"Shortcut: %s": "Гаряча клавіша: %s",
"Next": "Далі",
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
"horizon.splitscreen.profiles.forget": "Забути",
"horizon.splitscreen.profiles.create": "Створити",
"horizon.splitscreen.profiles.you": " - ви",
"horizon.splitscreen.profiles.forget_named": "Забути %s",
"horizon.splitscreen.profiles.previous": "Назад",
"horizon.splitscreen.profiles.next": "Далі",
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
"horizon.splitscreen.profiles.new_title": "Новий профіль",
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
"horizon.splitscreen.profiles.move": "Змінити місце гри",
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
"horizon.friends.title": "Грати з друзями",
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
"horizon.friends.copied": "Скопійовано",
"horizon.friends.copy_code": "Копіювати код",
"horizon.friends.stop_sharing": "Припинити поширення",
"horizon.friends.none_yet": "Друзів тут поки немає.",
"horizon.friends.here_now": "Зараз тут: %s",
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
"horizon.friends.start_sharing": "Почати поширення",
"horizon.friends.troubleshooting": "Усунення несправностей…",
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Пробіл",
"horizon.osk.delete": "Видалити",
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити"
}
Loaded 100 of 553 files, more files were not shown because too many files have changed in this diff. Show more