mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
commit
dea8e3b54c
553 files changed
+53046
-3004
No files matched your search
@@ -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' || '' }}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()?];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1343
-327
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
|
||||
|
||||
@@ -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, §ions);
|
||||
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, §ions)
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(¶ms));
|
||||
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);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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, ©, 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(©, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = §ion.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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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, ®ion(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, ®ion(&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, ®ion(&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, ®ion(&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, ®ion(&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,
|
||||
®ion(&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, ®ion(&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, ®ion, 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(®ion, 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(®ion, 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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in new issue
Block a user