mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Merge upstream/main into native/api-mods
Upstream added `pub mod lod_net;` to the old lib.rs module list, which this PR moved to client_lib.rs: add it there. Upstream's new horizon-optifine dependency is used by mobs/cem.rs, a reusable-client module, so it goes in horizon-client-lib's manifest instead of horizon-client's. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
8421d34846
302 files changed
+19275
-1299
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' || '' }}
|
||||
@@ -57,6 +57,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
+4
@@ -1400,6 +1400,7 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1534,6 +1535,7 @@ dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1569,6 +1571,7 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -1675,6 +1678,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.)
|
||||
|
||||
@@ -21,6 +21,7 @@ horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
|
||||
@@ -96,6 +96,11 @@ 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,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -259,6 +264,9 @@ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -387,6 +387,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 +580,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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn windows_processor_name(query: &str) -> Option<String> {
|
||||
query.lines().find_map(|line| {
|
||||
let (_, value) = line.split_once("REG_SZ")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
command_output(
|
||||
"reg",
|
||||
&[
|
||||
"query",
|
||||
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"/v",
|
||||
"ProcessorNameString",
|
||||
],
|
||||
)
|
||||
.and_then(|query| windows_processor_name(&query))
|
||||
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -636,6 +656,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
|
||||
let query = r#"
|
||||
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
|
||||
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
|
||||
"#;
|
||||
assert_eq!(
|
||||
windows_processor_name(query).as_deref(),
|
||||
Some("13th Gen Intel(R) Core(TM) i7-13700F")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_processor_name("ProcessorNameString REG_SZ "),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
@@ -113,6 +118,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,7 +145,9 @@ 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,
|
||||
@@ -146,6 +155,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +223,9 @@ 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),
|
||||
@@ -221,6 +233,7 @@ impl Extras {
|
||||
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 +241,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:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -254,7 +267,9 @@ 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),
|
||||
@@ -263,6 +278,7 @@ impl Extras {
|
||||
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,7 +399,9 @@ pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
HideBlockParticles,
|
||||
AutoReconnect,
|
||||
UiSoundReserve,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
@@ -393,6 +411,8 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -403,6 +423,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +432,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(SortKey),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("Sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -418,7 +442,9 @@ impl Item {
|
||||
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",
|
||||
@@ -426,16 +452,18 @@ impl Item {
|
||||
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 => {
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -450,9 +478,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."
|
||||
}
|
||||
@@ -471,6 +505,9 @@ impl Item {
|
||||
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 +518,14 @@ 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::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -494,13 +535,16 @@ 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::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -521,13 +565,16 @@ 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::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 +596,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 +623,20 @@ 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.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);
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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,11 @@
|
||||
//! 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.
|
||||
//! two real ones. So far this holds its optical flow, its interpolation and the motion
|
||||
//! vectors of what moves on its own; the setting that runs them comes next.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
#[allow(dead_code)]
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
@@ -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>,
|
||||
@@ -79,26 +87,74 @@ 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);
|
||||
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> {
|
||||
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);
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@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 +171,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
|
||||
@@ -134,12 +200,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`).
|
||||
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 +218,24 @@ 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 {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> 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)
|
||||
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)],
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -174,14 +248,16 @@ impl NativeMaterial {
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
) -> Result<Self, String> {
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
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),
|
||||
@@ -346,14 +422,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).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))
|
||||
)
|
||||
});
|
||||
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,23 @@ 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| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
|
||||
.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 +63,34 @@ 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 (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -67,10 +103,10 @@ 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))
|
||||
.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 +115,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 +124,10 @@ impl NativePack {
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
@@ -95,6 +136,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()
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,59 @@ 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);
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -50,6 +106,10 @@ pub struct NativeFrame {
|
||||
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 +117,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 +214,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,19 +229,46 @@ 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 opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
@@ -192,16 +286,49 @@ 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,
|
||||
@@ -241,54 +368,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,8 +485,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,6 +586,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 +624,43 @@ 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),
|
||||
];
|
||||
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 +699,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 +727,7 @@ impl NativePost {
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
textures: frame.textures.clone(),
|
||||
frame: frame.buffer.clone(),
|
||||
shadow: (
|
||||
frame.shadow_map.view.clone(),
|
||||
@@ -557,6 +742,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,
|
||||
})
|
||||
}
|
||||
@@ -573,7 +761,39 @@ impl NativePost {
|
||||
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 {
|
||||
@@ -624,62 +844,79 @@ 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: 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();
|
||||
@@ -706,8 +943,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 +967,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 +1112,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 +1292,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 +1301,7 @@ mod tests {
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
@@ -1053,8 +1412,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)
|
||||
|
||||
@@ -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
|
||||
@@ -56,7 +56,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
|
||||
@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 +67,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 +225,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 +269,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 +278,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 +303,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,14 +319,106 @@ 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{}{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) -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
prefixed(
|
||||
@@ -283,8 +433,9 @@ impl NativeShadow {
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source();
|
||||
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let constants = [
|
||||
@@ -420,6 +571,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 +580,7 @@ impl NativeShadow {
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
@@ -439,7 +592,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]));
|
||||
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 +629,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 +650,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,6 +35,16 @@ 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,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
}
|
||||
@@ -173,6 +183,13 @@ pub const NAMES: &[&str] = &[
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
"heightLimit",
|
||||
"logicalHeightLimit",
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
];
|
||||
|
||||
/// Values carried from frame to frame.
|
||||
@@ -380,6 +397,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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -437,14 +437,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 +461,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 +469,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 +478,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 +491,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()
|
||||
});
|
||||
|
||||
@@ -325,6 +325,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.
|
||||
@@ -364,10 +377,16 @@ pub struct Graphics {
|
||||
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.
|
||||
@@ -409,6 +428,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.
|
||||
@@ -424,10 +448,12 @@ impl Default for Graphics {
|
||||
entity_culling: false,
|
||||
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,
|
||||
@@ -446,6 +472,7 @@ impl Default for Graphics {
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
translucent_batching: false,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -490,10 +517,14 @@ impl Graphics {
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"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" {
|
||||
@@ -532,6 +563,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,7 +580,7 @@ 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:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -556,10 +588,12 @@ impl Graphics {
|
||||
bool_text(self.entity_culling),
|
||||
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",
|
||||
@@ -586,6 +620,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.
|
||||
@@ -709,10 +744,12 @@ mod tests {
|
||||
entity_culling: true,
|
||||
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,
|
||||
@@ -731,10 +768,13 @@ 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);
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum H {
|
||||
Fog,
|
||||
Weather,
|
||||
AnimatedTextures,
|
||||
DitherCutoutMips,
|
||||
PresentMode,
|
||||
Pacing,
|
||||
Workers,
|
||||
@@ -261,6 +262,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,
|
||||
@@ -525,6 +531,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",
|
||||
@@ -646,6 +653,7 @@ impl H {
|
||||
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 => {
|
||||
@@ -733,6 +741,7 @@ impl Item {
|
||||
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,
|
||||
|
||||
@@ -544,6 +544,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() {
|
||||
@@ -567,6 +578,12 @@ impl HudAtlas {
|
||||
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()?;
|
||||
@@ -2000,9 +2017,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 +2030,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 +2061,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,
|
||||
|
||||
@@ -494,7 +494,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 +531,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 +605,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 +618,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 +661,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 +868,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,
|
||||
@@ -1411,14 +1490,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)
|
||||
@@ -2183,11 +2263,10 @@ 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)
|
||||
{
|
||||
continue;
|
||||
@@ -2203,17 +2282,8 @@ 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,
|
||||
}));
|
||||
}
|
||||
self.wanted
|
||||
.extend(wanted_tiles(&self.plan, &self.ready, camera).map(std::cmp::Reverse));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2607,14 +2677,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 +3328,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 {
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -231,6 +231,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 +251,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 +282,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 +293,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
|
||||
@@ -1124,6 +1130,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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -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,6 +2262,14 @@ 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.
|
||||
@@ -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)
|
||||
@@ -5153,6 +5224,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 +5253,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 +5265,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 +6814,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());
|
||||
|
||||
@@ -1024,6 +1024,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 +1100,7 @@ impl PostEffects {
|
||||
(&work.1, size),
|
||||
0.0,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.staging = Some(staging);
|
||||
for id in &applied {
|
||||
@@ -1113,6 +1115,7 @@ impl PostEffects {
|
||||
depth,
|
||||
req.menu_blur_radius,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.chains.insert(id.clone(), Some(chain));
|
||||
}
|
||||
@@ -1126,6 +1129,7 @@ impl PostEffects {
|
||||
(target, size),
|
||||
0.0,
|
||||
format,
|
||||
timing,
|
||||
);
|
||||
self.blit = Some(blit);
|
||||
self.work = Some(work);
|
||||
@@ -1144,6 +1148,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 +1208,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 +1227,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 +1281,7 @@ impl PostEffects {
|
||||
&inputs,
|
||||
(view(pass.output), sizes[pass.output]),
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1287,6 +1295,7 @@ impl PostEffects {
|
||||
(view(pass.output), sizes[pass.output]),
|
||||
menu_radius,
|
||||
format,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1312,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 +1363,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 +1392,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 +1453,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 +1728,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 +1859,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()]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -792,6 +795,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 +825,7 @@ impl SoundEngine {
|
||||
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
|
||||
source_volumes: [1.0; 11],
|
||||
listening: false,
|
||||
listener_reserve: 0,
|
||||
notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1100,7 +1107,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 +1564,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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +347,7 @@ impl Sprites {
|
||||
decoded
|
||||
}
|
||||
};
|
||||
self.register(name, decoded)
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
@@ -263,40 +368,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 +393,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 +423,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 +445,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 +460,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 +502,66 @@ 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.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 +607,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);
|
||||
|
||||
@@ -2371,6 +2371,7 @@ mod tests {
|
||||
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 +2388,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
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -134,7 +134,7 @@ fn main() -> Result<()> {
|
||||
.collect();
|
||||
let descriptor = BlockState {
|
||||
name: name.clone(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
};
|
||||
let Some(id) = registry.id(&descriptor) else {
|
||||
states.insert(key.clone(), Value::Null);
|
||||
|
||||
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -478,7 +478,7 @@ pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
|
||||
inside
|
||||
}
|
||||
/// `Projectile.lerpRotation`.
|
||||
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
pub fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
while new - old < -180.0 {
|
||||
old -= 360.0;
|
||||
}
|
||||
|
||||
@@ -445,6 +445,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
|
||||
@@ -6,7 +6,19 @@ use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
/// Revisions this server implements. Older ones are not claimed: only the revision actually
|
||||
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
|
||||
const SUPPORTED: &[&str] = &["2025-06-18"];
|
||||
|
||||
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
|
||||
fn negotiate_version(params: &Value) -> &'static str {
|
||||
let wanted = params.get("protocolVersion").and_then(Value::as_str);
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| Some(*v) == wanted)
|
||||
.unwrap_or(SUPPORTED[0])
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
@@ -134,7 +146,7 @@ impl Session {
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"protocolVersion": negotiate_version(params),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn version_negotiation() {
|
||||
let v = |p: Value| negotiate_version(&p);
|
||||
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
|
||||
assert_eq!(v(json!({})), "2025-06-18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
@@ -234,9 +256,9 @@ mod tests {
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert_eq!(init["protocolVersion"], "2025-06-18");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
|
||||
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
|
||||
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
|
||||
pub fn collected_components(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
collected
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
for (component, value) in collected_components(saved) {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,7 @@ pub mod item_tooltip;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mobs;
|
||||
|
||||
@@ -1356,10 +1356,11 @@ impl Recipes {
|
||||
None => sources.push((name, bytes)),
|
||||
}
|
||||
}
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
let mut v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
@@ -1373,6 +1374,14 @@ impl Recipes {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
// The data's recipe filters, over the final set.
|
||||
let full_id = match name.contains(':') {
|
||||
true => name.clone(),
|
||||
false => format!("minecraft:{name}"),
|
||||
};
|
||||
if !filters.apply(&full_id, &mut v) {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
@@ -2163,6 +2172,44 @@ mod tests {
|
||||
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
/// A pack's recipe filter removes a recipe and swaps a mod recipe's input
|
||||
/// in the loaded tables, after the packs are merged.
|
||||
#[test]
|
||||
fn recipe_filters_remove_and_swap() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-filters-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
let crusher = |input: &str| {
|
||||
format!(
|
||||
r#"{{"type":"mymod:crusher","input":"{input}","result":{{"id":"mymod:dust"}}}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/iron.json"),
|
||||
crusher("minecraft:iron_ingot"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/gold.json"),
|
||||
crusher("minecraft:gold_ingot"),
|
||||
)
|
||||
.unwrap();
|
||||
let filter = br##"{"filters":[
|
||||
{"id":"minecraft:gold","remove":true},
|
||||
{"type":"mymod:*","replace_input":{"from":"minecraft:iron_ingot","to":"#c:ingots/iron"}}
|
||||
]}"##;
|
||||
let packs = [("recipe_filter/pack:f".to_owned(), vec![filter.to_vec()])];
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
|
||||
let crushed = &recipes.custom["mymod:crusher"];
|
||||
assert_eq!(
|
||||
crushed.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["minecraft:iron"]
|
||||
);
|
||||
assert_eq!(crushed[0].json["input"], "#c:ingots/iron");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
|
||||
@@ -86,15 +86,17 @@ impl Displayer {
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
labelled_pass_lines("Shader pack GPU", passes)
|
||||
}
|
||||
|
||||
/// `label` and the passes' total, then the costliest passes by GPU time.
|
||||
pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut lines = vec![format!("{label}: {total:.2} ms in {} passes", passes.len())];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
|
||||
@@ -154,6 +154,7 @@ mod lan_camera;
|
||||
mod lan_chat;
|
||||
mod lan_crafting;
|
||||
mod lan_entities;
|
||||
mod lan_far;
|
||||
mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
@@ -187,6 +188,7 @@ mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod narrator;
|
||||
mod native_biome_colors;
|
||||
mod native_block_entity_draw;
|
||||
mod native_components;
|
||||
mod native_criteria;
|
||||
@@ -196,7 +198,9 @@ mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_net;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
@@ -764,6 +768,9 @@ enum Screen {
|
||||
Win,
|
||||
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
|
||||
DebugOptions,
|
||||
/// A screen of a native mod's own (`native_mod_screen_open`): it draws and
|
||||
/// takes its input through the screen hooks.
|
||||
Mod,
|
||||
}
|
||||
impl Screen {
|
||||
/// The id native mods see for this screen (`NativeScreenView.id`, the
|
||||
@@ -790,6 +797,7 @@ impl Screen {
|
||||
Screen::DialogWarning => "dialog_warning",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug_options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -871,6 +879,13 @@ struct GameLevel<'w> {
|
||||
dropped: &'w [ItemEntity],
|
||||
orbs: &'w [ExperienceOrb],
|
||||
projectiles: &'w [horizon_client::projectiles::Projectile],
|
||||
/// The tridents, firework rockets and fishing hooks here. None ignores
|
||||
/// block triggers (`Entity.isIgnoringBlockTriggers`), so each counts on a
|
||||
/// plate or tripwire (`getEntitiesOfClass`). The flag says whether it also
|
||||
/// reaches `entityInside` through this list (`applyEffectsFromBlocks`):
|
||||
/// not a rocket boosting a player or a hook on an entity, whose ticks
|
||||
/// skip that step, nor a trident, which `tick_blocks` sweeps itself.
|
||||
trigger_projectiles: Vec<(Aabb, bool)>,
|
||||
biomes: &'w Biomes,
|
||||
seed: i64,
|
||||
behaviours: &'w Behaviours,
|
||||
@@ -1548,6 +1563,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
let others = || {
|
||||
self.other_trigger_boxes()
|
||||
.chain(self.trigger_projectiles.iter().map(|&(bounds, _)| bounds))
|
||||
.filter(|b| area.intersects(*b))
|
||||
.count()
|
||||
};
|
||||
@@ -1648,6 +1664,8 @@ struct FallingBlock {
|
||||
age: i32,
|
||||
/// `Entity.isNoGravity`.
|
||||
no_gravity: bool,
|
||||
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
|
||||
portal: horizon_client::nether_portal::PortalState,
|
||||
/// `FallingBlockEntity.dropItem`.
|
||||
drop_item: bool,
|
||||
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
|
||||
@@ -1738,8 +1756,8 @@ struct WorldRest {
|
||||
/// recipes, loot tables and tags from the world's pack stack, over the
|
||||
/// resource root's own. Worlds without packs use `Resources.rest` directly.
|
||||
struct WorldTables {
|
||||
/// The pack files the tables were built from, for the advancement tree
|
||||
/// reload and the loot data.
|
||||
/// The pack files the tables were built from (a native mod's among
|
||||
/// them), for the advancement tree reload and the loot data.
|
||||
files: Vec<(String, Vec<Vec<u8>>)>,
|
||||
items: Items,
|
||||
recipes: Recipes,
|
||||
@@ -2216,6 +2234,8 @@ struct Game<'a> {
|
||||
/// Minecraft.pause: a single-player screen that pauses the game is open.
|
||||
pause: bool,
|
||||
debug: bool,
|
||||
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
|
||||
gpu_passes: bool,
|
||||
screen: Screen,
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
@@ -2227,9 +2247,13 @@ struct Game<'a> {
|
||||
luminance: Option<horizon_client::dynamic_light::Luminance>,
|
||||
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
|
||||
see_through_vertices: Vec<Vertex>,
|
||||
/// The first-person player's model, which only a native pack's shadow pass draws.
|
||||
shadow_only_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
|
||||
/// while `pack_ids` is set (game/shader_packs.rs).
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
@@ -2336,6 +2360,10 @@ struct Game<'a> {
|
||||
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
|
||||
/// it was by connection (None: the host).
|
||||
last_hurt_by_player: HashMap<u32, Option<u32>>,
|
||||
/// Leashed mobs a player killed, with the killer's UUID: the lead drops
|
||||
/// on the corpse's next tick (`Leashable.tickLeash`), for the mods. With
|
||||
/// the corpse's dimension: each level's tick prunes only its own.
|
||||
lead_killers: HashMap<u32, (u128, Dim)>,
|
||||
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
|
||||
/// connection (None: the host). Breeding credits them, wherever they are.
|
||||
love_cause: HashMap<u32, Option<u32>>,
|
||||
@@ -2614,6 +2642,8 @@ struct Game<'a> {
|
||||
native_lifecycle: native_lifecycle::NativeLifecycle,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
native_net: native_net::NativeNet,
|
||||
/// The mod whose own screen is open (`Screen::Mod`).
|
||||
native_mod_screen: Option<String>,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
|
||||
/// This frame's view frustum, for culling entities and block entities.
|
||||
@@ -2806,6 +2836,8 @@ struct Game<'a> {
|
||||
border: horizon_client::worldborder::WorldBorder,
|
||||
/// `WorldBorderRenderer` and its render state.
|
||||
border_renderer: horizon_client::border_render::BorderRenderer,
|
||||
/// The camera's 16-block section when the wall was last invalidated.
|
||||
border_section: Option<[i32; 3]>,
|
||||
border_state: horizon_client::border_render::BorderRenderState,
|
||||
/// `Hud.tabList`: the player list's header, footer and heart states.
|
||||
tab_list: horizon_client::tab_list::TabList,
|
||||
@@ -3428,6 +3460,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
@@ -3439,6 +3474,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.addBreakingParticles: one crumb on the face being mined.
|
||||
fn crack_particle(&mut self, pos: [i32; 3], face: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
@@ -3562,6 +3600,10 @@ impl Game<'_> {
|
||||
// Block.popResource: Mth.nextDouble offsets from the level random; the
|
||||
// motion is `new ItemEntity(..)`'s own, from the item's random.
|
||||
let p = spill::pop_position(&mut self.world.random, pos);
|
||||
// Block.popResource draws its offsets before the private overload checks the gamerule.
|
||||
if !self.ui.rules.bool("block_drops") {
|
||||
return;
|
||||
}
|
||||
let v = dispenser::new_item_motion();
|
||||
self.spawn_item(stack, p, v, 10);
|
||||
}
|
||||
@@ -3889,6 +3931,18 @@ impl Game<'_> {
|
||||
.map(|g| g.position)
|
||||
}))
|
||||
.collect();
|
||||
let mut trigger_projectiles: Vec<(Aabb, bool)> = self
|
||||
.tridents
|
||||
.iter()
|
||||
.map(|t| (horizon_client::arrows::arrow_bounds(t.position), false))
|
||||
.chain(self.rockets.iter().map(|r| (r.bounds(), !r.attached)))
|
||||
.chain(
|
||||
self.hook
|
||||
.iter()
|
||||
.map(|h| (h.bounds(), h.applies_block_effects())),
|
||||
)
|
||||
.collect();
|
||||
self.lan_hook_triggers(&mut trigger_projectiles);
|
||||
let blockers = Blockers {
|
||||
mobs: &self.mobs,
|
||||
armor_stands: &self.armor_stands,
|
||||
@@ -3906,6 +3960,7 @@ impl Game<'_> {
|
||||
dropped: &self.dropped,
|
||||
orbs: &self.orbs,
|
||||
projectiles: &self.creatures.projectiles,
|
||||
trigger_projectiles,
|
||||
world: &mut self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
@@ -4277,7 +4332,16 @@ impl Game<'_> {
|
||||
}
|
||||
// The mobs and the other entities (items, orbs, projectiles...) too.
|
||||
let mut touched: Vec<BlockPos> = Vec::new();
|
||||
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
|
||||
let projectiles = level
|
||||
.trigger_projectiles
|
||||
.iter()
|
||||
.filter(|(_, applies)| *applies)
|
||||
.map(|&(bounds, _)| bounds);
|
||||
for bounds in level
|
||||
.mob_trigger_boxes()
|
||||
.chain(level.other_trigger_boxes())
|
||||
.chain(projectiles)
|
||||
{
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
|
||||
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
|
||||
@@ -4719,6 +4783,7 @@ impl Game<'_> {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 0,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -4758,7 +4823,7 @@ impl Game<'_> {
|
||||
}
|
||||
PlayerEffect::InteractLoot(_) => {}
|
||||
},
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80),
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
@@ -5064,7 +5129,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// One `FallingBlockEntity.tick` move of `f`.
|
||||
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
if !f.no_gravity {
|
||||
@@ -5110,30 +5175,11 @@ impl Game<'_> {
|
||||
f.position.z + movement.z,
|
||||
);
|
||||
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
|
||||
// FallingBlockEntity.tick: off the ground, it gives up after 600
|
||||
// ticks (100 outside the world) and drops itself as an item.
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if next.y < f.position.y {
|
||||
f.fall_distance += f.position.y - next.y;
|
||||
}
|
||||
let from = f.position;
|
||||
f.position = next;
|
||||
if on_ground {
|
||||
let pos = [
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
];
|
||||
return Ok(FallStep::Landed(pos));
|
||||
}
|
||||
// Entity.move stops it on the axes it collided on.
|
||||
if movement.x != wanted.x {
|
||||
f.velocity.x = 0.0;
|
||||
@@ -5144,6 +5190,10 @@ impl Game<'_> {
|
||||
if movement.z != wanted.z {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
if on_ground {
|
||||
self.falling_block_impact(f);
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
|
||||
// drags it. Entity.move records the move unless it was blocked short
|
||||
@@ -5175,12 +5225,44 @@ impl Game<'_> {
|
||||
if stuck {
|
||||
f.stuck = horizon_client::falling_block::web_multiplier();
|
||||
}
|
||||
// FallingBlockEntity.tick handles its portal after the move's block
|
||||
// effects, before placing/dropping on the floor under an End portal.
|
||||
let teleported = self.falling_through_portal(f);
|
||||
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
|
||||
// either way through the End deliberately keeps the source's final
|
||||
// placement/drop branch, even though the source entity was removed.
|
||||
if let Some(target) = teleported
|
||||
&& self.dim != Dim::End
|
||||
&& target != Dim::End
|
||||
{
|
||||
return Ok(FallStep::Gone);
|
||||
}
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if on_ground {
|
||||
return Ok(FallStep::Landed([
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
]));
|
||||
}
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x * 0.98,
|
||||
f.velocity.y * 0.98,
|
||||
f.velocity.z * 0.98,
|
||||
);
|
||||
Ok(FallStep::Falling)
|
||||
Ok(if teleported.is_some() {
|
||||
FallStep::Gone
|
||||
} else {
|
||||
FallStep::Falling
|
||||
})
|
||||
}
|
||||
/// `Entity.spawnAtLocation`: an item entity where the block was.
|
||||
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
|
||||
@@ -5332,6 +5414,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
}
|
||||
if creative && storage::is_shulker_box(&facts.name) {
|
||||
self.drop_creative_shulker(pos);
|
||||
}
|
||||
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
|
||||
// with its bees and honey level.
|
||||
if creative
|
||||
@@ -7700,7 +7785,12 @@ impl Game<'_> {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
let max_damage = info.map_or(0, |info| info.max_damage as i32);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
@@ -8079,6 +8169,7 @@ impl Game<'_> {
|
||||
Screen::Dead => self.draw_death_screen(&mut batch),
|
||||
Screen::Stats => self.draw_stats(&mut batch),
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::Mod => {}
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
@@ -8118,11 +8209,12 @@ impl Game<'_> {
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
|
||||
/// screen, once a frame; the Win and Options screens call this on their
|
||||
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
|
||||
/// playing with no screen. Client only, read-only: what a mod draws is
|
||||
/// its own.
|
||||
/// playing with no screen, nor while the server pack prompt hides the
|
||||
/// open screen (`hud_layers` draws `Playing` then). Client only,
|
||||
/// read-only: what a mod draws is its own.
|
||||
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing {
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
@@ -10072,6 +10164,11 @@ impl Game<'_> {
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
camera_rotation(self.camera_type, self.yaw, self.pitch)
|
||||
}
|
||||
/// The entity whose geometry starts at vertex `start` (0: none), for frame
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
pub(super) fn mark_motion(&self, start: usize, id: u64) {
|
||||
self.entity_motion.borrow_mut().push((start as u32, id));
|
||||
}
|
||||
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
|
||||
fn entity_geometry(
|
||||
&mut self,
|
||||
@@ -10080,6 +10177,7 @@ impl Game<'_> {
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
self.drain_transient_block_queue();
|
||||
self.prepare_entity_culling(camera);
|
||||
@@ -10184,6 +10282,7 @@ impl Game<'_> {
|
||||
// `ItemEntityRenderer`.
|
||||
self.mark_entity(out.len(), "minecraft:item");
|
||||
self.dropped_item_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
@@ -10196,9 +10295,18 @@ impl Game<'_> {
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
|
||||
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
|
||||
// the first-person model.
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
self.shadow_only_vertices
|
||||
.borrow_mut()
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.guest_model_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
@@ -10213,9 +10321,11 @@ impl Game<'_> {
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
let shadow_sprite = self
|
||||
.settings
|
||||
.entity_shadows
|
||||
// A native shader pack's sun shadows already fall under the entities in the Overworld:
|
||||
// the round blob would be a second one. The Nether and the End have no sun to cast them.
|
||||
let pack_shadows =
|
||||
self.dim == Dim::Overworld && self.renderer.as_ref().is_some_and(|r| r.native_shadow());
|
||||
let shadow_sprite = (self.settings.entity_shadows && !pack_shadows)
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
@@ -11345,6 +11455,9 @@ impl Game<'_> {
|
||||
&self.options.resources,
|
||||
&pack_files(&self.world_tables),
|
||||
);
|
||||
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
|
||||
// its chunks and players are.
|
||||
self.native_server_data_loaded(false);
|
||||
self.bosses = Default::default();
|
||||
reset_world_progress(&mut self.adv, &mut self.stats);
|
||||
self.prefetch_advancement_tree();
|
||||
@@ -13211,6 +13324,8 @@ impl Game<'_> {
|
||||
self.renderer.as_ref(),
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
}
|
||||
fn apply_settings(&mut self) {
|
||||
@@ -14292,6 +14407,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
@@ -14673,6 +14790,7 @@ impl Game<'_> {
|
||||
entities.clear();
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
if !title {
|
||||
entities = horizon_client::spans::time("entity_geometry", || {
|
||||
self.entity_geometry(camera, alpha, entities)
|
||||
@@ -14684,7 +14802,9 @@ impl Game<'_> {
|
||||
self.bolt_instances(camera)
|
||||
};
|
||||
let mut additive = Vec::new();
|
||||
if !title && self.dim == Dim::End {
|
||||
// The dragon is drawn in every dimension, as any entity: a dragon
|
||||
// summoned outside the End (the server's AddEntity) is shown too.
|
||||
if !title {
|
||||
let mut dragon = Vec::new();
|
||||
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
@@ -14752,7 +14872,9 @@ impl Game<'_> {
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -14834,6 +14956,7 @@ impl Game<'_> {
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
entity_motion: &entity_motion,
|
||||
entity_spans: &entity_spans,
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
@@ -14845,6 +14968,7 @@ impl Game<'_> {
|
||||
},
|
||||
additive: &additive,
|
||||
see_through: &see_through,
|
||||
shadow_only: &shadow_only,
|
||||
celestial: if self.settings.horizon.sky || title {
|
||||
&celestial
|
||||
} else {
|
||||
@@ -14903,7 +15027,7 @@ impl Game<'_> {
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug);
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
@@ -14911,7 +15035,9 @@ impl Game<'_> {
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -15037,6 +15163,13 @@ impl Game<'_> {
|
||||
distance,
|
||||
&mut self.border_state,
|
||||
);
|
||||
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
|
||||
// enters another `SectionPos` (it only holds the stretch in range).
|
||||
let section = camera.map(|c| (c.floor() as i32) >> 4);
|
||||
if self.border_section != Some(section) {
|
||||
self.border_renderer.invalidate();
|
||||
self.border_section = Some(section);
|
||||
}
|
||||
// Camera.depthFar, as the renderer's far plane.
|
||||
let depth_far =
|
||||
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
|
||||
@@ -15116,6 +15249,7 @@ impl Game<'_> {
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Mod => self.close_mod_screen(),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -15399,6 +15533,16 @@ impl Game<'_> {
|
||||
if let Err(e) = self.poll_remote() {
|
||||
log::warn!("Horizon: {e}");
|
||||
}
|
||||
// The renderer laid translucent quads out again (a shader pack's flat terrain came
|
||||
// or went) and dropped every section and far tile: meshed again, as for a new one.
|
||||
if self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.is_some_and(Renderer::take_dropped_sections)
|
||||
{
|
||||
self.clear_lod();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.prepare_lod();
|
||||
if self.lod.is_some() {
|
||||
for &((x, _, z), _, _) in &self.world.light_edits {
|
||||
@@ -15717,6 +15861,30 @@ impl Game<'_> {
|
||||
self.physical_buttons.remove(button);
|
||||
}
|
||||
}
|
||||
let mouse = if !self.pad_synthetic_input()
|
||||
&& let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
{
|
||||
Some((
|
||||
glfw_button(*button),
|
||||
*state == ElementState::Pressed,
|
||||
glfw_modifiers(self.modifiers),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_button_pre(button, pressed, modifiers, &mut canceled);
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.handle_window_event_inner(event_loop, event);
|
||||
if let Some((button, pressed, modifiers)) = mouse {
|
||||
self.native_mouse_button_post(button, pressed, modifiers);
|
||||
}
|
||||
}
|
||||
fn handle_window_event_inner(&mut self, event_loop: &dyn EventLoopCtl, mut event: WindowEvent) {
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
self.physical_buttons.clear();
|
||||
@@ -15914,6 +16082,63 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the key before
|
||||
// it acts on it, and a cancelled press goes no further — no focus
|
||||
// move, no Escape-close, no inventory or hotbar key, no typing by the
|
||||
// key itself, no `native_key`. It fires after the window's own
|
||||
// shortcuts (fullscreen, screenshot, the debug keys, the narrator) so
|
||||
// those keep working with a mod that cancels; the key's release still
|
||||
// comes through.
|
||||
//
|
||||
// A key's characters are a separate callback of GLFW's, and it comes
|
||||
// after the key callback (`KeyboardHandler.charTyped`): they are taken
|
||||
// off the key here and dispatched once the screen has acted on it, on
|
||||
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
|
||||
// Options keeps its text on the key: its own key path routes a key's
|
||||
// text between its boxes and its key-bind capture, and no key that
|
||||
// carries text closes that screen or moves its focus.
|
||||
let mut typed: Option<String> = None;
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
} = &mut event
|
||||
&& key_event.state == ElementState::Pressed
|
||||
{
|
||||
if self.screen_hooks_open() && self.screen != Screen::Options {
|
||||
typed = key_event.text.take().map(|text| text.to_string());
|
||||
}
|
||||
let canceled = match key_event.physical_key {
|
||||
PhysicalKey::Code(key) => self.screen_key_pressed(key, key_event.repeat),
|
||||
PhysicalKey::Unidentified(_) => false,
|
||||
};
|
||||
if self.screen == Screen::Options && self.screen_hooks_open() {
|
||||
// The hook after the key's own, then the text the key path
|
||||
// types in place (its own routing keeps it).
|
||||
if let Some(text) = key_event.text.as_deref() {
|
||||
let left = self.screen_text_typed(text);
|
||||
if left.as_deref() != Some(text) {
|
||||
key_event.text = left.map(Into::into);
|
||||
}
|
||||
}
|
||||
}
|
||||
if canceled {
|
||||
// charTyped is a separate callback: the characters still
|
||||
// reach the text box, without the key's own effects. Options'
|
||||
// characters already passed the hook in place.
|
||||
if let Some(text) = typed.as_deref() {
|
||||
self.screen_chars_typed(Some(text));
|
||||
} else if self.screen == Screen::Options
|
||||
&& let Some(text) = key_event.text.as_deref()
|
||||
&& self.types_text()
|
||||
{
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
if !text.is_empty() {
|
||||
// Err only says no box of the screen takes it.
|
||||
let _ = self.type_chars_into_screen(&text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Screen.keyPressed: Tab, the arrows and Enter move and press the
|
||||
// menu's focus, unless the focused widget or the screen keeps them.
|
||||
if let WindowEvent::KeyboardInput {
|
||||
@@ -15923,6 +16148,9 @@ impl Game<'_> {
|
||||
&& let PhysicalKey::Code(key) = key_event.physical_key
|
||||
&& self.key_focus_key(event_loop, key)
|
||||
{
|
||||
// The keyboard's focus took the key; its characters are a separate
|
||||
// callback and still come (`KeyboardHandler.charTyped`).
|
||||
self.screen_chars_typed(typed.as_deref());
|
||||
return;
|
||||
}
|
||||
let result: Result<()> = (|| {
|
||||
@@ -16488,7 +16716,9 @@ impl Game<'_> {
|
||||
if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.search_tab_hotbar_key(key)
|
||||
{
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
// The hovered slot took the key, and its characters
|
||||
// go nowhere (`ignoreTextInput`).
|
||||
typed = None;
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key {
|
||||
@@ -16930,6 +17160,10 @@ impl Game<'_> {
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
// ScreenEvent.CharacterTyped.Pre: GLFW's char callback comes after its
|
||||
// key callback, so a key's characters reach the screen the key left
|
||||
// open, after the key's own effects (`KeyboardHandler.charTyped`).
|
||||
self.screen_chars_typed(typed.as_deref());
|
||||
if let Err(e) = result {
|
||||
self.error = Some(e);
|
||||
self.exit_on_error(event_loop);
|
||||
@@ -17176,6 +17410,7 @@ impl<'a> Game<'a> {
|
||||
was_playing: false,
|
||||
pause: false,
|
||||
debug: false,
|
||||
gpu_passes: false,
|
||||
screen: Screen::Playing,
|
||||
window: None,
|
||||
renderer: None,
|
||||
@@ -17183,7 +17418,9 @@ impl<'a> Game<'a> {
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: None,
|
||||
see_through_vertices: Vec::new(),
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
@@ -17250,6 +17487,7 @@ impl<'a> Game<'a> {
|
||||
attack_ticks: 100,
|
||||
attack_item: None,
|
||||
last_hurt_by_player: HashMap::new(),
|
||||
lead_killers: HashMap::new(),
|
||||
love_cause: HashMap::new(),
|
||||
died: Default::default(),
|
||||
be_anims: {
|
||||
@@ -17427,6 +17665,7 @@ impl<'a> Game<'a> {
|
||||
native_shaders: Default::default(),
|
||||
native_lifecycle: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_mod_screen: None,
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
@@ -17514,6 +17753,7 @@ impl<'a> Game<'a> {
|
||||
brushing: Default::default(),
|
||||
border: Default::default(),
|
||||
border_renderer: Default::default(),
|
||||
border_section: None,
|
||||
border_state: Default::default(),
|
||||
tab_list: Default::default(),
|
||||
subtitles: Default::default(),
|
||||
@@ -17836,11 +18076,16 @@ impl Game<'_> {
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
fn build_world_tables(&self) -> Option<WorldTables> {
|
||||
let files = functions::content_overlay(
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
self.native_data_pack_files(&mut contributed);
|
||||
let files = functions::overlay_files(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
contributed,
|
||||
);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
@@ -17902,6 +18147,7 @@ impl Game<'_> {
|
||||
&overlay,
|
||||
));
|
||||
self.adv.reset_tree();
|
||||
self.native_server_data_loaded(true);
|
||||
}
|
||||
|
||||
fn join_world_rest(&mut self) -> Result<()> {
|
||||
@@ -18201,6 +18447,8 @@ fn load_resources(
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
// published at once so the title screen can appear while the world bundle
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
@@ -18456,7 +18704,10 @@ fn load_world_rest(
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Arc::new(v)))
|
||||
.collect();
|
||||
let mob_data = horizon_client::mobs::MobData::load(root, facts);
|
||||
let mut mob_data = horizon_client::mobs::MobData::load(root, facts);
|
||||
mob_data.cem = std::sync::RwLock::new(Arc::new(
|
||||
horizon_client::mobs::cem::CemModels::load(root, &mob_types),
|
||||
));
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
Ok((mob_types, mob_data))
|
||||
};
|
||||
@@ -18549,6 +18800,12 @@ fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::Prepa
|
||||
&assets.sprite_mips,
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
)
|
||||
.with_pbr(
|
||||
&assets.pbr_normals,
|
||||
&assets.pbr_specular,
|
||||
assets.pbr_size,
|
||||
assets.texture_layers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload pack textures and create the renderer on the window's device.
|
||||
@@ -18560,6 +18817,8 @@ fn build_renderer(
|
||||
previous: Option<&Renderer>,
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
shader_pack: bool,
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
@@ -18571,6 +18830,8 @@ fn build_renderer(
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -18592,6 +18853,8 @@ fn build_renderer(
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
shader_pack,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -18653,6 +18916,9 @@ fn loading_renderer(
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
@@ -18721,6 +18987,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
@@ -18730,6 +18997,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
@@ -19193,6 +19461,8 @@ impl Loading {
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
@@ -20008,6 +20278,7 @@ mod tests {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 5,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
|
||||
hud::HudBatch,
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
@@ -326,13 +326,19 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
// The world's packs' advancements go over the game's own, and a
|
||||
// native mod's contributed files are already among them: the tables
|
||||
// were built for this open (`build_world_tables`), so the hook is
|
||||
// asked once, not again here.
|
||||
let files = match &self.world_tables {
|
||||
Some(tables) => tables.files.clone(),
|
||||
None => super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
),
|
||||
};
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
@@ -1821,11 +1827,7 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
|
||||
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
|
||||
};
|
||||
let (key, color) = toast_label(frame);
|
||||
// AdvancementToast.extractRenderState: a title of one line sits under
|
||||
// the frame's label; a longer one is wrapped at 125, the label fading
|
||||
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
|
||||
@@ -1862,6 +1864,15 @@ impl Game<'_> {
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
/// The toast's frame label and its colour (`AdvancementToast`: -30465, pink,
|
||||
/// for a challenge, else -256, yellow), packed as `hud::rgba` for the batch.
|
||||
fn toast_label(frame: Frame) -> (&'static str, u32) {
|
||||
match frame {
|
||||
Frame::Task => ("advancements.toast.task", rgba(255, 255, 0, 255)),
|
||||
Frame::Goal => ("advancements.toast.goal", rgba(255, 255, 0, 255)),
|
||||
Frame::Challenge => ("advancements.toast.challenge", rgba(255, 136, 255, 255)),
|
||||
}
|
||||
}
|
||||
/// `Advancement.name`: the bracketed title in the frame's colour, with the
|
||||
/// description on hover; the id when there is no display.
|
||||
pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
@@ -2059,6 +2070,18 @@ pub(super) fn text_of(v: &Value) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toast_labels_are_yellow_or_pink() {
|
||||
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
|
||||
for frame in [Frame::Task, Frame::Goal] {
|
||||
assert_eq!(toast_label(frame).1.to_le_bytes(), [255, 255, 0, 255]);
|
||||
}
|
||||
assert_eq!(
|
||||
toast_label(Frame::Challenge).1.to_le_bytes(),
|
||||
[255, 136, 255, 255]
|
||||
);
|
||||
}
|
||||
|
||||
fn recipes(n: usize) -> Toast {
|
||||
Toast::recipe(
|
||||
(0..n)
|
||||
|
||||
@@ -424,6 +424,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -498,7 +499,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
@@ -528,8 +529,16 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Splitscreen's profile page, a joining player's (`split_profiles`).
|
||||
fn split_profiles_open(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
}
|
||||
fn title_value(&self) -> Value {
|
||||
use horizon_client::title::Mode;
|
||||
if self.split_profiles_open() {
|
||||
return self.ui.options.split_profiles.state();
|
||||
}
|
||||
let Some(title) = &self.title else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -727,7 +736,7 @@ impl Game<'_> {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1591,6 +1600,22 @@ impl Game<'_> {
|
||||
self.synth_click(event_loop, x, y, button)?;
|
||||
ok(json!({ "ok": true, "screen": self.screen_label() }))
|
||||
}
|
||||
// Splitscreen's profile page, over a joining player's title screen.
|
||||
"type" if self.split_profiles_open() => {
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
let page = &mut self.ui.options.split_profiles;
|
||||
if text == "\\b" {
|
||||
page.key(winit::keyboard::KeyCode::Backspace, None);
|
||||
} else {
|
||||
page.type_text(text);
|
||||
}
|
||||
ok(json!({ "ok": true, "menu": self.title_value() }))
|
||||
}
|
||||
"type" if self.title.is_some() => {
|
||||
// type [--field name|seed] [--replace] TEXT on the title screens.
|
||||
let mut text = line
|
||||
|
||||
@@ -152,6 +152,31 @@ impl Game<'_> {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
|
||||
/// one item at the block center, with collectComponents and the default pickup delay.
|
||||
/// This direct spawn intentionally does not use the block_drops gamerule.
|
||||
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
|
||||
if items.iter().flatten().any(|s| s.count > 0));
|
||||
if !filled {
|
||||
return;
|
||||
}
|
||||
let Some(saved) = self.saved_block_entity(key) else {
|
||||
return;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
|
||||
let patch = block_entity_item::collected_components(&saved);
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
|
||||
self.spawn_item(
|
||||
stack,
|
||||
pos.map(|v| f64::from(v) + 0.5),
|
||||
super::dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
|
||||
/// `block_state` component (a hive's honey level, a statue's pose) set on
|
||||
/// the block just placed at `pos`. Returns the state it has now.
|
||||
|
||||
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
|
||||
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
Ok(BlockState { name, properties })
|
||||
Ok(BlockState {
|
||||
name,
|
||||
properties: properties.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -1119,7 +1119,7 @@ impl Game<'_> {
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
fluid = Some((fluid_hit_point(p, y, h), t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
@@ -1454,10 +1454,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
|
||||
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
|
||||
/// (the same fluid above it) is a full cube, so the hit is the ray's own
|
||||
/// point and a boat placed under water stays at the eye; a partial cell is
|
||||
/// clipped to its surface.
|
||||
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
|
||||
if h >= 1.0 {
|
||||
p
|
||||
} else {
|
||||
[p[0], f64::from(y) + f64::from(h), p[2]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
|
||||
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
|
||||
/// cell is clipped to its surface.
|
||||
#[test]
|
||||
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
|
||||
let eye = [0.5, 3.62, 0.5];
|
||||
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
|
||||
assert_eq!(
|
||||
fluid_hit_point(eye, 3, 8.0 / 9.0),
|
||||
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
|
||||
);
|
||||
}
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ pub(super) struct CombatTracker {
|
||||
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
|
||||
/// cause).
|
||||
pub(super) projectile_direct: Option<Text>,
|
||||
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
|
||||
/// its entity id and display name, the blast's causing entity.
|
||||
pub(super) blast_owner: Option<(u32, Text)>,
|
||||
/// The guest behind `blast_owner`, when that player is a LAN guest; the
|
||||
/// host's own blast leaves it None.
|
||||
pub(super) blast_guest: Option<u32>,
|
||||
pub(super) projectile_attacker: Option<(u32, Text)>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
//! waits until the terrain and entities around the player are loaded;
|
||||
//! `portal` sends the player through the nether portal it stands in.
|
||||
//! `break` destroys a block as the server player; `portal` sends the player
|
||||
//! through the nether portal it stands in; `falling` counts physical falling
|
||||
//! blocks by dimension, block and gravity.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, text::Text};
|
||||
use serde_json::{Value, json};
|
||||
@@ -244,7 +246,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"break" => {
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| g.break_block(pos))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
"falling" => {
|
||||
// Physical falling blocks are not command-selector targets
|
||||
// yet. Inspect the actual level rather than that unrelated gap.
|
||||
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
|
||||
let block = op["block"].as_str().unwrap_or("minecraft:sand");
|
||||
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
|
||||
let back = self.dim;
|
||||
self.enter_level(None, super::Dim::from_id(dimension))?;
|
||||
let facts = self.res.facts();
|
||||
let matches = |f: &super::FallingBlock| {
|
||||
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
|
||||
};
|
||||
let count = self.falling.iter().filter(|f| matches(f)).count();
|
||||
if op["discard"].as_bool() == Some(true) {
|
||||
self.falling.retain(|f| !matches(f));
|
||||
}
|
||||
self.enter_level(None, back)?;
|
||||
let record = json!({
|
||||
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
|
||||
"messages": [],
|
||||
"results": [[true, count]],
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2459,6 +2459,11 @@ impl Game<'_> {
|
||||
ctx.has("keep"),
|
||||
);
|
||||
let mut count = 0;
|
||||
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
|
||||
// of states, and matching a tag is a name lookup.
|
||||
let mut matched: std::collections::HashMap<BlockStateId, bool> =
|
||||
std::collections::HashMap::new();
|
||||
let plain = !(destroy || hollow || outline);
|
||||
for y in min[1]..=max[1] {
|
||||
for z in min[2]..=max[2] {
|
||||
for x in min[0]..=max[0] {
|
||||
@@ -2466,11 +2471,23 @@ impl Game<'_> {
|
||||
if keep && current != air {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter
|
||||
&& !self.block_matches(f, current)
|
||||
{
|
||||
// Already the block it would become: nothing to place (place_state would do nothing either).
|
||||
if plain && current == state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
let ok = match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let edge = x == min[0]
|
||||
|| x == max[0]
|
||||
|| y == min[1]
|
||||
|
||||
@@ -109,10 +109,28 @@ impl Game<'_> {
|
||||
let _ = flight.reply.send(summary.to_string());
|
||||
}
|
||||
}
|
||||
/// The frame summary, with Horizon's own passes' median GPU times while they are timed.
|
||||
fn summary_with_passes(&self) -> Value {
|
||||
let mut summary = self.frame_stats.summary();
|
||||
let passes = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| r.own_pass_times())
|
||||
.unwrap_or_default();
|
||||
if !passes.is_empty() {
|
||||
summary["gpu_passes"] = Value::Object(
|
||||
passes
|
||||
.into_iter()
|
||||
.map(|(name, ms)| (name, json!(ms)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
summary
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = reply.send(self.frame_stats.summary().to_string());
|
||||
let _ = reply.send(self.summary_with_passes().to_string());
|
||||
} else {
|
||||
self.pending_bench = Some((deadline, reply));
|
||||
}
|
||||
@@ -150,6 +168,69 @@ impl Game<'_> {
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
}
|
||||
|
||||
/// Text typed at the open screen's text box by the control socket's
|
||||
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
|
||||
/// taken one, not the characters `\` and `b`. `Err` when no text box of
|
||||
/// the screen takes it.
|
||||
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if text == "\\b" {
|
||||
match self.screen {
|
||||
Screen::WorldOptions => {
|
||||
self.world_options_text(None);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.type_chars_into_screen(text)
|
||||
}
|
||||
|
||||
/// Characters the open screen's focused text box takes: the control
|
||||
/// socket's `type` and a key press's surviving characters (`charTyped`).
|
||||
/// Nothing here is a key — the caller drops control characters. `Err` when
|
||||
/// no text box of the screen takes them.
|
||||
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return Ok(());
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(text), false)
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box, while it has an input to name.
|
||||
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn control_command(
|
||||
&mut self,
|
||||
line: &str,
|
||||
@@ -249,6 +330,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"gpu-passes" => {
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => self.gpu_passes = true,
|
||||
Some("off") => self.gpu_passes = false,
|
||||
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
@@ -371,6 +461,17 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
let action = arg(2)?;
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
|
||||
// press before it acts on it, as the window's key event hands
|
||||
// it over. A cancelled press goes no further — not to the
|
||||
// screens, not to `native_key` — and its release still comes.
|
||||
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
@@ -675,48 +776,20 @@ impl Game<'_> {
|
||||
self.require_world()?;
|
||||
// Typed keys: the open screen's narration hears them.
|
||||
self.narration.keyboard_action();
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box.
|
||||
Screen::Inventory
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
|
||||
{
|
||||
self.station_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
|
||||
// each character before a text box takes it, as the window's
|
||||
// key event fires it. A cancelled character is dropped, so
|
||||
// `type` types the survivors only.
|
||||
// `\b` is the socket's backspace sentinel, not two characters.
|
||||
let typed = if text == "\\b" {
|
||||
text.to_owned()
|
||||
} else {
|
||||
let Some(typed) = self.screen_text_typed(text) else {
|
||||
return ok();
|
||||
};
|
||||
typed
|
||||
};
|
||||
self.type_into_screen(&typed)?;
|
||||
ok()
|
||||
}
|
||||
"options" => {
|
||||
@@ -774,7 +847,7 @@ impl Game<'_> {
|
||||
})))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.frame_stats.summary();
|
||||
let summary = self.summary_with_passes();
|
||||
if words.get(1) == Some(&"reset") {
|
||||
self.frame_stats.reset();
|
||||
}
|
||||
@@ -986,6 +1059,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1220,6 +1220,9 @@ impl Game<'_> {
|
||||
let player = killer
|
||||
.filter(|_| m.ai.last_hurt_by_player > 0)
|
||||
.and_then(|conn| self.killer_uuid(conn));
|
||||
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
|
||||
self.lead_killers.insert(m.id, (player, self.dim));
|
||||
}
|
||||
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
@@ -2379,6 +2382,16 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
MobEvent::ParticleColored {
|
||||
at,
|
||||
color: [r, g, b],
|
||||
} => {
|
||||
let options = horizon_client::particles::ParticleOptions::colored(
|
||||
horizon_client::particles::ParticleType::EntityEffect,
|
||||
[r, g, b, 1.0],
|
||||
);
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer { mob } => {
|
||||
let previous = self
|
||||
@@ -3785,7 +3798,7 @@ impl Game<'_> {
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
self.spawn_primed_tnt(pos, false);
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
@@ -5898,6 +5911,7 @@ impl Game<'_> {
|
||||
self.rendered_mobs.set(0);
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
@@ -5971,6 +5985,25 @@ impl Game<'_> {
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
if !is_player
|
||||
&& !layers_only
|
||||
&& self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
{
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
@@ -6072,6 +6105,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
|
||||
@@ -162,6 +162,13 @@ impl Game<'_> {
|
||||
if self.debug && !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// Horizon's own passes, in a group of their own.
|
||||
let own_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
overlay::labelled_pass_lines("Horizon GPU", &r.own_pass_times())
|
||||
});
|
||||
if self.debug && !own_lines.is_empty() {
|
||||
d.group("Horizon passes", own_lines);
|
||||
}
|
||||
}
|
||||
"tps" => self.debug_tps_line(d),
|
||||
// DebugEntryChunkGeneration, where the world is generated
|
||||
|
||||
@@ -877,6 +877,9 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
lod_distance: horizon_client::lod_net::distance_property(
|
||||
props.raw.get(horizon_client::lod_net::PROPERTY),
|
||||
),
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::{ServerState, log};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
@@ -48,6 +49,27 @@ impl Game<'_> {
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
"serversettings/difficulty/set" => {
|
||||
let Some(value) = body["value"].as_str() else {
|
||||
return json!({ "error": "difficulty must be a string" });
|
||||
};
|
||||
let Some(index) = DIFFICULTIES.iter().position(|name| *name == value) else {
|
||||
return json!({ "error": format!("unknown difficulty {value:?}") });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update difficulty from '{}' to '{}'",
|
||||
DIFFICULTIES[self.level.difficulty_id() as usize],
|
||||
value
|
||||
));
|
||||
// DedicatedServer.setDifficulty stores the property and then forces the world
|
||||
// difficulty, including hardcore's always-hard rule and the player broadcast.
|
||||
self.store_property("difficulty", value.to_owned());
|
||||
self.set_difficulty_forced(if self.level.hardcore { 3 } else { index as u8 });
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
"allowlist/add" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Add player '{}' to allowlist", shown(&user)));
|
||||
|
||||
@@ -1377,7 +1377,7 @@ impl Game<'_> {
|
||||
);
|
||||
} else if &*name == "minecraft:tnt" {
|
||||
if self.ui.rules.bool("tnt_explodes") {
|
||||
self.spawn_primed_tnt(target_pos, false);
|
||||
self.spawn_primed_tnt(target_pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
|
||||
self.process_level_events()?;
|
||||
} else {
|
||||
@@ -1441,7 +1441,7 @@ impl Game<'_> {
|
||||
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
|
||||
let target = Self::pos_of(front);
|
||||
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
|
||||
self.add_primed_tnt(target, rot, 80);
|
||||
self.add_primed_tnt(target, rot, 80, None, None);
|
||||
self.audio.play(
|
||||
"minecraft:entity.tnt.primed",
|
||||
Some([
|
||||
|
||||
@@ -438,8 +438,10 @@ impl Dragon {
|
||||
fn sample(&self, delay: usize) -> (f64, f32) {
|
||||
self.history[(self.head + 64 - (delay & 63)) & 63]
|
||||
}
|
||||
/// DragonFlightHistory.get(delay, partial).
|
||||
/// `DragonFlightHistory.get(delay, partial)`, with
|
||||
/// `EnderDragonRenderer.extractRenderState`'s frozen history on death.
|
||||
fn sample_lerp(&self, delay: usize, partial: f32) -> (f64, f32) {
|
||||
let partial = if self.alive() { partial } else { 0.0 };
|
||||
let a = self.sample(delay);
|
||||
let b = self.sample(delay + 1);
|
||||
let yaw = b.1 + wrap(a.1 - b.1) * partial;
|
||||
@@ -3874,7 +3876,7 @@ fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out:
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
@@ -3911,6 +3913,55 @@ fn push_seen_with_hurt(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dying_dragons_keep_their_flight_pose_between_frames() {
|
||||
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
|
||||
dragon.history.fill((70.0, 170.0));
|
||||
dragon.position.y = 90.0;
|
||||
dragon.yaw = -170.0;
|
||||
dragon.record();
|
||||
assert_eq!(dragon.sample_lerp(0, 0.5), (80.0, 180.0));
|
||||
dragon.health = 0.0;
|
||||
for partial in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert_eq!(dragon.sample_lerp(0, partial), (70.0, 170.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Driven by `tools/test_dragon_render_parity.py`; compares the actual
|
||||
/// ray vertices with EnderDragonRenderer's captured VertexConsumer output.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dragon_render_parity_fixture() {
|
||||
let path = std::env::var("HORIZON_DRAGON_RENDER_OUTPUT").expect("harness output path");
|
||||
let mut cases = Vec::new();
|
||||
for t in [0.0, 0.05, 0.25, 0.5, 0.8, 0.9, 0.95, 1.0] {
|
||||
let mut vertices = Vec::new();
|
||||
dragon_rays(
|
||||
glam::Mat4::IDENTITY,
|
||||
t,
|
||||
horizon_client::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
// The fourth quad vertex only pads a triangle for our index buffer.
|
||||
let triangles: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|q| &q[..3])
|
||||
.map(|v| serde_json::json!([v.pos[0], v.pos[1], v.pos[2], v.color]))
|
||||
.collect();
|
||||
cases.push(triangles);
|
||||
}
|
||||
std::fs::write(path, serde_json::to_vec(&cases).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn death_rays_sample_inside_the_white_sprite() {
|
||||
// A four-texel white sprite surrounded by transparent atlas pixels.
|
||||
|
||||
@@ -17,6 +17,9 @@ impl Game<'_> {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
|
||||
(Some(r), Some(_)) => r.pack_shadow_casters(),
|
||||
// A native pack's shadow pass has no spans to hide entities with: it only needs
|
||||
// the first-person model.
|
||||
(Some(r), None) => (false, r.native_shadow()),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, VoxelShape},
|
||||
@@ -175,12 +175,17 @@ pub(super) fn client_float() -> f32 {
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
|
||||
// blast before any mob does, which makes the damage type
|
||||
// `minecraft:player_explosion` (`server_explode` asks this).
|
||||
self.combat.blast_owner.clone().or_else(|| {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `ServerExplosion.getSeenPercent` for an entity box.
|
||||
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
|
||||
@@ -976,7 +981,19 @@ impl Game<'_> {
|
||||
let r = &mut self.world.random;
|
||||
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
|
||||
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
|
||||
self.add_primed_tnt(BlockPos::new(pos[0], pos[1], pos[2]), rot, fuse);
|
||||
// TntBlock.wasExploded hands the new TNT the blast's indirect
|
||||
// source, so a chain reaction keeps the player who started it.
|
||||
let (owner, guest) = match &self.combat.blast_owner {
|
||||
Some(_) => (Some(Who::Player), self.combat.blast_guest),
|
||||
None => (None, None),
|
||||
};
|
||||
self.add_primed_tnt(
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
rot,
|
||||
fuse,
|
||||
owner,
|
||||
guest,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,18 +5,15 @@ use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
|
||||
/// `FallingBlockEntity.causeFallDamage`: impact damage and anvil wear,
|
||||
/// before block effects and portal handling can transfer the entity.
|
||||
pub(super) fn falling_block_impact(&mut self, block: &mut FallingBlock) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
let stalactite = matches!(
|
||||
&*name,
|
||||
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
|
||||
);
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(impact) = block.damage.impact(block.fall_distance) {
|
||||
if impact.amount > 0.0 {
|
||||
let mob_kind = if stalactite {
|
||||
@@ -79,6 +76,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_falling_block(&mut self, block: FallingBlock, pos: [i32; 3]) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ => None,
|
||||
};
|
||||
if block.damage.cancel_drop {
|
||||
if let Some(id) = sound_event {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -123,6 +123,18 @@ impl Rocket {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `EntityType.FIREWORK_ROCKET`: 0.25 x 0.25.
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
let h = 0.125;
|
||||
Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 2.0 * h,
|
||||
self.position.z + h,
|
||||
),
|
||||
)
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) {
|
||||
@@ -479,8 +491,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// The owner as `rocket_targets` names it: a guest by their entity id.
|
||||
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
|
||||
let owner = r.owner?;
|
||||
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
|
||||
self.shooter_who(r.owner, r.guest)
|
||||
}
|
||||
/// `Projectile.getOwner` as a target's `Who`: a guest's `Who::Player`
|
||||
/// (kept with the connection that shot it) is their entity, not the host.
|
||||
pub(super) fn shooter_who(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Who> {
|
||||
let owner = owner?;
|
||||
let Some(conn) = guest.filter(|_| owner == Who::Player) else {
|
||||
return Some(owner);
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
|
||||
@@ -64,6 +64,16 @@ fn bounds(p: Vec3) -> Aabb {
|
||||
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
|
||||
)
|
||||
}
|
||||
impl Hook {
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
bounds(self.position)
|
||||
}
|
||||
/// `FishingHook.tick` returns before `applyEffectsFromBlocks` while it
|
||||
/// holds an entity.
|
||||
pub(super) fn applies_block_effects(&self) -> bool {
|
||||
!matches!(self.state, State::Hooked(_))
|
||||
}
|
||||
}
|
||||
/// RandomSource.triangle(mode, deviation).
|
||||
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
|
||||
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
|
||||
|
||||
@@ -124,7 +124,14 @@ impl Game<'_> {
|
||||
self.camera_fluid(camera),
|
||||
false,
|
||||
);
|
||||
self.zoomed_fov_degrees(fov)
|
||||
let vanilla = self.zoomed_fov_degrees(fov);
|
||||
let mut degrees = vanilla;
|
||||
self.native_compute_fov(partial, camera, &mut degrees);
|
||||
if degrees.is_finite() && (1.0..=179.0).contains(°rees) {
|
||||
degrees
|
||||
} else {
|
||||
vanilla
|
||||
}
|
||||
}
|
||||
/// `Camera.calculateHudFov`: the first-person hand's field of view.
|
||||
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
|
||||
@@ -96,6 +96,48 @@ pub(super) fn content_overlay(
|
||||
DataStore::load(&packs).files.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The files the content tables load from: the world's enabled packs'
|
||||
/// (`content_overlay`) with what native mods contribute for this load
|
||||
/// (`native_data_pack_files`) below them. `PackOverlay::top` is a path's last
|
||||
/// file, so a pack's file still overrides a mod's for the same path, and a
|
||||
/// later contribution overrides an earlier one, as packs do. `Game` builds
|
||||
/// its tables through here on a world's first open and on `/reload`, so the
|
||||
/// mods are asked once per load.
|
||||
pub(super) fn overlay_files(
|
||||
world_root: &std::path::Path,
|
||||
resources: &std::path::Path,
|
||||
vanilla_disabled: bool,
|
||||
enabled_features: &[String],
|
||||
contributed: Vec<(String, Vec<u8>)>,
|
||||
) -> Vec<(String, Vec<Vec<u8>>)> {
|
||||
let mut files = content_overlay(world_root, resources, vanilla_disabled, enabled_features);
|
||||
if contributed.is_empty() {
|
||||
return files;
|
||||
}
|
||||
// A path's mod files in contribution order, under the pack files.
|
||||
let mut mods: std::collections::BTreeMap<String, Vec<Vec<u8>>> = Default::default();
|
||||
for (path, bytes) in contributed {
|
||||
// One bad file would fail its whole table and drop the world's pack
|
||||
// content with it, so it is skipped alone: every kind is JSON.
|
||||
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
|
||||
continue;
|
||||
}
|
||||
mods.entry(path).or_default().push(bytes);
|
||||
}
|
||||
for (path, versions) in files.iter_mut() {
|
||||
let Some(under) = mods.remove(path.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut merged = under;
|
||||
merged.append(versions);
|
||||
*versions = merged;
|
||||
}
|
||||
// The paths only a mod has.
|
||||
files.extend(mods);
|
||||
files
|
||||
}
|
||||
|
||||
impl FnState {
|
||||
/// The level function files compile at (`ServerFunctionLibrary`'s
|
||||
/// `functionCompilationPermissions`): `function-permission-level` on a
|
||||
@@ -886,11 +928,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ReloadCommand.reloadPacks`: no message of its own.
|
||||
/// `ReloadCommand.reloadPacks`: no message of its own. It reloads the
|
||||
/// server's data as `/reload` does (`reload_datapacks`), the content
|
||||
/// tables with the functions.
|
||||
fn apply_pack_change(&mut self) {
|
||||
self.save_datapack_settings();
|
||||
self.rebuild_store();
|
||||
self.fns.load_pending = true;
|
||||
self.reload_world_tables();
|
||||
}
|
||||
|
||||
// ---- predicates -------------------------------------------------------------------
|
||||
@@ -948,4 +993,87 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a native mod contributes (`native_data_pack_files`) joins the load
|
||||
/// the tables are built from, a world's first open and `/reload` alike
|
||||
/// (`Game::build_world_tables` is both), and sits below the world's own
|
||||
/// packs. Each load asks again, so it is the current contribution that
|
||||
/// counts.
|
||||
#[test]
|
||||
fn contributed_files_join_the_load_below_the_worlds_packs() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-contributed-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let pack = dir.join("datapacks").join("pack");
|
||||
std::fs::create_dir_all(pack.join("data/pkg/tags/item")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(
|
||||
pack.join("data/pkg/tags/item/both.json"),
|
||||
r#"{"replace": true, "values": ["pkg:from_pack"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = serde_json::json!({"enabled": ["file/pack"], "disabled": []});
|
||||
std::fs::write(dir.join("datapacks.json"), settings.to_string()).unwrap();
|
||||
let tag = |entry: &str| format!(r#"{{"values": ["{entry}"]}}"#).into_bytes();
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![
|
||||
("tags/pkg:item/both".to_owned(), tag("pkg:from_mod")),
|
||||
("tags/pkg:item/mod_only".to_owned(), tag("pkg:only_mod")),
|
||||
],
|
||||
);
|
||||
// The pack's file is the path's last (`PackOverlay::top`), and its
|
||||
// `replace` drops what the mod put below it.
|
||||
let (_, versions) = files
|
||||
.iter()
|
||||
.find(|(id, _)| id == "tags/pkg:item/both")
|
||||
.unwrap();
|
||||
assert!(horizon_world::loot::PackOverlay::top(versions).starts_with(b"{\"replace\""));
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert!(tags["pkg:both"].contains("pkg:from_pack"));
|
||||
assert!(!tags["pkg:both"].contains("pkg:from_mod"));
|
||||
// A path only the mod has reaches the tables too.
|
||||
assert_eq!(
|
||||
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
|
||||
["pkg:only_mod"]
|
||||
);
|
||||
// The next load asks again: the new contribution, not the first's.
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![("tags/pkg:item/mod_only".to_owned(), tag("pkg:second_load"))],
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert_eq!(
|
||||
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
|
||||
["pkg:second_load"]
|
||||
);
|
||||
// A file that isn't JSON is skipped alone, not the whole load.
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![
|
||||
("tags/pkg:item/mod_only".to_owned(), b"{not json".to_vec()),
|
||||
("tags/pkg:item/good".to_owned(), tag("pkg:good")),
|
||||
],
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert!(!tags.contains_key("pkg:mod_only"));
|
||||
assert!(tags["pkg:good"].contains("pkg:good"));
|
||||
// With nothing contributed the overlay is the packs' own.
|
||||
assert_eq!(
|
||||
overlay_files(&dir, std::path::Path::new(""), true, &[], Vec::new()),
|
||||
content_overlay(&dir, std::path::Path::new(""), true, &[])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,13 @@ impl Game<'_> {
|
||||
pub(super) fn ime_commit(&mut self, text: &str) {
|
||||
preedit::set_preedit(None);
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
|
||||
// reaches a text box by characters, as a key's own text does, so the
|
||||
// open screen hears of each of them first and a cancelled one never
|
||||
// reaches the box.
|
||||
let Some(text) = self.screen_text_typed(&text) else {
|
||||
return;
|
||||
};
|
||||
if text.is_empty() || !self.text_field_focused() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -421,6 +421,7 @@ impl Game<'_> {
|
||||
});
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
let q = item.position;
|
||||
let pos = glam::DVec3::from_array(at) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
|
||||
@@ -187,6 +187,9 @@ pub(super) struct LanHost {
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// Far terrain sampled for Horizon clients (game/lan_far.rs); None when
|
||||
/// `horizon-lod-distance` is 0.
|
||||
pub far: Option<horizon_client::lod_net::Service>,
|
||||
/// Native mods' commands and the level each needs, once asked
|
||||
/// (game/native_server.rs).
|
||||
pub native_commands: Option<HashMap<String, u8>>,
|
||||
@@ -1395,6 +1398,8 @@ impl Game<'_> {
|
||||
Game::native_block_entity_update_tag(pos, entity, &mut tag);
|
||||
tag
|
||||
});
|
||||
let far = (net.lod_distance > 0)
|
||||
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
|
||||
let lan = Lan::start_with(sync.clone(), net)?;
|
||||
if let Some(uuid) = host {
|
||||
lan.reserve(uuid);
|
||||
@@ -1429,6 +1434,7 @@ impl Game<'_> {
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
far,
|
||||
native_commands: None,
|
||||
sleep_ignored: Default::default(),
|
||||
states: Default::default(),
|
||||
@@ -2351,6 +2357,7 @@ impl Game<'_> {
|
||||
host.hub.anticheat_ping();
|
||||
drop(holding);
|
||||
self.flush_native_net(&mut host);
|
||||
self.flush_far_terrain(&mut host);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
self.flush_native_sounds();
|
||||
|
||||
@@ -414,6 +414,7 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.flying_speed = g.flying_speed;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
@@ -2095,6 +2096,9 @@ impl Game<'_> {
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
|
||||
self.guest_native_net(host, conn, &p.data)
|
||||
}
|
||||
S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => {
|
||||
self.guest_far_terrain(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -2474,6 +2478,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a block outline occludes the straight line from `eye` to `point`.
|
||||
fn block_between(&self, eye: [f64; 3], point: [f64; 3]) -> bool {
|
||||
let d = [point[0] - eye[0], point[1] - eye[1], point[2] - eye[2]];
|
||||
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if dist < 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let dir = [d[0] / dist, d[1] / dist, d[2] / dist];
|
||||
self.clip_ray(eye, dir, dist).is_some_and(|h| {
|
||||
let hd = [
|
||||
h.point[0] - eye[0],
|
||||
h.point[1] - eye[1],
|
||||
h.point[2] - eye[2],
|
||||
];
|
||||
let hit_dist = (hd[0] * hd[0] + hd[1] * hd[1] + hd[2] * hd[2]).sqrt();
|
||||
hit_dist < dist - 1e-6
|
||||
})
|
||||
}
|
||||
|
||||
/// Aim the swapped-in player at an entity; returns where it aims and the
|
||||
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
|
||||
/// the point the client hit (the interact packet's location) when that
|
||||
@@ -2514,12 +2537,13 @@ impl Game<'_> {
|
||||
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
|
||||
Some(if on { inside } else { middle })
|
||||
};
|
||||
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
|
||||
Some(m) => [
|
||||
m.position.x,
|
||||
m.position.y + m.kind.height * 0.5,
|
||||
m.position.z,
|
||||
],
|
||||
let mob_box = self.mobs.iter().find(|m| m.id as i32 == id).map(|m| {
|
||||
let b = m.bounds();
|
||||
let (mn, mx) = (b.min_position(), b.max_position());
|
||||
([mn.x, mn.y, mn.z], [mx.x, mx.y, mx.z])
|
||||
});
|
||||
let centre = match mob_box {
|
||||
Some((lo, hi)) => visible_aim_point(eye, lo, hi, |p| self.block_between(eye, p)),
|
||||
None if self.object_target(id).is_some() => object()?,
|
||||
// A dragon part or an end crystal (game/dragon.rs).
|
||||
None => {
|
||||
@@ -4772,3 +4796,140 @@ mod bundle_slot_tests {
|
||||
assert_eq!(bundle_inventory_slot(45, None), Some(45));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the server aims a guest's attack or interact on a mob. The packet names only the
|
||||
/// entity, so the server picked the mob's centre; a slab, trapdoor, stair or fence between the
|
||||
/// eyes and that centre then hid a mob the guest's own crosshair hit through the gap (#5772).
|
||||
/// This returns the centre when it is visible, else the visible point of the box nearest to it (the one nearer the eyes when two are equally near)
|
||||
/// (`occluded` tells whether a block outline hides a point from the eyes); the centre again
|
||||
/// when the whole box is hidden.
|
||||
fn visible_aim_point(
|
||||
eye: [f64; 3],
|
||||
lo: [f64; 3],
|
||||
hi: [f64; 3],
|
||||
occluded: impl Fn([f64; 3]) -> bool,
|
||||
) -> [f64; 3] {
|
||||
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
if !occluded(centre) {
|
||||
return centre;
|
||||
}
|
||||
let margin = [0, 1, 2].map(|k| ((hi[k] - lo[k]) / 2.0).min(0.02));
|
||||
let across = |k: usize, steps: usize| {
|
||||
(0..=steps)
|
||||
.map(|i| {
|
||||
lo[k] + margin[k] + (hi[k] - lo[k] - 2.0 * margin[k]) * i as f64 / steps as f64
|
||||
})
|
||||
.collect::<Vec<f64>>()
|
||||
};
|
||||
let (xs, ys, zs) = (across(0, 2), across(1, 4), across(2, 2));
|
||||
let mut candidates = Vec::with_capacity(xs.len() * ys.len() * zs.len());
|
||||
for &x in &xs {
|
||||
for &y in &ys {
|
||||
for &z in &zs {
|
||||
candidates.push([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nearness = |p: &[f64; 3]| (0..3).map(|k| (p[k] - centre[k]).powi(2)).sum::<f64>();
|
||||
let from_eye = |p: &[f64; 3]| (0..3).map(|k| (p[k] - eye[k]).powi(2)).sum::<f64>();
|
||||
candidates.sort_by(|a, b| {
|
||||
nearness(a)
|
||||
.total_cmp(&nearness(b))
|
||||
.then(from_eye(a).total_cmp(&from_eye(b)))
|
||||
.then(a[1].total_cmp(&b[1]))
|
||||
.then(a[0].total_cmp(&b[0]))
|
||||
.then(a[2].total_cmp(&b[2]))
|
||||
});
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|p| !occluded(*p))
|
||||
.unwrap_or(centre)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod aim_point_tests {
|
||||
use super::visible_aim_point;
|
||||
|
||||
const EYE: [f64; 3] = [-760.7, 70.27, -450.4];
|
||||
// A zombie one block behind a top slab: its centre is above the 0.5 gap under the slab.
|
||||
const LO: [f64; 3] = [-763.0, 70.0, -450.9];
|
||||
const HI: [f64; 3] = [-762.4, 71.95, -450.3];
|
||||
|
||||
/// A top slab filling x in [-762, -761] with its underside at y 70.5: a point is hidden when
|
||||
/// the line from the eyes is above 70.5 anywhere inside that column.
|
||||
fn behind_top_slab(p: [f64; 3]) -> bool {
|
||||
let y_at = |x: f64| EYE[1] + (p[1] - EYE[1]) * (x - EYE[0]) / (p[0] - EYE[0]);
|
||||
p[0] < -762.0 && (y_at(-761.0) > 70.5 || y_at(-762.0) > 70.5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visible_centre_is_kept() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert_eq!(visible_aim_point(EYE, LO, HI, |_| false), centre);
|
||||
}
|
||||
|
||||
/// A bottom slab hides everything under y 70.9: the aim goes above it, through the gap over the slab.
|
||||
#[test]
|
||||
fn a_mob_behind_a_bottom_slab_is_aimed_at_above_the_slab() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
let p = visible_aim_point(EYE, LO, HI, |pt| pt[1] < 70.9);
|
||||
assert!(p[1] >= 70.9, "{p:?} is still under the slab");
|
||||
assert!(p[1] > centre[1] - 0.5, "{p:?} is far from the centre");
|
||||
}
|
||||
|
||||
/// With only the centre hidden, two equally near candidates go to the one nearer the eyes.
|
||||
#[test]
|
||||
fn an_equally_near_point_on_the_side_of_the_eyes_wins() {
|
||||
let eye = [5.0, 70.5, 0.0];
|
||||
let (lo, hi) = ([-0.3, 70.0, -0.3], [0.3, 71.9, 0.3]);
|
||||
let centre = [
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
];
|
||||
let p = visible_aim_point(eye, lo, hi, |pt| pt == centre);
|
||||
assert!(p[0] > centre[0], "{p:?} is on the far side from the eyes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mob_seen_only_through_a_gap_is_aimed_at_through_the_gap() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert!(behind_top_slab(centre), "the slab hides the centre");
|
||||
let p = visible_aim_point(EYE, LO, HI, behind_top_slab);
|
||||
assert!(!behind_top_slab(p), "{p:?} is still hidden");
|
||||
for k in 0..3 {
|
||||
assert!(LO[k] <= p[k] && p[k] <= HI[k], "{p:?} is outside the mob");
|
||||
}
|
||||
assert!(p[1] < centre[1], "{p:?} is not lower than the centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_hidden_mob_keeps_the_centre() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert_eq!(visible_aim_point(EYE, LO, HI, |_| true), centre);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_is_deterministic() {
|
||||
assert_eq!(
|
||||
visible_aim_point(EYE, LO, HI, behind_top_slab),
|
||||
visible_aim_point(EYE, LO, HI, behind_top_slab)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,11 @@ impl Game<'_> {
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
|
||||
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
|
||||
// Omitted false values must reset clients that saw a true value,
|
||||
// including when the controlling passenger dismounts mid-stroke.
|
||||
for (k, paddling) in b.paddling.iter().enumerate() {
|
||||
e.defaults
|
||||
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if *paddling {
|
||||
e.meta
|
||||
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Far terrain for a server's Horizon clients (`horizon_client::lod_net`): a
|
||||
//! `horizon:lod` request is checked against the player's limits and position,
|
||||
//! then sampled from the level the player is in on the far terrain thread.
|
||||
//! The replies come back here and leave with the tick's other packets. A
|
||||
//! refused request is answered at once, so the client knows to ask again.
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
lan::conn::PlayerSender,
|
||||
lod_net::{CHANNEL, Delivery, Job, Verdict},
|
||||
};
|
||||
use horizon_protocol::{
|
||||
packets::{configuration::CustomPayload, play},
|
||||
text::translatable,
|
||||
};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Bytes a player's connection may have waiting before far terrain holds its
|
||||
/// replies back: a client that reads slowly gets the game's packets first.
|
||||
const BACKLOG: usize = 1 << 20;
|
||||
|
||||
fn send(sender: &PlayerSender, data: Vec<u8>) {
|
||||
let packet = play::Clientbound::CustomPayload(CustomPayload {
|
||||
channel: CHANNEL.into(),
|
||||
data,
|
||||
});
|
||||
sender.send(packet.encode());
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:lod` payload from `conn`.
|
||||
pub(super) fn guest_far_terrain(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(far) = host.far.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Only the clients that heard the offer (lan::conn) may ask.
|
||||
let Some(guest) = host.hub.guest(conn).filter(|g| g.brand == "horizon") else {
|
||||
return;
|
||||
};
|
||||
// Players who left are forgotten once they could matter.
|
||||
if far.players() > host.hub.guests.len() + 8 {
|
||||
let guests = &host.hub.guests;
|
||||
far.retain(|c| guests.iter().any(|g| g.conn == c));
|
||||
}
|
||||
let dim = Dim::from_id(&guest.dimension);
|
||||
let source = self.lod_source_in(dim);
|
||||
let now = std::time::Instant::now();
|
||||
match far.request(conn, data, guest.position, source.is_some(), now) {
|
||||
Verdict::Serve(request) => far.push(Job {
|
||||
conn,
|
||||
level: dim.id().to_owned(),
|
||||
source: source.expect("served when open"),
|
||||
request,
|
||||
}),
|
||||
Verdict::Refuse(reply) => send(&guest.sender, reply),
|
||||
Verdict::Ignore => {}
|
||||
Verdict::Kick => host.hub.kick(
|
||||
conn,
|
||||
translatable("multiplayer.disconnect.invalid_packet", &[]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiles the far terrain thread finished go to their players, once
|
||||
/// their connections have room.
|
||||
pub(super) fn flush_far_terrain(&mut self, host: &mut LanHost) {
|
||||
let Some(far) = host.far.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let guests = &host.hub.guests;
|
||||
far.deliver(|conn, data| match guests.iter().find(|g| g.conn == conn) {
|
||||
None => Delivery::Gone,
|
||||
Some(g) if g.sender.pending.load(Ordering::Relaxed) > BACKLOG => Delivery::Busy,
|
||||
Some(g) => {
|
||||
send(&g.sender, std::mem::take(data));
|
||||
Delivery::Sent
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user