ccc3e20902
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2995 lines
129 KiB
Diff
2995 lines
129 KiB
Diff
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
|
From: chexit <chexit@duck.com>
|
|
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
|
Subject: [PATCH 33/40] HD clouds and fog polish: cloud panorama, cirrus,
|
|
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
|
material overlays by sprite name
|
|
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
---
|
|
crates/horizon-client/src/film_clock.rs | 45 +++
|
|
crates/horizon-client/src/game.rs | 11 +
|
|
crates/horizon-client/src/game/film.rs | 17 +
|
|
crates/horizon-client/src/game/mod_models.rs | 42 +-
|
|
crates/horizon-client/src/game/mods.rs | 60 +++
|
|
crates/horizon-client/src/gfx.rs | 5 +
|
|
.../src/gfx/THIRD_PARTY_PHOTON.md | 2 +-
|
|
crates/horizon-client/src/gfx/hd/cloud.wgsl | 233 +++++++----
|
|
.../src/gfx/hd/cloud_shadow.wgsl | 17 +-
|
|
crates/horizon-client/src/gfx/hd/fog.wgsl | 86 +++-
|
|
crates/horizon-client/src/gfx/hd/frame.rs | 4 +-
|
|
crates/horizon-client/src/gfx/hd/ground.rs | 208 ++++++++++
|
|
crates/horizon-client/src/gfx/hd/light.wgsl | 26 +-
|
|
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
|
|
crates/horizon-client/src/gfx/hd/materials.rs | 2 +-
|
|
crates/horizon-client/src/gfx/hd/mod.rs | 18 +-
|
|
.../horizon-client/src/gfx/hd/pano_funcs.wgsl | 18 +
|
|
crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 +
|
|
crates/horizon-client/src/gfx/hd/sky.rs | 3 +
|
|
crates/horizon-client/src/gfx/hd/smoke.rs | 304 +++++++++-----
|
|
.../horizon-client/src/gfx/hd/smoke_grid.wgsl | 6 +-
|
|
.../src/gfx/hd/smoke_sample.wgsl | 29 ++
|
|
crates/horizon-client/src/gfx/hd/volumes.rs | 144 ++++++-
|
|
crates/horizon-client/src/gfx/hd/water.rs | 21 +-
|
|
crates/horizon-client/src/gfx/hd/water.wgsl | 3 +-
|
|
crates/horizon-client/src/gfx/mod_post.rs | 2 +-
|
|
crates/horizon-client/src/gfx/native_post.rs | 2 +-
|
|
.../horizon-client/src/gfx/pack_uniforms.rs | 2 +-
|
|
crates/horizon-client/src/lib.rs | 2 +
|
|
crates/horizon-client/src/world.rs | 129 ++++--
|
|
crates/horizon-client/src/world_log.rs | 372 ++++++++++++++++++
|
|
crates/horizon-mod-sdk/wit/horizon.wit | 26 ++
|
|
crates/horizon-mods/src/api_level.rs | 9 +-
|
|
crates/horizon-mods/src/runtime.rs | 50 +++
|
|
docs/api/mods-reference.md | 4 +
|
|
35 files changed, 1657 insertions(+), 250 deletions(-)
|
|
create mode 100644 crates/horizon-client/src/film_clock.rs
|
|
create mode 100644 crates/horizon-client/src/gfx/hd/ground.rs
|
|
create mode 100644 crates/horizon-client/src/gfx/hd/pano_funcs.wgsl
|
|
create mode 100644 crates/horizon-client/src/world_log.rs
|
|
|
|
diff --git a/crates/horizon-client/src/film_clock.rs b/crates/horizon-client/src/film_clock.rs
|
|
new file mode 100644
|
|
index 000000000..6396e4c1f
|
|
--- /dev/null
|
|
+++ b/crates/horizon-client/src/film_clock.rs
|
|
@@ -0,0 +1,45 @@
|
|
+//! AirDef fork: the clock of animations that run on wall time (clouds, water, wind, post-pass
|
|
+//! time, smoothing steps). While a film is captured the game's frames are not real time (each
|
|
+//! takes as long as it takes to render, and a 120 fps film has twice the frames of a 60 fps one
|
|
+//! in the same film second), so these animations read this clock instead: it stands at the
|
|
+//! film's own time, `1 / fps` further each frame, and is the real clock the rest of the time.
|
|
+use std::sync::Mutex;
|
|
+use std::time::{Duration, Instant};
|
|
+
|
|
+static FILM: Mutex<Option<Instant>> = Mutex::new(None);
|
|
+
|
|
+/// The film's moment (frozen for the frame), or None to run on real time again.
|
|
+pub fn set(now: Option<Instant>) {
|
|
+ if let Ok(mut f) = FILM.lock() {
|
|
+ *f = now;
|
|
+ }
|
|
+}
|
|
+
|
|
+/// What the animations take as now.
|
|
+pub fn now() -> Instant {
|
|
+ FILM.lock()
|
|
+ .ok()
|
|
+ .and_then(|f| *f)
|
|
+ .unwrap_or_else(Instant::now)
|
|
+}
|
|
+
|
|
+/// Time since `start` on this clock.
|
|
+pub fn since(start: Instant) -> Duration {
|
|
+ now().saturating_duration_since(start)
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+
|
|
+ #[test]
|
|
+ fn film_time_is_the_frames_not_the_wall() {
|
|
+ let origin = Instant::now();
|
|
+ set(Some(origin + Duration::from_millis(50)));
|
|
+ assert_eq!(since(origin), Duration::from_millis(50));
|
|
+ std::thread::sleep(Duration::from_millis(5));
|
|
+ assert_eq!(since(origin), Duration::from_millis(50));
|
|
+ set(None);
|
|
+ assert!(since(origin) >= Duration::from_millis(5));
|
|
+ }
|
|
+}
|
|
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
|
index bace52193..3f776fd3f 100644
|
|
--- a/crates/horizon-client/src/game.rs
|
|
+++ b/crates/horizon-client/src/game.rs
|
|
@@ -11881,7 +11881,17 @@ impl Game<'_> {
|
|
let hd_lights = self.mod_lights(camera, alpha);
|
|
let hd_spots = self.mod_spots(camera, alpha);
|
|
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
|
|
+ // AirDef fork: HD's fog reads the ground's height round the camera.
|
|
+ let hd_ground = |x: i32, z: i32| {
|
|
+ self.world
|
|
+ .world_surface(x, z, |s| self.res.facts().is_air(s))
|
|
+ };
|
|
let frame = Frame {
|
|
+ hd_ground: if self.mod_hd().is_some() {
|
|
+ Some(&hd_ground)
|
|
+ } else {
|
|
+ None
|
|
+ },
|
|
mod_views: &mod_views,
|
|
hd: self.mod_hd(),
|
|
hd_lights: &hd_lights,
|
|
@@ -14828,6 +14838,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
|
hd_spots: &[],
|
|
hd_smoke: &[],
|
|
hd_smoke_clock: 0.0,
|
|
+ hd_ground: None,
|
|
weather: &[],
|
|
weather_time: horizon_client::weather::Time::new(0, 0.0),
|
|
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
|
diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs
|
|
index 8d1995030..2ae449fc5 100644
|
|
--- a/crates/horizon-client/src/game/film.rs
|
|
+++ b/crates/horizon-client/src/game/film.rs
|
|
@@ -96,6 +96,8 @@ pub(super) struct Film {
|
|
view: Option<u32>,
|
|
/// Audio frames mixed so far.
|
|
mixed: u64,
|
|
+ /// Where the film's own clock (`film_clock`) began: real time at the first frame.
|
|
+ origin: std::time::Instant,
|
|
/// Told to stop (cancel or not) and what the writer is finishing.
|
|
cancel: bool,
|
|
note: String,
|
|
@@ -476,6 +478,10 @@ impl Game<'_> {
|
|
let n = film.stepped;
|
|
film.stepped += 1;
|
|
let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps;
|
|
+ // Animations on wall time follow the film's frames, not the machine's speed.
|
|
+ horizon_client::film_clock::set(Some(
|
|
+ film.origin + Duration::from_nanos(at(n + 1)),
|
|
+ ));
|
|
Duration::from_nanos(at(n + 1) - at(n))
|
|
}
|
|
_ => real,
|
|
@@ -535,6 +541,14 @@ impl Game<'_> {
|
|
};
|
|
}
|
|
}
|
|
+ if self
|
|
+ .film
|
|
+ .film
|
|
+ .as_ref()
|
|
+ .is_none_or(|f| f.phase != Phase::Recording)
|
|
+ {
|
|
+ horizon_client::film_clock::set(None);
|
|
+ }
|
|
// Film quality: a capture needs it; otherwise it is the mods' choice.
|
|
let want = self.film.film.is_some() || self.mods_film_quality();
|
|
horizon_client::gfx::set_film_quality(want);
|
|
@@ -585,6 +599,7 @@ impl Game<'_> {
|
|
thread: None,
|
|
view: None,
|
|
mixed: 0,
|
|
+ origin: std::time::Instant::now(),
|
|
cancel: false,
|
|
note: String::new(),
|
|
})
|
|
@@ -643,6 +658,8 @@ impl Game<'_> {
|
|
film.sink = Some(tx);
|
|
film.phase = Phase::Recording;
|
|
film.stepped = 0;
|
|
+ film.origin = std::time::Instant::now();
|
|
+ horizon_client::film_clock::set(Some(film.origin));
|
|
if film.req.audio {
|
|
self.audio.film_begin();
|
|
}
|
|
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
|
index 9367bb994..78ee8448b 100644
|
|
--- a/crates/horizon-client/src/game/mod_models.rs
|
|
+++ b/crates/horizon-client/src/game/mod_models.rs
|
|
@@ -53,7 +53,6 @@ const MODEL_GLOW: u32 = 40;
|
|
const MODEL_PAINTED: u32 = 41;
|
|
/// Bare metal: smooth and fully metallic (entity.wgsl's fs_entity_gbuffer).
|
|
#[cfg(feature = "mods")]
|
|
-#[allow(dead_code)]
|
|
const MODEL_BARE_METAL: u32 = 42;
|
|
/// Full brightness: sky and block light 15, as the entity shader packs them.
|
|
#[cfg(feature = "mods")]
|
|
@@ -330,8 +329,8 @@ impl Game<'_> {
|
|
let a = DVec3::new(s.ax, s.ay, s.az) - camera;
|
|
let b = DVec3::new(s.bx, s.by, s.bz) - camera;
|
|
let near = a.length().min(b.length());
|
|
- // Far past the grid, nothing of it can show.
|
|
- if near > 600.0 {
|
|
+ // Far past the far grid (768 blocks), nothing of it can show.
|
|
+ if near > 900.0 {
|
|
return None;
|
|
}
|
|
let seed = (s.id.wrapping_mul(2_654_435_761) >> 8) as f32 / 16_777_216.0;
|
|
@@ -347,7 +346,7 @@ impl Game<'_> {
|
|
})
|
|
.collect();
|
|
out.sort_by(|x, y| x.0.total_cmp(&y.0));
|
|
- out.truncate(3072);
|
|
+ out.truncate(8192);
|
|
(
|
|
out.into_iter().map(|(_, s)| s).collect(),
|
|
clock.rem_euclid(86_400.0 * 20.0) as f32 / 20.0,
|
|
@@ -399,6 +398,25 @@ impl Game<'_> {
|
|
let reach = depth_far * 0.85;
|
|
// A mod camera (a replay's, a system's) must see them where they are: no impostors then.
|
|
let impostors = self.mods.models.views.is_empty();
|
|
+ // Sprites a model asks a material of by name: `glow_*` glows (40), `bare_*` and `glass_*`
|
|
+ // are bare metal / glass (42); the rest is painted (41). Matched by atlas rectangle below.
|
|
+ let special: Vec<([f32; 4], u32)> = self
|
|
+ .gui
|
|
+ .atlas
|
|
+ .sprites
|
|
+ .iter()
|
|
+ .filter_map(|(n, r)| {
|
|
+ let f = n.rsplit('/').next()?;
|
|
+ let m = if f.starts_with("glow_") {
|
|
+ MODEL_GLOW
|
|
+ } else if f.starts_with("bare_") || f.starts_with("glass_") {
|
|
+ MODEL_BARE_METAL
|
|
+ } else {
|
|
+ return None;
|
|
+ };
|
|
+ Some(([r.u0, r.v0, r.u1, r.v1], m))
|
|
+ })
|
|
+ .collect();
|
|
for p in self.mods.models.poses.values() {
|
|
let mut at = p.previous.lerp(p.position, t);
|
|
let d = at.distance(camera);
|
|
@@ -439,6 +457,22 @@ impl Game<'_> {
|
|
for v in &mut out[start..] {
|
|
v.layer = if p.glow { MODEL_GLOW } else { MODEL_PAINTED };
|
|
}
|
|
+ if !p.glow {
|
|
+ // A quad takes its material from the sprite under its centre (the corners may touch a neighbour).
|
|
+ for q in out[start..].chunks_mut(4) {
|
|
+ let n = q.len() as f32;
|
|
+ let u = q.iter().map(|v| v.uv[0]).sum::<f32>() / n;
|
|
+ let w = q.iter().map(|v| v.uv[1]).sum::<f32>() / n;
|
|
+ if let Some((_, m)) = special.iter().find(|(r, _)| u > r[0] && u < r[2] && w > r[1] && w < r[3]) {
|
|
+ for v in q {
|
|
+ v.layer = *m;
|
|
+ if *m == MODEL_GLOW {
|
|
+ v.light = GLOW;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
}
|
|
}
|
|
|
|
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
|
index 6623905f0..b4d332f41 100644
|
|
--- a/crates/horizon-client/src/game/mods.rs
|
|
+++ b/crates/horizon-client/src/game/mods.rs
|
|
@@ -280,6 +280,7 @@ impl Game<'_> {
|
|
pub(super) fn sync_mods(&mut self) {}
|
|
pub(super) fn tick_mods(&mut self) {}
|
|
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
|
+ pub(super) fn sync_world_log(&mut self) {}
|
|
pub(super) fn mods_film_requests(
|
|
&mut self,
|
|
) -> (Option<super::film::FilmRequest>, Option<bool>) {
|
|
@@ -716,6 +717,7 @@ impl Game<'_> {
|
|
}
|
|
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
|
self.reload_dev_mods();
|
|
+ self.sync_world_log();
|
|
if !self.mods.mods.is_running() {
|
|
return;
|
|
}
|
|
@@ -727,6 +729,64 @@ impl Game<'_> {
|
|
self.mods.mods.frame(elapsed.as_secs_f32());
|
|
self.show_mod_output();
|
|
}
|
|
+ /// AirDef fork: the block-change log a mod asked for (`render.world-log-start`), what it
|
|
+ /// covers, and the mod's view of the world at an earlier tick (`set-world-time-view`),
|
|
+ /// moved on a little each frame (all at once while a film is recorded: its frames are not
|
|
+ /// real time). Only where the world is a server's copy: with the world run in the frame
|
|
+ /// the blocks are the game's own and are never shown other than they are.
|
|
+ pub(super) fn sync_world_log(&mut self) {
|
|
+ use horizon_client::world_log::WorldLog;
|
|
+ self.world.log_tick = self.ticks;
|
|
+ let wanted = if self.remote.is_some() {
|
|
+ self.mods.mods.world_log_wanted()
|
|
+ } else {
|
|
+ None
|
|
+ };
|
|
+ match wanted {
|
|
+ Some(id) => {
|
|
+ if self.world.log.as_ref().is_none_or(|l| l.id != id) {
|
|
+ self.world.log = Some(WorldLog::new(id, self.ticks));
|
|
+ }
|
|
+ }
|
|
+ None => {
|
|
+ if self.world.log.is_some() {
|
|
+ // Every displaced block back, then the log goes.
|
|
+ self.world.view_log(None, usize::MAX, None);
|
|
+ self.world.log = None;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ let id = self.world.log.as_ref().map(|l| l.id);
|
|
+ let state = self
|
|
+ .world
|
|
+ .log
|
|
+ .as_ref()
|
|
+ .map(|l| (l.id, l.earliest(), self.ticks));
|
|
+ self.mods.mods.set_world_log_state(state);
|
|
+ let target = self
|
|
+ .mods
|
|
+ .mods
|
|
+ .time_view()
|
|
+ .filter(|v| Some(v.0) == id)
|
|
+ .map(|v| v.1);
|
|
+ let active = self.world.log.as_ref().is_some_and(|l| l.active());
|
|
+ if target.is_some() || active {
|
|
+ // The player's own neighbourhood stays as it is: nobody falls through a view.
|
|
+ let protect = self.player.as_ref().map(|p| {
|
|
+ [
|
|
+ p.position.x.floor() as i32,
|
|
+ p.position.y.floor() as i32,
|
|
+ p.position.z.floor() as i32,
|
|
+ ]
|
|
+ });
|
|
+ let budget = if self.film.recording() {
|
|
+ usize::MAX
|
|
+ } else {
|
|
+ 12_000
|
|
+ };
|
|
+ self.world.view_log(target, budget, protect);
|
|
+ }
|
|
+ }
|
|
/// `/command` typed in chat: true when a mod owns it.
|
|
pub(super) fn mod_command(&mut self, command: &str) -> bool {
|
|
if !self.mods.mods.is_running() {
|
|
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
|
index 9482cf0d2..5b221fd7e 100644
|
|
--- a/crates/horizon-client/src/gfx.rs
|
|
+++ b/crates/horizon-client/src/gfx.rs
|
|
@@ -245,6 +245,8 @@ pub struct Frame<'a> {
|
|
/// in seconds.
|
|
pub hd_smoke: &'a [HdSmoke],
|
|
pub hd_smoke_clock: f32,
|
|
+ /// One above the highest block of a world column (for HD's terrain-aware fog), if known.
|
|
+ pub hd_ground: Option<&'a (dyn Fn(i32, i32) -> i32 + 'a)>,
|
|
pub yaw: f32,
|
|
pub pitch: f32,
|
|
pub fov_degrees: f32,
|
|
@@ -5266,6 +5268,9 @@ impl Renderer {
|
|
frame.hd_lights,
|
|
frame.hd_smoke_clock,
|
|
);
|
|
+ if let Some(top) = frame.hd_ground {
|
|
+ hd.write_ground(&self.queue, camera.x, camera.z, top);
|
|
+ }
|
|
hd.write_spots(frame.hd_spots);
|
|
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
|
index fd9f4d029..15838a3f7 100644
|
|
--- a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
|
+++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
|
@@ -19,7 +19,7 @@ edge sharpening, Worley erosion, the single and multiple scattering phase functi
|
|
the eight-octave multiple-scattering loop, the light march with doubling steps, the transmittance remap,
|
|
the aerial perspective), `include/lighting/cloud_shadows.glsl` (the cloud shadow map, here a lookup into a
|
|
small map) and `include/fog/air_fog_vl.glsl` (the phase mix of two Henyey-Greenstein lobes, the evening
|
|
-glow). The 3D Worley and 2D noise textures are generated on our side; the ray march, the passes, the
|
|
+glow). The cirrus layer follows the structure of `include/sky/clouds/cirrus.glsl` (a thin 2D layer at height, noise-driven coverage, forward-scattering phase, no light march), written from that structure, not copied. The 3D Worley and 2D noise textures are generated on our side; the ray march, the passes, the
|
|
reprojection and the integration are our own.
|
|
|
|
Photon is distributed under the MIT License:
|
|
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
|
index b2bdd3080..18aac24f6 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
|
@@ -75,6 +75,143 @@ fn cloud_scatter(
|
|
return s * integral;
|
|
}
|
|
|
|
+// ---- The high cirrus layer: a thin flat sheet of wisps far above the cumulus, no light march. ----
|
|
+
|
|
+// Its height above the cumulus layer's base, and its extinction at full density.
|
|
+const CIRRUS_ABOVE: f32 = 707.67;
|
|
+const CIRRUS_EXT: f32 = 1.3;
|
|
+const CIRRUS_WIND: vec2<f32> = vec2<f32>(5.5, 1.2);
|
|
+
|
|
+// The density of the cirrus at a point of its plane (world x, z). Stretched along x (the wind), the
|
|
+// coverage from a very large field, a warp that bends the streaks, then streaks and fine wisps.
|
|
+// Every scale divides 65536 (the camera's wrap).
|
|
+fn cirrus_density(cir: vec2<f32>) -> f32 {
|
|
+ let cov = textureSampleLevel(noise2, repeat_s, vec2<f32>(cir.x / 32768.0, cir.y / 16384.0), 0.0).r;
|
|
+ let warp = (textureSampleLevel(noise2, repeat_s, vec2<f32>(cir.x / 16384.0, cir.y / 8192.0), 0.0).g - 0.5) * 1200.0;
|
|
+ let cw = vec2<f32>(cir.x, cir.y + warp);
|
|
+ let streak = textureSampleLevel(noise2, repeat_s, vec2<f32>(cw.x / 8192.0, cw.y / 1024.0), 0.0).b;
|
|
+ let wisp = textureSampleLevel(noise2, repeat_s, vec2<f32>(cw.x / 4096.0, cw.y / 512.0), 0.0).b;
|
|
+ let fine = textureSampleLevel(noise2, repeat_s, vec2<f32>(cw.x / 2048.0, cw.y / 256.0), 0.0).a;
|
|
+ let n = 0.5 * streak + 0.3 * wisp + 0.2 * fine;
|
|
+ // Rain brings more of it.
|
|
+ let t = mix(0.64, 0.50, cov) - frame.cloud_cam.z * 0.8;
|
|
+ let d = smoothstep(t, t + 0.13, n);
|
|
+ return d * sqrt(d);
|
|
+}
|
|
+
|
|
+// The cirrus along `dir`: light and transmittance (a), lit by the same sun as the cumulus (tinted at
|
|
+// sunset), thin enough to need no march. Behind the cumulus, fading out towards the horizon.
|
|
+fn cirrus(dir: vec3<f32>) -> vec4<f32> {
|
|
+ let rel = frame.clouds.x + CIRRUS_ABOVE;
|
|
+ if abs(dir.y) < 0.02 { return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
|
|
+ let t = rel / dir.y;
|
|
+ if t <= 0.0 || t > 45000.0 { return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
|
|
+ let cir = vec2<f32>(frame.cloud_cam.x + dir.x * t, frame.cloud_cam.y + dir.z * t)
|
|
+ + CIRRUS_WIND * frame.clouds.w;
|
|
+ let d = cirrus_density(cir);
|
|
+ let fade = smoothstep(0.03, 0.2, abs(dir.y));
|
|
+ let od = d * CIRRUS_EXT * fade;
|
|
+ if od < 0.003 { return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
|
|
+ let overcast = frame.sky_extra.z;
|
|
+ let light = frame.cloud_dir.xyz;
|
|
+ let cos_theta = dot(dir, light);
|
|
+ let sun_light = frame.cloud_sun.rgb * frame.cloud_sun.w * (1.0 - overcast);
|
|
+ let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
|
|
+ let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
|
|
+ let ambient = mix(horizon, zenith, 0.55) * (1.0 - 0.4 * overcast);
|
|
+ // A thin sheet scatters forward: bright near the sun, a pale veil elsewhere.
|
|
+ let direct = sun_light * (0.5 + 3.0 * hg(cos_theta, 0.7));
|
|
+ let trans = exp(-od);
|
|
+ var colour = (1.0 - trans) * (direct + ambient * 0.9) * 1.1;
|
|
+ let haze = haze_colour(dir);
|
|
+ colour = mix((1.0 - trans) * haze, colour, exp(-t / 16000.0));
|
|
+ return vec4<f32>(colour, trans);
|
|
+}
|
|
+
|
|
+// Cirrus behind a cumulus result (`c`: light, transmittance).
|
|
+fn behind(c: vec4<f32>, far: vec4<f32>) -> vec4<f32> {
|
|
+ return vec4<f32>(c.rgb + c.a * far.rgb, c.a * far.a);
|
|
+}
|
|
+
|
|
+struct March {
|
|
+ cloud: vec4<f32>,
|
|
+ hit_t: f32,
|
|
+}
|
|
+
|
|
+// The cumulus slab along `dir` up to `scene_len`: light and transmittance. `lite` is the cheap
|
|
+// version for the sky panorama (fewer steps, a shorter light march, no flying thing's tunnel).
|
|
+fn march_cumulus(dir: vec3<f32>, scene_len: f32, dither: f32, lite: bool) -> March {
|
|
+ var cur = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
+ var hit_t = 900.0;
|
|
+ let y0 = frame.clouds.x;
|
|
+ let y1 = y0 + frame.clouds.y;
|
|
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
|
|
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
|
|
+ let ta = y0 / dy;
|
|
+ let tb = y1 / dy;
|
|
+ let lo = max(min(ta, tb), 0.0);
|
|
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
|
|
+ if hi > lo {
|
|
+ // Film quality (surface.z): half the step length, twice the cap.
|
|
+ let fine = frame.surface.z > 0.5 && !lite;
|
|
+ let n = select(
|
|
+ clamp(i32(ceil((hi - lo) / select(14.0, 7.0, fine))), select(8, 16, fine), select(26, 52, fine)),
|
|
+ 14,
|
|
+ lite,
|
|
+ );
|
|
+ let light_steps = select(3, 2, lite);
|
|
+ let step_len = (hi - lo) / f32(n);
|
|
+ let light = frame.cloud_dir.xyz;
|
|
+ let cos_theta = dot(dir, light);
|
|
+ let overcast = frame.sky_extra.z;
|
|
+ let ext = CLOUD_EXT * (1.0 - 0.25 * overcast);
|
|
+ let sca = ext;
|
|
+ let bounced = 0.4 * max(light.y, 0.0) / CPI;
|
|
+ var scatter = vec2<f32>(0.0);
|
|
+ var t_acc = 1.0;
|
|
+ var dist_sum = 0.0;
|
|
+ var weight_sum = 0.0;
|
|
+ for (var i = 0; i < n; i++) {
|
|
+ if t_acc < 0.075 { break; }
|
|
+ let t = lo + step_len * (f32(i) + dither);
|
|
+ let pos = dir * t;
|
|
+ var d = cloud_density(pos, 1.0);
|
|
+ if d < 0.002 { continue; }
|
|
+ d *= 1.0 - smoothstep(0.8, 1.0, t / 4200.0);
|
|
+ // A flying thing's tunnel through the cloud.
|
|
+ if !lite && frame.smoke.x > 0.5 { d *= 1.0 - smoke_at(smoke_tex, linear, pos).a; }
|
|
+ let step_od = d * ext * step_len;
|
|
+ let step_t = exp(-step_od);
|
|
+ let h = clamp((pos.y - y0) / frame.clouds.y, 0.0, 1.0);
|
|
+ let hash = fract(dither * 7.13 + f32(i) * 0.618);
|
|
+ let light_od = cloud_light_depth(pos, light, light_steps, hash, 1.0);
|
|
+ let sky_od = cloud_light_depth(pos, vec3<f32>(0.0, 1.0, 0.0), 1, 1.0 - hash, 0.0);
|
|
+ // A guess of the optical depth to the ground from the altitude and this density.
|
|
+ let ground_od = mix(d, 1.0, clamp(h * 2.0 - 1.0, 0.0, 1.0)) * h * frame.clouds.y;
|
|
+ scatter += cloud_scatter(d, light_od, sky_od, ground_od, ext, sca, step_t, cos_theta, bounced) * t_acc;
|
|
+ t_acc *= step_t;
|
|
+ dist_sum += t * d;
|
|
+ weight_sum += d;
|
|
+ }
|
|
+ if weight_sum > 0.0 { hit_t = dist_sum / weight_sum; }
|
|
+ // The sunlight's colour at this hour (Photon: the light's colour from the transmittance),
|
|
+ // the sky's own light from the zenith and the horizon; rain darkens the sky's share too.
|
|
+ let sun_light = frame.cloud_sun.rgb * frame.cloud_sun.w * (1.0 - overcast);
|
|
+ let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
|
|
+ let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
|
|
+ let ambient = mix(horizon, zenith, 0.55) * (1.0 - 0.4 * overcast);
|
|
+ var colour = scatter.x * sun_light * 3.2 + scatter.y * ambient * 5.0;
|
|
+ // The remap of Photon: the transmittance's floor is 0.
|
|
+ let trans = linear_step(0.075, 1.0, t_acc);
|
|
+ // Aerial perspective: far clouds fade into the haze.
|
|
+ let haze = haze_colour(dir);
|
|
+ let air = exp(-hit_t / 3600.0);
|
|
+ colour = mix((1.0 - trans) * haze, colour, air);
|
|
+ cur = vec4<f32>(colour, trans);
|
|
+ }
|
|
+ return March(cur, hit_t);
|
|
+}
|
|
+
|
|
@fragment
|
|
fn fs_cloud(in: FullOut) -> CloudOut {
|
|
// The nearest of the four world pixels this texel stands for: the march ends at the surface
|
|
@@ -99,72 +236,11 @@ fn fs_cloud(in: FullOut) -> CloudOut {
|
|
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
|
|
let hh = frame.inverse_view_proj * ndc;
|
|
let dir = normalize(hh.xyz / hh.w);
|
|
- var cur = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
- var hit_t = 900.0;
|
|
- let y0 = frame.clouds.x;
|
|
- let y1 = y0 + frame.clouds.y;
|
|
- // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
|
|
- let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
|
|
- {
|
|
- let ta = y0 / dy;
|
|
- let tb = y1 / dy;
|
|
- let lo = max(min(ta, tb), 0.0);
|
|
- let hi = min(max(ta, tb), min(4200.0, scene_len));
|
|
- if hi > lo {
|
|
- let dither = ign(in.position.xy);
|
|
- // Film quality (surface.z): half the step length, twice the cap.
|
|
- let fine = frame.surface.z > 0.5;
|
|
- let n = clamp(i32(ceil((hi - lo) / select(14.0, 7.0, fine))), select(8, 16, fine), select(26, 52, fine));
|
|
- let step_len = (hi - lo) / f32(n);
|
|
- let light = frame.cloud_dir.xyz;
|
|
- let cos_theta = dot(dir, light);
|
|
- let overcast = frame.sky_extra.z;
|
|
- let ext = CLOUD_EXT * (1.0 - 0.25 * overcast);
|
|
- let sca = ext;
|
|
- let bounced = 0.4 * max(light.y, 0.0) / CPI;
|
|
- var scatter = vec2<f32>(0.0);
|
|
- var t_acc = 1.0;
|
|
- var dist_sum = 0.0;
|
|
- var weight_sum = 0.0;
|
|
- for (var i = 0; i < n; i++) {
|
|
- if t_acc < 0.075 { break; }
|
|
- let t = lo + step_len * (f32(i) + dither);
|
|
- let pos = dir * t;
|
|
- var d = cloud_density(pos, 1.0);
|
|
- if d < 0.002 { continue; }
|
|
- d *= 1.0 - smoothstep(0.8, 1.0, t / 4200.0);
|
|
- // A flying thing's tunnel through the cloud.
|
|
- if frame.smoke.x > 0.5 { d *= 1.0 - smoke_at(smoke_tex, linear, pos).a; }
|
|
- let step_od = d * ext * step_len;
|
|
- let step_t = exp(-step_od);
|
|
- let h = clamp((pos.y - y0) / frame.clouds.y, 0.0, 1.0);
|
|
- let hash = fract(dither * 7.13 + f32(i) * 0.618);
|
|
- let light_od = cloud_light_depth(pos, light, 3, hash, 1.0);
|
|
- let sky_od = cloud_light_depth(pos, vec3<f32>(0.0, 1.0, 0.0), 1, 1.0 - hash, 0.0);
|
|
- // A guess of the optical depth to the ground from the altitude and this density.
|
|
- let ground_od = mix(d, 1.0, clamp(h * 2.0 - 1.0, 0.0, 1.0)) * h * frame.clouds.y;
|
|
- scatter += cloud_scatter(d, light_od, sky_od, ground_od, ext, sca, step_t, cos_theta, bounced) * t_acc;
|
|
- t_acc *= step_t;
|
|
- dist_sum += t * d;
|
|
- weight_sum += d;
|
|
- }
|
|
- if weight_sum > 0.0 { hit_t = dist_sum / weight_sum; }
|
|
- // The sunlight's colour at this hour (Photon: the light's colour from the transmittance),
|
|
- // the sky's own light from the zenith and the horizon.
|
|
- let sun_light = frame.cloud_sun.rgb * frame.cloud_sun.w * (1.0 - overcast);
|
|
- let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
|
|
- let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
|
|
- let ambient = mix(horizon, zenith, 0.55);
|
|
- var colour = scatter.x * sun_light * 3.2 + scatter.y * ambient * 5.0;
|
|
- // The remap of Photon: the transmittance's floor is 0.
|
|
- let trans = linear_step(0.075, 1.0, t_acc);
|
|
- // Aerial perspective: far clouds fade into the haze.
|
|
- let haze = haze_colour(dir);
|
|
- let air = exp(-hit_t / 3600.0);
|
|
- colour = mix((1.0 - trans) * haze, colour, air);
|
|
- cur = vec4<f32>(colour, trans);
|
|
- }
|
|
- }
|
|
+ let m = march_cumulus(dir, scene_len, ign(in.position.xy), false);
|
|
+ var cur = m.cloud;
|
|
+ let hit_t = m.hit_t;
|
|
+ // The cirrus stands behind everything but the sky.
|
|
+ if scene_len >= SKY_LEN * 0.99 { cur = behind(cur, cirrus(dir)); }
|
|
var out: CloudOut;
|
|
out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
|
|
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
|
|
@@ -179,9 +255,11 @@ fn fs_cloud(in: FullOut) -> CloudOut {
|
|
let hs = vec2<f32>(textureDimensions(history_dist));
|
|
let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
|
|
let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
|
|
- // Not too much of a history that disagrees by a lot (a gust, a fast turn).
|
|
+ // The march's own noise (a dithered, short march) also makes the two differ: only
|
|
+ // a clear disagreement (a gust, a fast change) drops the history, so a fast turn
|
|
+ // keeps its smoothness instead of showing the noise of fresh samples.
|
|
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
|
|
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
|
|
+ let keep = 0.92 * (1.0 - smoothstep(0.7, 1.9, diff)) * same_len;
|
|
cur = mix(cur, old, keep);
|
|
}
|
|
}
|
|
@@ -189,3 +267,22 @@ fn fs_cloud(in: FullOut) -> CloudOut {
|
|
out.cloud = cur;
|
|
return out;
|
|
}
|
|
+
|
|
+// The sky panorama: the clouds as seen from the camera in every direction above the horizon
|
|
+// (azimuth u over the whole circle from +x, elevation v = sqrt(e / (pi / 2))), without the scene.
|
|
+// Lite march, blended with last frame's panorama (the history is in world directions, no
|
|
+// reprojection). The water's and the wet surfaces' sky reflections read it.
|
|
+@fragment
|
|
+fn fs_cloud_sky(in: FullOut) -> @location(0) vec4<f32> {
|
|
+ let e = CPI * 0.5 * in.uv.y * in.uv.y;
|
|
+ let phi = (in.uv.x - 0.5) * 2.0 * CPI;
|
|
+ let dir = vec3<f32>(cos(e) * cos(phi), sin(e), cos(e) * sin(phi));
|
|
+ let m = march_cumulus(dir, SKY_LEN, ign(in.position.xy), true);
|
|
+ var cur = behind(m.cloud, cirrus(dir));
|
|
+ if frame.cloud_cam.w > 0.5 {
|
|
+ let old = textureLoad(history, vec2<i32>(in.position.xy), 0);
|
|
+ let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
|
|
+ cur = mix(cur, old, 0.8 * (1.0 - smoothstep(0.6, 1.8, diff)));
|
|
+ }
|
|
+ return cur;
|
|
+}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/cloud_shadow.wgsl b/crates/horizon-client/src/gfx/hd/cloud_shadow.wgsl
|
|
index 8fca87fba..36cfe66ef 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/cloud_shadow.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/cloud_shadow.wgsl
|
|
@@ -1,18 +1,19 @@
|
|
-
|
|
@fragment
|
|
fn fs_cloud_shadow(in: FullOut) -> @location(0) vec4<f32> {
|
|
- // A square of the cloud base plane round the camera; a texel holds the optical depth of the
|
|
- // sunlight's way through the slab from that point.
|
|
- let q = (in.uv - vec2<f32>(0.5)) * (2.0 * SHADOW_R);
|
|
+ // A square of the cloud base plane, centred where the sunlight through the camera crosses it
|
|
+ // (so a low sun, whose shadows fall far from the cloud, still covers the ground round the
|
|
+ // camera); a texel holds the optical depth of the sunlight's way through the slab from there.
|
|
let light = frame.cloud_dir.xyz;
|
|
- let ly = max(light.y, 0.15);
|
|
+ let ly = max(light.y, 0.04);
|
|
+ let centre = light.xz * (frame.clouds.x / ly);
|
|
+ let q = centre + (in.uv - vec2<f32>(0.5)) * (2.0 * SHADOW_R);
|
|
let base = vec3<f32>(q.x, frame.clouds.x + 0.5, q.y);
|
|
let len = frame.clouds.y / ly;
|
|
var od = 0.0;
|
|
- for (var i = 0; i < 4; i++) {
|
|
- let t = (f32(i) + 0.5) / 4.0 * len;
|
|
+ for (var i = 0; i < 8; i++) {
|
|
+ let t = (f32(i) + 0.5) / 8.0 * len;
|
|
od += cloud_density(base + light * t, 0.0);
|
|
}
|
|
- od *= CLOUD_EXT * len / 4.0 * (1.0 - 0.25 * frame.sky_extra.z);
|
|
+ od *= CLOUD_EXT * len / 8.0 * (1.0 - 0.25 * frame.sky_extra.z);
|
|
return vec4<f32>(od, 0.0, 0.0, 1.0);
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
|
index 3fde6e22f..40a81e540 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
|
@@ -8,6 +8,11 @@
|
|
@group(0) @binding(6) var linear: sampler;
|
|
//SKY7
|
|
@group(0) @binding(8) var smoke_tex: texture_2d<f32>;
|
|
+// The ground's height (r) and basin depth (g) round the camera, 8-block cells, stored toroidally
|
|
+// (64 cells = 512 blocks): read with the world x, z over 512 and a repeating sampler.
|
|
+@group(0) @binding(9) var ground_tex: texture_2d<f32>;
|
|
+// The far smoke grid (12-block cells).
|
|
+@group(0) @binding(10) var smoke_far_tex: texture_2d<f32>;
|
|
|
|
//SPOT
|
|
|
|
@@ -49,24 +54,42 @@ fn sun_visible(p: vec3<f32>) -> f32 {
|
|
}
|
|
|
|
// The air's extinction per block at a camera-relative point: a haze that thins with height above
|
|
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
|
|
-// deep underground.
|
|
+// the sea and pools a little in valleys, a morning mist that follows the ground (it fills a valley
|
|
+// up to about its rim, and clings thin to flat ground and ridges), rain, patches from a 3D noise
|
|
+// that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at
|
|
+// x or z = 0); none deep under the ground.
|
|
fn fog_sigma(p: vec3<f32>) -> f32 {
|
|
let wy = p.y + frame.vfog.y;
|
|
+ let wxz = vec2<f32>(p.x + frame.cloud_cam.x, p.z + frame.cloud_cam.y);
|
|
+ let gr = textureSampleLevel(ground_tex, repeat_s, wxz / 512.0, 0.0).rg;
|
|
+ let ground = gr.x;
|
|
+ let valley = smoothstep(3.0, 24.0, gr.y);
|
|
+ let above = max(wy - ground, 0.0);
|
|
let h = wy - 62.0;
|
|
var s = 0.0016 * exp(clamp(-h / 42.0, -6.0, 1.3));
|
|
- s += frame.vfog.z * 0.011 * exp(clamp(-h / 15.0, -6.0, 1.0));
|
|
+ // Valleys hold some haze through the day too.
|
|
+ s += 0.0012 * valley * exp(clamp(-above / (10.0 + 0.6 * gr.y), -6.0, 0.0));
|
|
+ // Mist: in a valley it fills up to the rim (three quarters of the way to the average ground)
|
|
+ // and fades over a few blocks above; elsewhere it hugs the ground.
|
|
+ let rim = ground + 0.75 * gr.y;
|
|
+ let fill = exp(clamp(-max(wy - rim, 0.0) / 9.0, -6.0, 0.0));
|
|
+ let hug = exp(clamp(-above / 11.0, -6.0, 0.0));
|
|
+ s += frame.vfog.z * 0.011 * mix(0.55 * hug, 1.35 * fill, valley);
|
|
s += frame.sky_extra.z * 0.003;
|
|
- let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
|
|
- + vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
|
|
+ let w = vec3<f32>(wxz.x, wy, wxz.y) + vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
|
|
let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
|
|
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
|
|
- // Fade out far below the sea's height: caves keep their own dark air.
|
|
- return s * patchy * smoothstep(28.0, 58.0, wy);
|
|
+ // Fade out below the ground (caves keep their own dark air); unknown ground reads as the sea.
|
|
+ // A low sun shows its shafts better in a little denser air (Photon thickens the fog at dawn and dusk too).
|
|
+ let low_sun = (1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y)) * min(frame.sun.w * 3.0, 1.0);
|
|
+ return s * patchy * (1.0 + 0.9 * low_sun) * smoothstep(ground - 14.0, ground - 3.0, wy);
|
|
}
|
|
|
|
const SMOKE_MAX: f32 = 200.0;
|
|
const SMOKE_STEP: f32 = 3.5;
|
|
+// The far grid's range and its step.
|
|
+const SMOKE_FAR_MAX: f32 = 800.0;
|
|
+const SMOKE_FAR_STEP: f32 = 12.0;
|
|
const SMOKE_WIND: vec2<f32> = vec2<f32>(1.6, 1.2);
|
|
|
|
fn fire_colour(h: f32) -> vec3<f32> {
|
|
@@ -86,17 +109,29 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
|
|
return core * mix(0.35, 1.45, keep) * mix(0.6, 1.0, smoothstep(0.0, 0.6, core));
|
|
}
|
|
|
|
-// The smoke between the camera and `len` along `dir`: light and transmittance (a).
|
|
-fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f32) -> vec4<f32> {
|
|
- let lo = frame.smoke_min.xyz;
|
|
- let hi = lo + vec3<f32>(128.0, 64.0, 128.0) * frame.smoke.y;
|
|
+// One cascade of the smoke grid at a point: the near one raw (the cover below weighs it), the far
|
|
+// one with its edge fade.
|
|
+fn smoke_cell(far: bool, p: vec3<f32>) -> vec4<f32> {
|
|
+ if far { return smoke_far_at(smoke_far_tex, linear, p); }
|
|
+ return smoke_raw(smoke_tex, linear, p, frame.smoke_min.xyz, frame.smoke.y);
|
|
+}
|
|
+
|
|
+// The smoke of one cascade between the camera and `len` along `dir`: light and transmittance (a).
|
|
+// The near cascade weighs a point by `smoke_near_cover`, the far one by what is left, so the seam
|
|
+// carries the same smoke either way.
|
|
+fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f32, far: bool) -> vec4<f32> {
|
|
+ let cell = select(frame.smoke.y, frame.smoke_far.w, far);
|
|
+ let lo = select(frame.smoke_min.xyz, frame.smoke_far.xyz, far);
|
|
+ let hi = lo + vec3<f32>(128.0, 64.0, 128.0) * cell;
|
|
+ let near_on = frame.smoke.x > 0.5;
|
|
let inv = vec3<f32>(1.0) / select(dir, vec3<f32>(1e-5), abs(dir) < vec3<f32>(1e-5));
|
|
let t0v = (lo - vec3<f32>(0.0)) * inv;
|
|
let t1v = (hi - vec3<f32>(0.0)) * inv;
|
|
let t_in = max(max(min(t0v.x, t1v.x), min(t0v.y, t1v.y)), max(min(t0v.z, t1v.z), 0.0));
|
|
let t_out = min(min(max(t0v.x, t1v.x), max(t0v.y, t1v.y)), min(max(t0v.z, t1v.z), len));
|
|
if t_out <= t_in { return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
|
|
- let n = clamp(i32(ceil((t_out - t_in) / SMOKE_STEP)), 1, 64);
|
|
+ let step_len = select(SMOKE_STEP, SMOKE_FAR_STEP, far);
|
|
+ let n = clamp(i32(ceil((t_out - t_in) / step_len)), 1, select(64, 72, far));
|
|
let ds = (t_out - t_in) / f32(n);
|
|
let sun = frame.sun.xyz;
|
|
let sun_on = frame.sun.w;
|
|
@@ -108,9 +143,13 @@ fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f3
|
|
for (var i = 0; i < n; i++) {
|
|
if trans < 0.02 { break; }
|
|
let pos = dir * (t_in + (f32(i) + jitter) * ds);
|
|
- if smoke_probe(smoke_tex, linear, pos) < 0.003 { continue; }
|
|
- let g = smoke_at(smoke_tex, linear, pos);
|
|
+ let cover = select(0.0, smoke_near_cover(pos), near_on);
|
|
+ let weight = select(cover, 1.0 - cover, far);
|
|
+ if weight < 0.003 { continue; }
|
|
+ var g = smoke_cell(far, pos);
|
|
if g.x < 0.004 { continue; }
|
|
+ g *= weight;
|
|
+ if g.x < 0.002 { continue; }
|
|
let dens = smoke_detail(pos, g.x);
|
|
let a = 1.0 - exp(-dens * ds);
|
|
let inv_d = 1.0 / max(g.x, 1e-4);
|
|
@@ -121,8 +160,9 @@ fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f3
|
|
var vis = 1.0;
|
|
if sun_on > 0.0 { vis = sun_visible(pos); }
|
|
var od = 0.0;
|
|
+ let lstep = select(4.0, 16.0, far);
|
|
for (var k = 0; k < 3; k++) {
|
|
- od += smoke_at(smoke_tex, linear, pos + sun * (f32(k) + 0.5 + 0.5 * jitter) * 4.0).x * 4.0;
|
|
+ od += smoke_cell(far, pos + sun * (f32(k) + 0.5 + 0.5 * jitter) * lstep).x * lstep;
|
|
}
|
|
let through = 0.65 * exp(-od * 0.9) + 0.35 * exp(-od * 0.22);
|
|
var glow = vec3<f32>(0.0);
|
|
@@ -136,7 +176,7 @@ fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f3
|
|
glow += frame.smoke_lights[j * 2 + 1].rgb / (dist * dist + 6.0) * window;
|
|
}
|
|
let ambient = up_sky * (0.25 + 0.75 * exp(-od * 0.35)) * FOG_AMBIENT;
|
|
- var s = albedo * (sun_light * vis * phase * through * FOG_SUN + ambient + glow * 1.5);
|
|
+ var s = albedo * (sun_light * vis * phase * through * 1.1 + ambient + glow * 1.5);
|
|
s += fire_colour(heat) * heat * heat * 9.0;
|
|
light += trans * a * s;
|
|
trans *= 1.0 - a;
|
|
@@ -209,8 +249,16 @@ fn fs_fog(in: FullOut) -> FogOut {
|
|
if frame.vfog.w > 0.5 {
|
|
fog = vec4<f32>(fog.rgb + spot_beams(dir, select(BEAM_FAR, length(p), d > 0.0), jitter), fog.a);
|
|
}
|
|
- if frame.smoke.x > 0.5 {
|
|
- let sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), d > 0.0), jitter, haze, cos_v);
|
|
+ if frame.smoke.x > 0.5 || frame.smoke.w > 0.5 {
|
|
+ let surface = d > 0.0;
|
|
+ var sm = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
|
+ if frame.smoke.x > 0.5 {
|
|
+ sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), surface), jitter, haze, cos_v, false);
|
|
+ }
|
|
+ if frame.smoke.w > 0.5 {
|
|
+ let far = march_smoke(dir, select(SMOKE_FAR_MAX, min(length(p), SMOKE_FAR_MAX), surface), jitter, haze, cos_v, true);
|
|
+ sm = vec4<f32>(sm.rgb + sm.a * far.rgb, sm.a * far.a);
|
|
+ }
|
|
// The smoke is lit by the sun itself and stands in the haze: it is laid over the fog.
|
|
fog = vec4<f32>(fog.rgb + fog.a * sm.rgb, fog.a * sm.a);
|
|
}
|
|
@@ -218,7 +266,7 @@ fn fs_fog(in: FullOut) -> FogOut {
|
|
return out;
|
|
}
|
|
|
|
-const FOG_SUN: f32 = 1.1;
|
|
+const FOG_SUN: f32 = 1.35;
|
|
const FOG_AMBIENT: f32 = 0.9;
|
|
fn linear_step(a: f32, b: f32, x: f32) -> f32 {
|
|
return clamp((x - a) / (b - a), 0.0, 1.0);
|
|
diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs
|
|
index 6b06732c3..a98bbd2b9 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/frame.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/frame.rs
|
|
@@ -114,10 +114,12 @@ pub(super) struct HdFrame {
|
|
/// the specular and the wet look are on.
|
|
pub(super) surface: [f32; 4],
|
|
/// Smoke (see `smoke.rs`). x: 1 when the smoke grid is drawn; y: the grid's cell size in blocks;
|
|
- /// z: the smoke clock in seconds modulo 86400 (the noise rides the wind with it); w: unused.
|
|
+ /// z: the smoke clock in seconds modulo 86400 (the noise rides the wind with it); w: 1 when the far grid has smoke (x: the near one has).
|
|
pub(super) smoke: [f32; 4],
|
|
/// xyz: the grid's minimum corner relative to the camera; w: the number of glow lights.
|
|
pub(super) smoke_min: [f32; 4],
|
|
+ /// xyz: the far smoke grid's minimum corner relative to the camera; w: its cell size.
|
|
+ pub(super) smoke_far: [f32; 4],
|
|
/// Up to 16 lights that glow on the smoke: xyz position relative to the camera, w radius; then
|
|
/// rgb colour times intensity.
|
|
pub(super) smoke_lights: [[f32; 4]; 32],
|
|
diff --git a/crates/horizon-client/src/gfx/hd/ground.rs b/crates/horizon-client/src/gfx/hd/ground.rs
|
|
new file mode 100644
|
|
index 000000000..29fbf9c6e
|
|
--- /dev/null
|
|
+++ b/crates/horizon-client/src/gfx/hd/ground.rs
|
|
@@ -0,0 +1,208 @@
|
|
+//! AirDef fork: a coarse map of the ground's height round the camera, for the volumetric fog.
|
|
+//!
|
|
+//! The fog's mist pools in valleys and thins on ridges: it needs to know where the ground is, not
|
|
+//! only a sea level. The map is 64 x 64 cells of 8 blocks (512 blocks across, the fog reaches 192),
|
|
+//! stored *toroidally* (a world cell lives at index `cell mod 64`, so a repeating sampler reads it
|
|
+//! with the world coordinate and nothing is copied when the camera moves). The CPU refreshes a few
|
|
+//! hundred cells a frame from the client's own height data (`Frame::hd_ground`), round-robin, so
|
|
+//! the cost is bounded; a long jump of the camera refreshes everything at once.
|
|
+//!
|
|
+//! Contract:
|
|
+//! - Writes `view` (`Rg16Float`, 64 x 64): r the ground's height (one above the top block, the
|
|
+//! lowest of four samples per cell so trees do not make hills), g the *basin depth*: how far the
|
|
+//! cell lies below the average ground within about 48 blocks (0 on flat ground and ridges, up to
|
|
+//! 48 in a valley), clamped to 0..48.
|
|
+//! - Read by `fs_fog` through a repeating, linear sampler at `world xz / 512` (the camera's x, z
|
|
+//! come modulo 65536, which 512 divides).
|
|
+//! - Unloaded columns count as sea level (62), the old behaviour of the fog.
|
|
+
|
|
+pub const CELLS: usize = 64;
|
|
+/// Blocks per cell.
|
|
+pub const CELL: i32 = 8;
|
|
+pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
|
|
+/// Cells refreshed per frame.
|
|
+const BUDGET: usize = 256;
|
|
+/// The box blur's radius in cells (48 blocks), and the deepest basin kept.
|
|
+const BLUR: i32 = 6;
|
|
+const DEEPEST: f32 = 48.0;
|
|
+const SEA: f32 = 62.0;
|
|
+/// `MIN_Y` of the world: what the height query returns for an unloaded column.
|
|
+const UNLOADED: i32 = -64;
|
|
+
|
|
+pub struct Ground {
|
|
+ tex: wgpu::Texture,
|
|
+ pub view: wgpu::TextureView,
|
|
+ height: Vec<f32>,
|
|
+ basin: Vec<f32>,
|
|
+ cursor: usize,
|
|
+ centre: Option<[i32; 2]>,
|
|
+ changed: bool,
|
|
+ since_blur: u32,
|
|
+ scratch: Vec<u16>,
|
|
+}
|
|
+
|
|
+impl Ground {
|
|
+ pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
|
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
|
|
+ label: Some("HD ground"),
|
|
+ size: wgpu::Extent3d {
|
|
+ width: CELLS as u32,
|
|
+ height: CELLS as u32,
|
|
+ depth_or_array_layers: 1,
|
|
+ },
|
|
+ mip_level_count: 1,
|
|
+ sample_count: 1,
|
|
+ dimension: wgpu::TextureDimension::D2,
|
|
+ format: FORMAT,
|
|
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
+ view_formats: &[],
|
|
+ });
|
|
+ let view = tex.create_view(&Default::default());
|
|
+ let mut ground = Self {
|
|
+ tex,
|
|
+ view,
|
|
+ height: vec![SEA; CELLS * CELLS],
|
|
+ basin: vec![0.0; CELLS * CELLS],
|
|
+ cursor: 0,
|
|
+ centre: None,
|
|
+ changed: false,
|
|
+ since_blur: 0,
|
|
+ scratch: vec![0; CELLS * CELLS * 2],
|
|
+ };
|
|
+ ground.upload(queue);
|
|
+ ground
|
|
+ }
|
|
+
|
|
+ /// Refreshes the next cells for a camera at world (`x`, `z`); `top` gives one above the highest
|
|
+ /// non-air block of a column.
|
|
+ pub fn update(&mut self, queue: &wgpu::Queue, x: f64, z: f64, top: &dyn Fn(i32, i32) -> i32) {
|
|
+ let cell = [
|
|
+ (x.floor() as i32).div_euclid(CELL),
|
|
+ (z.floor() as i32).div_euclid(CELL),
|
|
+ ];
|
|
+ let jump = self
|
|
+ .centre
|
|
+ .is_none_or(|c| (c[0] - cell[0]).abs() > 24 || (c[1] - cell[1]).abs() > 24);
|
|
+ self.centre = Some(cell);
|
|
+ // A jump (a teleport, the first frame) refreshes the whole map now.
|
|
+ let count = if jump { CELLS * CELLS } else { BUDGET };
|
|
+ let half = CELLS as i32 / 2;
|
|
+ for _ in 0..count {
|
|
+ let k = self.cursor;
|
|
+ self.cursor = (self.cursor + 1) % (CELLS * CELLS);
|
|
+ let (i, j) = ((k % CELLS) as i32, (k / CELLS) as i32);
|
|
+ // The world cell of this index that lies in the window round the camera.
|
|
+ let wx = cell[0] - half + (i - (cell[0] - half)).rem_euclid(CELLS as i32);
|
|
+ let wz = cell[1] - half + (j - (cell[1] - half)).rem_euclid(CELLS as i32);
|
|
+ let (bx, bz) = (wx * CELL, wz * CELL);
|
|
+ let mut low = i32::MAX;
|
|
+ for (dx, dz) in [(1, 1), (5, 2), (2, 5), (6, 6)] {
|
|
+ let h = top(bx + dx, bz + dz);
|
|
+ if h > UNLOADED {
|
|
+ low = low.min(h);
|
|
+ }
|
|
+ }
|
|
+ let h = if low == i32::MAX { SEA } else { low as f32 };
|
|
+ if (self.height[k] - h).abs() > 0.01 {
|
|
+ self.height[k] = h;
|
|
+ self.changed = true;
|
|
+ }
|
|
+ }
|
|
+ self.since_blur += 1;
|
|
+ if self.changed && (jump || self.since_blur >= 4) {
|
|
+ self.blur();
|
|
+ self.upload(queue);
|
|
+ self.changed = false;
|
|
+ self.since_blur = 0;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /// The basin depth: the mean height within `BLUR` cells (a wrapping box blur, two passes) less the
|
|
+ /// cell's own.
|
|
+ fn blur(&mut self) {
|
|
+ let n = CELLS as i32;
|
|
+ let mut tmp = vec![0.0f32; CELLS * CELLS];
|
|
+ let mut out = vec![0.0f32; CELLS * CELLS];
|
|
+ let norm = 1.0 / (2 * BLUR + 1) as f32;
|
|
+ for j in 0..n {
|
|
+ for i in 0..n {
|
|
+ let mut s = 0.0;
|
|
+ for d in -BLUR..=BLUR {
|
|
+ s += self.height[(j * n + (i + d).rem_euclid(n)) as usize];
|
|
+ }
|
|
+ tmp[(j * n + i) as usize] = s * norm;
|
|
+ }
|
|
+ }
|
|
+ for j in 0..n {
|
|
+ for i in 0..n {
|
|
+ let mut s = 0.0;
|
|
+ for d in -BLUR..=BLUR {
|
|
+ s += tmp[((j + d).rem_euclid(n) * n + i) as usize];
|
|
+ }
|
|
+ out[(j * n + i) as usize] = s * norm;
|
|
+ }
|
|
+ }
|
|
+ for k in 0..CELLS * CELLS {
|
|
+ self.basin[k] = (out[k] - self.height[k]).clamp(0.0, DEEPEST);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn upload(&mut self, queue: &wgpu::Queue) {
|
|
+ for k in 0..CELLS * CELLS {
|
|
+ self.scratch[k * 2] = to_half(self.height[k]);
|
|
+ self.scratch[k * 2 + 1] = to_half(self.basin[k]);
|
|
+ }
|
|
+ queue.write_texture(
|
|
+ self.tex.as_image_copy(),
|
|
+ bytemuck::cast_slice(&self.scratch),
|
|
+ wgpu::TexelCopyBufferLayout {
|
|
+ offset: 0,
|
|
+ bytes_per_row: Some(CELLS as u32 * 4),
|
|
+ rows_per_image: Some(CELLS as u32),
|
|
+ },
|
|
+ wgpu::Extent3d {
|
|
+ width: CELLS as u32,
|
|
+ height: CELLS as u32,
|
|
+ depth_or_array_layers: 1,
|
|
+ },
|
|
+ );
|
|
+ }
|
|
+}
|
|
+
|
|
+/// An `f32` as IEEE half bits (normal range, rounded down; heights and depths stay below 512).
|
|
+fn to_half(v: f32) -> u16 {
|
|
+ let v = v.clamp(0.0, 2048.0);
|
|
+ if v < 6.2e-5 {
|
|
+ return 0;
|
|
+ }
|
|
+ let bits = v.to_bits();
|
|
+ let exp = ((bits >> 23) as i32 - 127 + 15).clamp(1, 30) as u16;
|
|
+ let mant = ((bits >> 13) & 0x3ff) as u16;
|
|
+ (exp << 10) | mant
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+
|
|
+ #[test]
|
|
+ fn half_floats_round_trip_closely() {
|
|
+ let from_half = |h: u16| {
|
|
+ let e = ((h >> 10) & 31) as i32;
|
|
+ if e == 0 {
|
|
+ 0.0
|
|
+ } else {
|
|
+ (1.0 + (h & 0x3ff) as f32 / 1024.0) * 2f32.powi(e - 15)
|
|
+ }
|
|
+ };
|
|
+ for v in [0.0f32, 1.0, 12.5, 62.0, 143.7, 320.25, 1000.0] {
|
|
+ let r = from_half(to_half(v));
|
|
+ assert!((r - v).abs() <= v * 0.001 + 0.001, "{v} -> {r}");
|
|
+ }
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn the_map_side_divides_the_camera_wrap() {
|
|
+ assert_eq!(65536 % (CELLS as u64 * CELL as u64), 0);
|
|
+ }
|
|
+}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
|
|
index a9e1fb7f4..bbeb35b86 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
|
|
@@ -32,6 +32,7 @@ struct Light {
|
|
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
|
|
// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
|
|
@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
|
|
+//PANO21
|
|
const SKY_RAY: f32 = 20000.0;
|
|
const CLOUD_SHADOW_R: f32 = 1200.0;
|
|
// The pixel being lit, for the shadow filter's noise.
|
|
@@ -158,16 +159,20 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
|
fn cloud_shadow(p: vec3<f32>) -> f32 {
|
|
if frame.clouds.z < 0.5 { return 1.0; }
|
|
let l = frame.cloud_dir.xyz;
|
|
- if l.y < 0.1 { return 1.0; }
|
|
- let t = (frame.clouds.x - p.y) / l.y;
|
|
+ // Fades in smoothly with the sun's height (no pop at the low sun's end).
|
|
+ let sun_fade = smoothstep(0.03, 0.2, l.y);
|
|
+ if sun_fade <= 0.0 { return 1.0; }
|
|
+ let t = (frame.clouds.x - p.y) / max(l.y, 0.04);
|
|
if t <= 0.0 { return 1.0; }
|
|
- let q = p.xz + l.xz * t;
|
|
- let uv = q / (2.0 * CLOUD_SHADOW_R) + vec2<f32>(0.5);
|
|
- if any(uv < vec2<f32>(0.0)) || any(uv > vec2<f32>(1.0)) { return 1.0; }
|
|
- let od = textureSampleLevel(cloud_shadow_tex, linear, uv, 0.0).r;
|
|
- // Fade out as the sun gets low (the shadow then falls far from the cloud).
|
|
- let fade = smoothstep(0.1, 0.25, l.y);
|
|
- return mix(1.0, mix(1.0, exp(-od * 0.9), 0.9), fade);
|
|
+ // The map is centred where the sunlight through the camera crosses the cloud base (as the pass).
|
|
+ let centre = l.xz * (frame.clouds.x / max(l.y, 0.04));
|
|
+ let uv = (p.xz + l.xz * t - centre) / (2.0 * CLOUD_SHADOW_R) + vec2<f32>(0.5);
|
|
+ // Fades out over the map's last tenth instead of stopping at its edge.
|
|
+ let edge = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));
|
|
+ let edge_fade = smoothstep(0.0, 0.1, edge);
|
|
+ if edge_fade <= 0.0 { return 1.0; }
|
|
+ let od = textureSampleLevel(cloud_shadow_tex, linear, clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)), 0.0).r;
|
|
+ return mix(1.0, mix(1.0, exp(-od * 0.9), 0.9), sun_fade * edge_fade);
|
|
}
|
|
|
|
// The volumetric fog at a full-resolution pixel `dist` away: the four nearest half-resolution
|
|
@@ -357,7 +362,8 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
|
}
|
|
if atmosphere && gloss > 0.12 {
|
|
let r = reflect(-v, n);
|
|
- let sky = atmosphere_sky(normalize(vec3<f32>(r.x, max(r.y, 0.02), r.z)));
|
|
+ let rd = normalize(vec3<f32>(r.x, max(r.y, 0.02), r.z));
|
|
+ let sky = sky_with_clouds(atmosphere_sky(rd), rd);
|
|
let reach = smoothstep(0.35, 0.95, lm.x) * smoothstep(-0.2, 0.15, r.y);
|
|
out += sky * fresnel * (gloss * gloss * reach * ao_ambient);
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
|
|
index 8c56768f8..23e16abfd 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
|
|
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
|
|
|
|
pub(super) const LIGHT: &str = include_str!("light.wgsl");
|
|
/// The lighting's group 0, binding by binding (see the contract above).
|
|
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
|
|
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 22] = [
|
|
UNIFORM,
|
|
UNFILTERED,
|
|
DEPTH,
|
|
@@ -56,6 +56,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
|
|
FLOAT,
|
|
FLOAT,
|
|
UNFILTERED,
|
|
+ FLOAT,
|
|
];
|
|
|
|
/// The outside views and sampler the lighting group reads.
|
|
@@ -223,6 +224,7 @@ impl Hd {
|
|
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
|
|
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
|
|
wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
|
|
+ wgpu::BindingResource::TextureView(self.volumes.pano_view()),
|
|
];
|
|
let entries: Vec<_> = resources
|
|
.into_iter()
|
|
diff --git a/crates/horizon-client/src/gfx/hd/materials.rs b/crates/horizon-client/src/gfx/hd/materials.rs
|
|
index 32d080878..4326f23a5 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/materials.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/materials.rs
|
|
@@ -200,7 +200,7 @@ pub fn specular_enabled() -> bool {
|
|
pub fn wind_seconds() -> f32 {
|
|
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
|
let start = START.get_or_init(std::time::Instant::now);
|
|
- (start.elapsed().as_secs_f64() % 600.0) as f32
|
|
+ (crate::film_clock::since(*start).as_secs_f64() % 600.0) as f32
|
|
}
|
|
|
|
#[cfg(test)]
|
|
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
|
|
index ccc793e9a..f7a64e7a4 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/mod.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
|
|
@@ -63,6 +63,7 @@ mod ao;
|
|
mod bloom;
|
|
mod frame;
|
|
mod gbuffer;
|
|
+mod ground;
|
|
mod lighting;
|
|
mod materials;
|
|
mod noise;
|
|
@@ -168,6 +169,13 @@ pub(super) fn assemble(body: &str) -> String {
|
|
&sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()),
|
|
);
|
|
}
|
|
+ // `//PANO<n>`: the cloud panorama (`pano_funcs.wgsl`) at binding n.
|
|
+ for binding in (0..32).rev() {
|
|
+ body = body.replace(
|
|
+ &format!("//PANO{binding}"),
|
|
+ &sky::PANO_FUNCS.replace("PANOBIND", &binding.to_string()),
|
|
+ );
|
|
+ }
|
|
// `//SPOT`: the spot lights' cone functions (`spot.wgsl`), which call the shader's own
|
|
// `spot_air(pos)` (the air's extinction per block).
|
|
let body = body.replace("//SPOT", SPOT);
|
|
@@ -490,7 +498,7 @@ impl Hd {
|
|
/// The world is smaller than the output, so TAA upsamples it.
|
|
/// How wet the world is: soaks in over ~15 s of rain, dries out over ~2.5 minutes after it.
|
|
fn wetness(&mut self, scene: &HdScene) -> f32 {
|
|
- let now = std::time::Instant::now();
|
|
+ let now = crate::film_clock::now();
|
|
let dt = now.duration_since(self.wet.1).as_secs_f32().min(1.0);
|
|
let target = if scene.sky {
|
|
(scene.rain / 0.7).clamp(0.0, 1.0)
|
|
@@ -572,7 +580,7 @@ impl Hd {
|
|
view_proj: scene.view_proj.to_cols_array_2d(),
|
|
water: [
|
|
if self.water.active { 1.0 } else { 0.0 },
|
|
- (self.started.elapsed().as_secs_f64() % 600.0) as f32,
|
|
+ (crate::film_clock::since(self.started).as_secs_f64() % 600.0) as f32,
|
|
scene.camera_mod[0],
|
|
scene.camera_mod[1],
|
|
],
|
|
@@ -627,6 +635,7 @@ impl Hd {
|
|
],
|
|
smoke: self.smoke.frame.smoke,
|
|
smoke_min: self.smoke.frame.min,
|
|
+ smoke_far: self.smoke.frame.far,
|
|
smoke_lights: self.smoke.frame.lights,
|
|
spots: {
|
|
let mut all = [[0.0; 4]; 64];
|
|
@@ -908,7 +917,10 @@ mod tests {
|
|
sources.iter().any(|s| {
|
|
let s = assemble(s);
|
|
// The sky snippet's binding number is filled in by `assemble`.
|
|
- shown.split("SKYBIND").all(|part| s.contains(part))
|
|
+ shown
|
|
+ .split("SKYBIND")
|
|
+ .flat_map(|part| part.split("PANOBIND"))
|
|
+ .all(|part| s.contains(part))
|
|
})
|
|
},
|
|
"{} is not part of any tested shader",
|
|
diff --git a/crates/horizon-client/src/gfx/hd/pano_funcs.wgsl b/crates/horizon-client/src/gfx/hd/pano_funcs.wgsl
|
|
new file mode 100644
|
|
index 000000000..d469a1400
|
|
--- /dev/null
|
|
+++ b/crates/horizon-client/src/gfx/hd/pano_funcs.wgsl
|
|
@@ -0,0 +1,18 @@
|
|
+
|
|
+// The clouds seen from the camera in every direction above the horizon (`cloud_sky` pass): light
|
|
+// and transmittance, for reflections of the sky. Azimuth from +x over the whole circle,
|
|
+// v = sqrt(elevation / (pi / 2)).
|
|
+@group(0) @binding(PANOBIND) var cloud_pano: texture_2d<f32>;
|
|
+
|
|
+fn cloud_pano_at(dir: vec3<f32>) -> vec4<f32> {
|
|
+ let u = atan2(dir.z, dir.x) / (2.0 * 3.14159265) + 0.5;
|
|
+ let v = sqrt(asin(clamp(dir.y, 0.0, 1.0)) / (3.14159265 * 0.5));
|
|
+ return textureSampleLevel(cloud_pano, linear, vec2<f32>(u, v), 0.0);
|
|
+}
|
|
+
|
|
+// A sky colour with the clouds in front of it (where the volumetric clouds are drawn).
|
|
+fn sky_with_clouds(sky: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
|
+ if frame.clouds.z < 0.5 || frame.sky_sun.w < 0.5 { return sky; }
|
|
+ let c = cloud_pano_at(dir);
|
|
+ return sky * c.a + c.rgb;
|
|
+}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
|
index 7e937a63b..6075cbb0b 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
|
@@ -27,6 +27,7 @@ struct HdFrame {
|
|
surface: vec4<f32>,
|
|
smoke: vec4<f32>,
|
|
smoke_min: vec4<f32>,
|
|
+ smoke_far: vec4<f32>,
|
|
smoke_lights: array<vec4<f32>, 32>,
|
|
spots: array<vec4<f32>, 64>,
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/sky.rs b/crates/horizon-client/src/gfx/hd/sky.rs
|
|
index 7211ffda2..f1c80a766 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/sky.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/sky.rs
|
|
@@ -34,6 +34,9 @@ pub(super) const SKY_PASS: &str = include_str!("sky.wgsl");
|
|
/// the binding of the texture; `frame` and the `linear` sampler come before it).
|
|
pub(super) const SKY_FUNCS: &str = include_str!("sky_funcs.wgsl");
|
|
|
|
+/// The cloud panorama's readers (`PANOBIND` is its binding), pasted at a `//PANO<n>` marker.
|
|
+pub(super) const PANO_FUNCS: &str = include_str!("pano_funcs.wgsl");
|
|
+
|
|
/// The sky pass's group 0: only the frame.
|
|
pub(super) const SKY_BINDINGS: [wgpu::BindingType; 1] = [UNIFORM];
|
|
|
|
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
|
|
index c751df4f4..266f5fdef 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
|
|
@@ -18,8 +18,12 @@
|
|
//! `cloud.wgsl` thins the clouds with it.
|
|
//!
|
|
//! Contract:
|
|
-//! - Input segments (`HdSmoke`) are camera-relative, ages and lives in seconds. The grid reaches
|
|
-//! `GRID_XZ * CELL / 2` = 192 blocks round the camera horizontally, 48 below and 144 above.
|
|
+//! - Input segments (`HdSmoke`) are camera-relative, ages and lives in seconds. Two grids: the near
|
|
+//! one reaches `GRID_XZ * CELL / 2` = 192 blocks round the camera horizontally (48 below, 144 above)
|
|
+//! in 3-block cells; the far one is the same lattice in 12-block cells (`FAR_CELL`), 768 blocks
|
|
+//! (192 below, 576 above), with trails kept 7.2 blocks thick for its cells. The fog blends them
|
|
+//! over the near grid's last 8 cells (weights sum to 1: no seam) and fades the far one out over
|
|
+//! its last 6; the far grid holds up to `FAR_MAX_SEGMENTS` segments, the nearest first.
|
|
//! - The grid atlas is `Rgba16Float`, rewritten every frame the smoke is active (`active`).
|
|
//! - Nothing here changes unless the mod sends segments; `HORIZON_HD_SMOKE=0` turns it off (the mod is
|
|
//! told, and draws its own smoke).
|
|
@@ -41,6 +45,11 @@ const TILES: u32 = 8;
|
|
pub const ATLAS: u32 = GRID_XZ * TILES;
|
|
pub const ATLAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
|
pub const MAX_SEGMENTS: usize = 3072;
|
|
+/// The far grid: the same lattice with 4x the cells (12 blocks): trails out to 768 blocks round the
|
|
+/// camera (192 below, 576 above), blended with the near one over the near grid's last cells.
|
|
+pub const FAR_CELL: f32 = 12.0;
|
|
+const FAR_MIN_TUBE: f32 = 7.2;
|
|
+pub const FAR_MAX_SEGMENTS: usize = 6144;
|
|
/// Brick-list entries: a segment is in every brick its box touches.
|
|
const MAX_INDICES: usize = 120_000;
|
|
pub const MAX_LIGHTS: usize = 16;
|
|
@@ -83,8 +92,8 @@ struct Baked {
|
|
seed: [f32; 4],
|
|
}
|
|
|
|
-/// `fs_smoke_grid`'s group 0: frame, the segments, the brick lists.
|
|
-pub(super) const SMOKE_GRID_BINDINGS: [wgpu::BindingType; 3] = [UNIFORM, STORAGE, STORAGE];
|
|
+/// `fs_smoke_grid`'s group 0: frame, the segments, the brick lists, the grid's corner and cell.
|
|
+pub(super) const SMOKE_GRID_BINDINGS: [wgpu::BindingType; 4] = [UNIFORM, STORAGE, STORAGE, UNIFORM];
|
|
|
|
pub(super) const SMOKE_GRID: &str = include_str!("smoke_grid.wgsl");
|
|
/// The grid's trilinear lookup, shared by the fog and the clouds. Needs `frame` in scope.
|
|
@@ -95,30 +104,40 @@ pub(super) const SMOKE_SAMPLE: &str = include_str!("smoke_sample.wgsl");
|
|
pub(super) struct SmokeFrame {
|
|
pub smoke: [f32; 4],
|
|
pub min: [f32; 4],
|
|
+ /// xyz: the far grid's corner relative to the camera; w: its cell.
|
|
+ pub far: [f32; 4],
|
|
pub lights: [[f32; 4]; 32],
|
|
}
|
|
|
|
-pub struct Smoke {
|
|
- pub atlas: wgpu::TextureView,
|
|
+/// One level of the smoke grid: a 128 x 64 x 128 lattice of cells of its own size, its own baked
|
|
+/// segments (a trail is kept at least `min_tube` thick for the cells) and brick lists.
|
|
+struct Cascade {
|
|
+ cell: f32,
|
|
+ min_tube: f32,
|
|
+ max_segments: usize,
|
|
+ atlas: wgpu::TextureView,
|
|
segments: wgpu::Buffer,
|
|
bricks: wgpu::Buffer,
|
|
- pipeline: wgpu::RenderPipeline,
|
|
+ /// xyz: the grid's corner relative to the camera; w: the cell (for `fs_smoke_grid`).
|
|
+ grid: wgpu::Buffer,
|
|
group: wgpu::BindGroup,
|
|
- /// Segments in the grid this frame (0: nothing is drawn).
|
|
- pub count: usize,
|
|
- pub frame: SmokeFrame,
|
|
+ /// Segments in the grid this frame (0: nothing is drawn, the frame says so).
|
|
+ count: usize,
|
|
+ min: [f32; 3],
|
|
baked: Vec<Baked>,
|
|
order: Vec<(f32, usize)>,
|
|
bricks_cpu: Vec<u32>,
|
|
- lights: Vec<(f32, [f32; 4], [f32; 4])>,
|
|
}
|
|
|
|
-impl Smoke {
|
|
- pub fn new(device: &wgpu::Device, frame: &wgpu::Buffer) -> Self {
|
|
- let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
- label: Some("HD smoke grid"),
|
|
- entries: &entries(&SMOKE_GRID_BINDINGS),
|
|
- });
|
|
+impl Cascade {
|
|
+ fn new(
|
|
+ device: &wgpu::Device,
|
|
+ layout: &wgpu::BindGroupLayout,
|
|
+ frame: &wgpu::Buffer,
|
|
+ cell: f32,
|
|
+ min_tube: f32,
|
|
+ max_segments: usize,
|
|
+ ) -> Self {
|
|
let storage = |label, size: usize| {
|
|
device.create_buffer(&wgpu::BufferDescriptor {
|
|
label: Some(label),
|
|
@@ -129,22 +148,18 @@ impl Smoke {
|
|
};
|
|
let segments = storage(
|
|
"HD smoke segments",
|
|
- MAX_SEGMENTS * std::mem::size_of::<Baked>(),
|
|
+ max_segments * std::mem::size_of::<Baked>(),
|
|
);
|
|
let bricks = storage("HD smoke bricks", (BRICKS * 2 + MAX_INDICES) * 4);
|
|
- let module = module(device, "HD smoke grid", SMOKE_GRID);
|
|
- let pipeline = fullscreen(
|
|
- device,
|
|
- "HD smoke grid",
|
|
- &layout,
|
|
- &module,
|
|
- "fs_smoke_grid",
|
|
- ATLAS_FORMAT,
|
|
- None,
|
|
- );
|
|
+ let grid = device.create_buffer(&wgpu::BufferDescriptor {
|
|
+ label: Some("HD smoke grid corner"),
|
|
+ size: 16,
|
|
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
+ mapped_at_creation: false,
|
|
+ });
|
|
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
label: Some("HD smoke grid"),
|
|
- layout: &layout,
|
|
+ layout,
|
|
entries: &[
|
|
wgpu::BindGroupEntry {
|
|
binding: 0,
|
|
@@ -158,41 +173,34 @@ impl Smoke {
|
|
binding: 2,
|
|
resource: bricks.as_entire_binding(),
|
|
},
|
|
+ wgpu::BindGroupEntry {
|
|
+ binding: 3,
|
|
+ resource: grid.as_entire_binding(),
|
|
+ },
|
|
],
|
|
});
|
|
+ let atlas = target(device, "HD smoke grid", [ATLAS, ATLAS], ATLAS_FORMAT).view;
|
|
Self {
|
|
- atlas: target(device, "HD smoke grid", [ATLAS, ATLAS], ATLAS_FORMAT).view,
|
|
+ cell,
|
|
+ min_tube,
|
|
+ max_segments,
|
|
+ atlas,
|
|
segments,
|
|
bricks,
|
|
- pipeline,
|
|
+ grid,
|
|
group,
|
|
count: 0,
|
|
- frame: SmokeFrame::default(),
|
|
+ min: [0.0; 3],
|
|
baked: Vec::new(),
|
|
order: Vec::new(),
|
|
bricks_cpu: Vec::new(),
|
|
- lights: Vec::new(),
|
|
}
|
|
}
|
|
|
|
- /// Bakes this frame's segments, bins them into the grid's bricks and uploads them; picks the
|
|
- /// lights that glow on the smoke. `clock` is the mod's clock in seconds.
|
|
- pub fn prepare(
|
|
- &mut self,
|
|
- queue: &wgpu::Queue,
|
|
- scene: &HdScene,
|
|
- smoke: &[HdSmoke],
|
|
- lights: &[HdLight],
|
|
- clock: f32,
|
|
- ) {
|
|
+ /// Bakes the segments in reach of this level, bins them into its bricks and uploads them.
|
|
+ fn prepare(&mut self, queue: &wgpu::Queue, scene: &HdScene, smoke: &[HdSmoke]) {
|
|
self.count = 0;
|
|
- self.frame = SmokeFrame::default();
|
|
- if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() && !smoke.is_empty() {
|
|
- log::info!("HD smoke: {} segments given", smoke.len());
|
|
- }
|
|
- if smoke.is_empty() {
|
|
- return;
|
|
- }
|
|
+ let cell = self.cell;
|
|
// The grid's corner on the world's lattice, relative to the camera.
|
|
let cam = [
|
|
scene.camera_world[0],
|
|
@@ -200,21 +208,29 @@ impl Smoke {
|
|
scene.camera_world[1],
|
|
];
|
|
let min = [
|
|
- (cam[0] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[0],
|
|
- (cam[1] / CELL).floor() * CELL - CELLS_BELOW * CELL - cam[1],
|
|
- (cam[2] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[2],
|
|
+ (cam[0] / cell).floor() * cell - (GRID_XZ / 2) as f32 * cell - cam[0],
|
|
+ (cam[1] / cell).floor() * cell - CELLS_BELOW * cell - cam[1],
|
|
+ (cam[2] / cell).floor() * cell - (GRID_XZ / 2) as f32 * cell - cam[2],
|
|
];
|
|
+ self.min = min;
|
|
+ queue.write_buffer(
|
|
+ &self.grid,
|
|
+ 0,
|
|
+ bytemuck::cast_slice(&[min[0], min[1], min[2], cell]),
|
|
+ );
|
|
let max = [
|
|
- min[0] + GRID_XZ as f32 * CELL,
|
|
- min[1] + GRID_Y as f32 * CELL,
|
|
- min[2] + GRID_XZ as f32 * CELL,
|
|
+ min[0] + GRID_XZ as f32 * cell,
|
|
+ min[1] + GRID_Y as f32 * cell,
|
|
+ min[2] + GRID_XZ as f32 * cell,
|
|
];
|
|
// Bake, keep the ones in reach, nearest first.
|
|
self.baked.clear();
|
|
self.order.clear();
|
|
for s in smoke {
|
|
- let Some(b) = bake(s) else { continue };
|
|
- let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL;
|
|
+ let Some(b) = bake(s, self.min_tube) else {
|
|
+ continue;
|
|
+ };
|
|
+ let reach = b.pa[3].max(b.extra[2]) * 1.3 + cell;
|
|
let lo = [
|
|
b.pa[0].min(b.pb[0]) - reach,
|
|
b.pa[1].min(b.pb[1]) - reach,
|
|
@@ -243,12 +259,12 @@ impl Smoke {
|
|
return;
|
|
}
|
|
self.order.sort_by(|x, y| x.0.total_cmp(&y.0));
|
|
- self.order.truncate(MAX_SEGMENTS);
|
|
+ self.order.truncate(self.max_segments);
|
|
// Bricks: count per brick, then fill; the nearest segments first, so a full list drops the
|
|
// farthest.
|
|
let brick_of = |lo: f32, hi: f32, axis: usize, bricks: u32| -> (i32, i32) {
|
|
- let a = ((lo - min[axis]) / (CELL * BRICK as f32)).floor() as i32;
|
|
- let b = ((hi - min[axis]) / (CELL * BRICK as f32)).floor() as i32;
|
|
+ let a = ((lo - min[axis]) / (cell * BRICK as f32)).floor() as i32;
|
|
+ let b = ((hi - min[axis]) / (cell * BRICK as f32)).floor() as i32;
|
|
(a.max(0), b.min(bricks as i32 - 1))
|
|
};
|
|
let mut counts = vec![0u32; BRICKS];
|
|
@@ -257,7 +273,7 @@ impl Smoke {
|
|
let mut kept: Vec<Baked> = Vec::with_capacity(self.order.len());
|
|
for &(_, i) in &self.order {
|
|
let b = self.baked[i];
|
|
- let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL;
|
|
+ let reach = b.pa[3].max(b.extra[2]) * 1.3 + cell;
|
|
let (x0, x1) = brick_of(
|
|
b.pa[0].min(b.pb[0]) - reach,
|
|
b.pa[0].max(b.pb[0]) + reach,
|
|
@@ -321,17 +337,91 @@ impl Smoke {
|
|
queue.write_buffer(&self.segments, 0, bytemuck::cast_slice(&kept));
|
|
queue.write_buffer(&self.bricks, 0, bytemuck::cast_slice(&self.bricks_cpu));
|
|
self.count = kept.len();
|
|
+ }
|
|
+}
|
|
+
|
|
+pub struct Smoke {
|
|
+ /// The near grid (3-block cells) and the far one (12-block cells), as the fog reads them.
|
|
+ pub atlas: wgpu::TextureView,
|
|
+ pub far_atlas: wgpu::TextureView,
|
|
+ near: Cascade,
|
|
+ far: Cascade,
|
|
+ pipeline: wgpu::RenderPipeline,
|
|
+ /// Segments in the near grid this frame.
|
|
+ pub count: usize,
|
|
+ pub frame: SmokeFrame,
|
|
+ lights: Vec<(f32, [f32; 4], [f32; 4])>,
|
|
+}
|
|
+
|
|
+impl Smoke {
|
|
+ pub fn new(device: &wgpu::Device, frame: &wgpu::Buffer) -> Self {
|
|
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
+ label: Some("HD smoke grid"),
|
|
+ entries: &entries(&SMOKE_GRID_BINDINGS),
|
|
+ });
|
|
+ let module = module(device, "HD smoke grid", SMOKE_GRID);
|
|
+ let pipeline = fullscreen(
|
|
+ device,
|
|
+ "HD smoke grid",
|
|
+ &layout,
|
|
+ &module,
|
|
+ "fs_smoke_grid",
|
|
+ ATLAS_FORMAT,
|
|
+ None,
|
|
+ );
|
|
+ let near = Cascade::new(device, &layout, frame, CELL, MIN_TUBE, MAX_SEGMENTS);
|
|
+ let far = Cascade::new(
|
|
+ device,
|
|
+ &layout,
|
|
+ frame,
|
|
+ FAR_CELL,
|
|
+ FAR_MIN_TUBE,
|
|
+ FAR_MAX_SEGMENTS,
|
|
+ );
|
|
+ Self {
|
|
+ atlas: near.atlas.clone(),
|
|
+ far_atlas: far.atlas.clone(),
|
|
+ near,
|
|
+ far,
|
|
+ pipeline,
|
|
+ count: 0,
|
|
+ frame: SmokeFrame::default(),
|
|
+ lights: Vec::new(),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /// Bakes this frame's segments into both grids and uploads them; picks the lights that glow on
|
|
+ /// the smoke. `clock` is the mod's clock in seconds.
|
|
+ pub fn prepare(
|
|
+ &mut self,
|
|
+ queue: &wgpu::Queue,
|
|
+ scene: &HdScene,
|
|
+ smoke: &[HdSmoke],
|
|
+ lights: &[HdLight],
|
|
+ clock: f32,
|
|
+ ) {
|
|
+ self.count = 0;
|
|
+ self.near.count = 0;
|
|
+ self.far.count = 0;
|
|
+ self.frame = SmokeFrame::default();
|
|
+ if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() && !smoke.is_empty() {
|
|
+ log::info!("HD smoke: {} segments given", smoke.len());
|
|
+ }
|
|
+ if smoke.is_empty() {
|
|
+ return;
|
|
+ }
|
|
+ self.near.prepare(queue, scene, smoke);
|
|
+ self.far.prepare(queue, scene, smoke);
|
|
+ self.count = self.near.count;
|
|
+ if self.near.count == 0 && self.far.count == 0 {
|
|
+ return;
|
|
+ }
|
|
if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() {
|
|
- let n = &self.baked[0];
|
|
log::info!(
|
|
- "HD smoke: {} in, {} baked, {} in the grid; first pa {:?} pb {:?} extra {:?} min {:?}",
|
|
+ "HD smoke: {} given, {} in the near grid, {} in the far grid",
|
|
smoke.len(),
|
|
- self.baked.len(),
|
|
- self.count,
|
|
- n.pa,
|
|
- n.pb,
|
|
- n.extra,
|
|
- min
|
|
+ self.near.count,
|
|
+ self.far.count
|
|
);
|
|
}
|
|
// The lights that glow on the smoke: the brightest near ones.
|
|
@@ -347,9 +437,17 @@ impl Smoke {
|
|
}
|
|
self.lights.sort_by(|a, b| b.0.total_cmp(&a.0));
|
|
self.lights.truncate(MAX_LIGHTS);
|
|
+ let (n, f) = (&self.near, &self.far);
|
|
let mut out = SmokeFrame {
|
|
- smoke: [1.0, CELL, clock.rem_euclid(86_400.0), 0.0],
|
|
- min: [min[0], min[1], min[2], self.lights.len() as f32],
|
|
+ // x: the near grid has smoke, w: the far one has.
|
|
+ smoke: [
|
|
+ if n.count > 0 { 1.0 } else { 0.0 },
|
|
+ CELL,
|
|
+ clock.rem_euclid(86_400.0),
|
|
+ if f.count > 0 { 1.0 } else { 0.0 },
|
|
+ ],
|
|
+ min: [n.min[0], n.min[1], n.min[2], self.lights.len() as f32],
|
|
+ far: [f.min[0], f.min[1], f.min[2], FAR_CELL],
|
|
lights: [[0.0; 4]; 32],
|
|
};
|
|
for (i, (_, pos, color)) in self.lights.iter().enumerate() {
|
|
@@ -364,23 +462,40 @@ impl Smoke {
|
|
self.frame = out;
|
|
}
|
|
|
|
- /// Fills the grid from the segments (when there are any).
|
|
+ /// Fills the near grid from the segments (when it has any).
|
|
pub fn draw(
|
|
&self,
|
|
encoder: &mut wgpu::CommandEncoder,
|
|
stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
|
) {
|
|
- if self.count == 0 {
|
|
- return;
|
|
+ if self.near.count > 0 {
|
|
+ stamped_pass(
|
|
+ encoder,
|
|
+ "HD smoke grid",
|
|
+ &[&self.near.atlas],
|
|
+ &self.pipeline,
|
|
+ &self.near.group,
|
|
+ stamp,
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /// Fills the far grid.
|
|
+ pub fn draw_far(
|
|
+ &self,
|
|
+ encoder: &mut wgpu::CommandEncoder,
|
|
+ stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
|
+ ) {
|
|
+ if self.far.count > 0 {
|
|
+ stamped_pass(
|
|
+ encoder,
|
|
+ "HD smoke grid far",
|
|
+ &[&self.far.atlas],
|
|
+ &self.pipeline,
|
|
+ &self.far.group,
|
|
+ stamp,
|
|
+ );
|
|
}
|
|
- stamped_pass(
|
|
- encoder,
|
|
- "HD smoke grid",
|
|
- &[&self.atlas],
|
|
- &self.pipeline,
|
|
- &self.group,
|
|
- stamp,
|
|
- );
|
|
}
|
|
}
|
|
|
|
@@ -389,7 +504,7 @@ fn brick_index(x: u32, y: u32, z: u32) -> usize {
|
|
}
|
|
|
|
/// A segment at its age: drifted with the wind, risen, grown, thinned, cooled; None once spent.
|
|
-fn bake(s: &HdSmoke) -> Option<Baked> {
|
|
+fn bake(s: &HdSmoke, min_tube: f32) -> Option<Baked> {
|
|
let t = s.a[3];
|
|
let life = s.b[3].max(0.05);
|
|
if !(t >= 0.0) || t >= life {
|
|
@@ -409,8 +524,8 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
|
|
// the same mass per length, so a young trail is a continuous ribbon.
|
|
let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
|
|
let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
|
|
- let (radius, density) = if tube && radius < MIN_TUBE {
|
|
- (MIN_TUBE, density * radius / MIN_TUBE)
|
|
+ let (radius, density) = if tube && radius < min_tube {
|
|
+ (min_tube, density * radius / min_tube)
|
|
} else {
|
|
(radius, density)
|
|
};
|
|
@@ -458,23 +573,26 @@ mod tests {
|
|
|
|
#[test]
|
|
fn a_segment_grows_thins_and_ends() {
|
|
- let young = bake(&puff(1.0, 40.0)).unwrap();
|
|
- let old = bake(&puff(30.0, 40.0)).unwrap();
|
|
+ let young = bake(&puff(1.0, 40.0), MIN_TUBE).unwrap();
|
|
+ let old = bake(&puff(30.0, 40.0), MIN_TUBE).unwrap();
|
|
assert!(old.pa[3] > young.pa[3], "grows");
|
|
assert!(old.pb[3] < young.pb[3], "thins");
|
|
assert!(
|
|
old.pa[0] > young.pa[0] && old.pa[1] > young.pa[1],
|
|
"drifts and rises"
|
|
);
|
|
- assert!(bake(&puff(40.0, 40.0)).is_none(), "gone at its life");
|
|
- assert!(bake(&puff(-1.0, 40.0)).is_none(), "not born yet");
|
|
+ assert!(
|
|
+ bake(&puff(40.0, 40.0), MIN_TUBE).is_none(),
|
|
+ "gone at its life"
|
|
+ );
|
|
+ assert!(bake(&puff(-1.0, 40.0), MIN_TUBE).is_none(), "not born yet");
|
|
}
|
|
|
|
#[test]
|
|
fn the_same_age_bakes_the_same() {
|
|
// Showing a frame again (a pause, a seek back to it) gives the same smoke.
|
|
- let a = bake(&puff(12.5, 40.0)).unwrap();
|
|
- let b = bake(&puff(12.5, 40.0)).unwrap();
|
|
+ let a = bake(&puff(12.5, 40.0), MIN_TUBE).unwrap();
|
|
+ let b = bake(&puff(12.5, 40.0), MIN_TUBE).unwrap();
|
|
assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&b));
|
|
}
|
|
|
|
@@ -483,7 +601,7 @@ mod tests {
|
|
let mut s = puff(10.0, 40.0);
|
|
s.shape[2] = 0.0;
|
|
s.motion[1] = 3.0;
|
|
- let b = bake(&s).unwrap();
|
|
+ let b = bake(&s, MIN_TUBE).unwrap();
|
|
assert!(b.extra[3] > 0.5 && b.extra[2] >= 3.0);
|
|
}
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
|
index 0adcd55f6..d44733aeb 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
|
@@ -1,4 +1,4 @@
|
|
-// The smoke grid: one texel of a 1024 x 1024 atlas per cell of a 128 x 64 x 128 lattice (64 slices
|
|
+// The smoke grid (two cascades share this shader): one texel of a 1024 x 1024 atlas per cell of a 128 x 64 x 128 lattice (64 slices
|
|
// of 128 x 128, 8 to a row). Each cell sums the capsules of its 8-cell brick.
|
|
// r density, g soot x density, b heat x density, a clear amount (the largest of the tunnels).
|
|
// Segments: 4 vec4 each (see smoke.rs `Baked`); bricks: (start, count) per brick, then the indices.
|
|
@@ -6,6 +6,8 @@
|
|
@group(0) @binding(0) var<uniform> frame: HdFrame;
|
|
@group(0) @binding(1) var<storage, read> segs: array<vec4<f32>>;
|
|
@group(0) @binding(2) var<storage, read> bricks: array<u32>;
|
|
+// xyz: the grid's corner relative to the camera; w: its cell in blocks (one per cascade).
|
|
+@group(0) @binding(3) var<uniform> grid: vec4<f32>;
|
|
|
|
const BRICKS_XZ: u32 = 16u;
|
|
const BRICK_LISTS: u32 = 4096u;
|
|
@@ -15,7 +17,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
|
|
let px = vec2<u32>(in.position.xy);
|
|
let tile = px / 128u;
|
|
let c = vec3<u32>(px.x % 128u, tile.y * 8u + tile.x, px.y % 128u);
|
|
- let p = frame.smoke_min.xyz + (vec3<f32>(c) + vec3<f32>(0.5)) * frame.smoke.y;
|
|
+ let p = grid.xyz + (vec3<f32>(c) + vec3<f32>(0.5)) * grid.w;
|
|
let b = (c.x >> 3u) + BRICKS_XZ * ((c.z >> 3u) + BRICKS_XZ * (c.y >> 3u));
|
|
let start = bricks[2u * b];
|
|
let count = bricks[2u * b + 1u];
|
|
diff --git a/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
|
|
index 024a1ac8e..14c7622f7 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
|
|
@@ -6,6 +6,35 @@ fn smoke_slice(tex: texture_2d<f32>, s: sampler, x: f32, z: f32, y: f32) -> vec4
|
|
return textureSampleLevel(tex, s, (tile * 128.0 + vec2<f32>(x + 0.5, z + 0.5)) / 1024.0, 0.0);
|
|
}
|
|
|
|
+// A cascade of the grid read trilinearly: `gmin` its corner relative to the camera, `cell` its cell
|
|
+// size. Zero outside, no fade at the edge.
|
|
+fn smoke_raw(tex: texture_2d<f32>, s: sampler, p: vec3<f32>, gmin: vec3<f32>, cell: f32) -> vec4<f32> {
|
|
+ let g = (p - gmin) / cell - vec3<f32>(0.5);
|
|
+ if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 {
|
|
+ return vec4<f32>(0.0);
|
|
+ }
|
|
+ let y0 = floor(g.y);
|
|
+ let y1 = min(y0 + 1.0, 63.0);
|
|
+ let a = smoke_slice(tex, s, g.x, g.z, y0);
|
|
+ let b = smoke_slice(tex, s, g.x, g.z, y1);
|
|
+ return mix(a, b, g.y - y0);
|
|
+}
|
|
+
|
|
+// The far cascade (12-block cells), fading out over its last 6 cells.
|
|
+fn smoke_far_at(tex: texture_2d<f32>, s: sampler, p: vec3<f32>) -> vec4<f32> {
|
|
+ let g = (p - frame.smoke_far.xyz) / frame.smoke_far.w - vec3<f32>(0.5);
|
|
+ let edge = min(min(g.x, 127.0 - g.x), min(g.z, 127.0 - g.z));
|
|
+ return smoke_raw(tex, s, p, frame.smoke_far.xyz, frame.smoke_far.w) * smoothstep(0.0, 6.0, edge);
|
|
+}
|
|
+
|
|
+// How much the near grid covers a point: 1 inside, easing to 0 over its last 8 cells on every side
|
|
+// (the far grid takes the rest, so the two sum to the smoke once).
|
|
+fn smoke_near_cover(p: vec3<f32>) -> f32 {
|
|
+ let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3<f32>(0.5);
|
|
+ let edge = min(min(min(g.x, 127.0 - g.x), min(g.z, 127.0 - g.z)), min(g.y, 63.0 - g.y));
|
|
+ return smoothstep(0.0, 8.0, edge);
|
|
+}
|
|
+
|
|
fn smoke_at(tex: texture_2d<f32>, s: sampler, p: vec3<f32>) -> vec4<f32> {
|
|
let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3<f32>(0.5);
|
|
if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 {
|
|
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
|
|
index d3b55e2f2..fd88b9ece 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
|
|
@@ -24,6 +24,7 @@
|
|
//! multiple-scattering octaves, the light march with growing steps) and the fog's phase mix and
|
|
//! evening glow are ported from Photon by sixthsurge (MIT, see THIRD_PARTY_PHOTON.md), shaders/include/sky/clouds/{common,cumulus}.glsl, shaders/include/lighting/cloud_shadows.glsl and shaders/include/fog/air_fog_vl.glsl at commit 26c2906.
|
|
|
|
+use super::ground::Ground;
|
|
use super::noise as hd_noise;
|
|
use super::{
|
|
DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
|
|
@@ -36,6 +37,8 @@ pub const FOG_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
|
pub const FOG_DIST_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float;
|
|
/// The cloud shadow map's side in texels, and its half size in blocks (the same in `fs_light`).
|
|
pub const SHADOW_SIZE: u32 = 256;
|
|
+/// The cloud panorama (the sky's clouds in every direction, for reflections): azimuths by elevations.
|
|
+pub const PANO_SIZE: [u32; 2] = [256, 64];
|
|
pub const SHADOW_RADIUS: f32 = 1200.0;
|
|
/// Vanilla's cloud base height; the volumetric layer stands on it.
|
|
pub const CLOUD_BASE: f32 = 192.33;
|
|
@@ -89,6 +92,12 @@ pub struct HdVolumes {
|
|
/// How far each cloud texel's ray was marched (ping-pong with `clouds`).
|
|
cloud_dist: [wgpu::TextureView; 2],
|
|
cloud_depth: Option<wgpu::TextureView>,
|
|
+ /// The cloud panorama and its history (ping-pong with `clouds`, the same flip).
|
|
+ pano: [wgpu::TextureView; 2],
|
|
+ pano_pipeline: wgpu::RenderPipeline,
|
|
+ pano_groups: Option<[wgpu::BindGroup; 2]>,
|
|
+ /// The ground's height map for the fog (`ground.rs`).
|
|
+ pub ground: Ground,
|
|
flip: usize,
|
|
history_ready: bool,
|
|
pub cloud_shadow: wgpu::TextureView,
|
|
@@ -123,7 +132,7 @@ pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
|
|
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
|
|
/// `fs_fog`'s group 0: frame, the world's depth, the shadow cascades and their sampler, 3D noise,
|
|
/// repeating and clamped samplers, the sky texture.
|
|
-pub(super) const FOG_BINDINGS: [wgpu::BindingType; 9] = [
|
|
+pub(super) const FOG_BINDINGS: [wgpu::BindingType; 11] = [
|
|
UNIFORM,
|
|
DEPTH,
|
|
SHADOW_ARRAY,
|
|
@@ -133,6 +142,8 @@ pub(super) const FOG_BINDINGS: [wgpu::BindingType; 9] = [
|
|
SAMPLER,
|
|
FLOAT,
|
|
FLOAT,
|
|
+ FLOAT,
|
|
+ FLOAT,
|
|
];
|
|
|
|
impl HdVolumes {
|
|
@@ -256,6 +267,38 @@ impl HdVolumes {
|
|
cache: None,
|
|
})
|
|
};
|
|
+ let pano_pipeline = {
|
|
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
+ label: Some("HD cloud sky"),
|
|
+ bind_group_layouts: &[Some(&cloud_layout)],
|
|
+ ..Default::default()
|
|
+ });
|
|
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
+ label: Some("HD cloud sky"),
|
|
+ layout: Some(&layout),
|
|
+ vertex: wgpu::VertexState {
|
|
+ module: &cloud_module,
|
|
+ entry_point: Some("vs_full"),
|
|
+ compilation_options: Default::default(),
|
|
+ buffers: &[],
|
|
+ },
|
|
+ fragment: Some(wgpu::FragmentState {
|
|
+ module: &cloud_module,
|
|
+ entry_point: Some("fs_cloud_sky"),
|
|
+ compilation_options: Default::default(),
|
|
+ targets: &[Some(wgpu::ColorTargetState {
|
|
+ format: CLOUD_FORMAT,
|
|
+ blend: None,
|
|
+ write_mask: wgpu::ColorWrites::ALL,
|
|
+ })],
|
|
+ }),
|
|
+ primitive: Default::default(),
|
|
+ depth_stencil: None,
|
|
+ multisample: Default::default(),
|
|
+ multiview_mask: None,
|
|
+ cache: None,
|
|
+ })
|
|
+ };
|
|
let shadow_pipeline = fullscreen(
|
|
device,
|
|
"HD cloud shadows",
|
|
@@ -320,6 +363,13 @@ impl HdVolumes {
|
|
one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
|
],
|
|
cloud_depth: None,
|
|
+ pano: [
|
|
+ one("HD cloud sky", PANO_SIZE, CLOUD_FORMAT),
|
|
+ one("HD cloud sky", PANO_SIZE, CLOUD_FORMAT),
|
|
+ ],
|
|
+ pano_pipeline,
|
|
+ pano_groups: None,
|
|
+ ground: Ground::new(device, queue),
|
|
flip: 0,
|
|
history_ready: false,
|
|
cloud_shadow: one(
|
|
@@ -358,6 +408,7 @@ impl HdVolumes {
|
|
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
|
|
self.history_ready = false;
|
|
self.cloud_groups = None;
|
|
+ self.pano_groups = None;
|
|
self.shadow_group = None;
|
|
self.fog_group = None;
|
|
}
|
|
@@ -380,6 +431,11 @@ impl HdVolumes {
|
|
&self.clouds[self.flip]
|
|
}
|
|
|
|
+ /// The cloud panorama written last: what the reflections read.
|
|
+ pub fn pano_view(&self) -> &wgpu::TextureView {
|
|
+ &self.pano[self.flip]
|
|
+ }
|
|
+
|
|
/// The march lengths that go with `cloud_view`.
|
|
pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
|
|
&self.cloud_dist[self.flip]
|
|
@@ -419,6 +475,7 @@ impl HdVolumes {
|
|
) {
|
|
if self.cloud_depth.as_ref() != Some(depth) {
|
|
self.cloud_groups = None;
|
|
+ self.pano_groups = None;
|
|
self.cloud_depth = Some(depth.clone());
|
|
}
|
|
if self.shadow_group.is_none() {
|
|
@@ -475,6 +532,56 @@ impl HdVolumes {
|
|
});
|
|
self.cloud_groups = Some(groups);
|
|
}
|
|
+ if self.pano_groups.is_none() {
|
|
+ let layout = self.cloud_layout.clone();
|
|
+ let (n3, n2, clamp, repeat) = (
|
|
+ self.noise3.clone(),
|
|
+ self.noise2.clone(),
|
|
+ self.clamp.clone(),
|
|
+ self.repeat.clone(),
|
|
+ );
|
|
+ let pano = self.pano.clone();
|
|
+ let dist = self.cloud_dist[0].clone();
|
|
+ let groups = [0, 1].map(|read| {
|
|
+ self.group(
|
|
+ device,
|
|
+ "HD cloud sky",
|
|
+ &layout,
|
|
+ &[
|
|
+ wgpu::BindingResource::Buffer(frame.as_entire_buffer_binding()),
|
|
+ wgpu::BindingResource::TextureView(&n3),
|
|
+ wgpu::BindingResource::TextureView(&n2),
|
|
+ wgpu::BindingResource::Sampler(&clamp),
|
|
+ wgpu::BindingResource::Sampler(&repeat),
|
|
+ wgpu::BindingResource::TextureView(sky_lut),
|
|
+ wgpu::BindingResource::TextureView(&pano[read]),
|
|
+ wgpu::BindingResource::TextureView(atlas),
|
|
+ wgpu::BindingResource::TextureView(depth),
|
|
+ wgpu::BindingResource::TextureView(&dist),
|
|
+ ],
|
|
+ )
|
|
+ });
|
|
+ self.pano_groups = Some(groups);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /// The cloud panorama (a small texture, every frame, blended with the last).
|
|
+ pub fn draw_cloud_sky(
|
|
+ &mut self,
|
|
+ encoder: &mut wgpu::CommandEncoder,
|
|
+ stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
|
+ ) {
|
|
+ if let Some(groups) = &self.pano_groups {
|
|
+ let (read, write) = (self.flip, 1 - self.flip);
|
|
+ stamped_pass(
|
|
+ encoder,
|
|
+ "HD cloud sky",
|
|
+ &[&self.pano[write]],
|
|
+ &self.pano_pipeline,
|
|
+ &groups[read],
|
|
+ stamp,
|
|
+ );
|
|
+ }
|
|
}
|
|
|
|
/// The cloud shadow map.
|
|
@@ -526,10 +633,12 @@ impl HdVolumes {
|
|
shadow_array: &wgpu::TextureView,
|
|
sky_lut: &wgpu::TextureView,
|
|
atlas: &wgpu::TextureView,
|
|
+ far_atlas: &wgpu::TextureView,
|
|
stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
|
) {
|
|
if self.fog_group.as_ref().is_none_or(|(v, _)| v != depth) {
|
|
let layout = self.fog_layout.clone();
|
|
+ let ground = self.ground.view.clone();
|
|
let (n3, clamp, repeat, cmp) = (
|
|
self.noise3.clone(),
|
|
self.clamp.clone(),
|
|
@@ -550,6 +659,8 @@ impl HdVolumes {
|
|
wgpu::BindingResource::Sampler(&clamp),
|
|
wgpu::BindingResource::TextureView(sky_lut),
|
|
wgpu::BindingResource::TextureView(atlas),
|
|
+ wgpu::BindingResource::TextureView(&ground),
|
|
+ wgpu::BindingResource::TextureView(far_atlas),
|
|
],
|
|
);
|
|
self.fog_group = Some((depth.clone(), group));
|
|
@@ -568,6 +679,20 @@ impl HdVolumes {
|
|
}
|
|
|
|
impl Hd {
|
|
+ /// Refreshes the fog's ground map for a camera at world (`x`, `z`); `top` is one above a column's
|
|
+ /// highest block. A few hundred cells a frame; call before `write`.
|
|
+ pub fn write_ground(
|
|
+ &mut self,
|
|
+ queue: &wgpu::Queue,
|
|
+ x: f64,
|
|
+ z: f64,
|
|
+ top: &dyn Fn(i32, i32) -> i32,
|
|
+ ) {
|
|
+ if fog_enabled() {
|
|
+ self.volumes.ground.update(queue, x, z, top);
|
|
+ }
|
|
+ }
|
|
+
|
|
/// The volumetric clouds (with their shadow map) and the fog, before the lighting reads them.
|
|
pub fn volumes(
|
|
&mut self,
|
|
@@ -585,6 +710,8 @@ impl Hd {
|
|
);
|
|
self.volumes
|
|
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
|
|
+ self.volumes
|
|
+ .draw_cloud_sky(encoder, stamp(&mut self.timing, "hd cloud sky"));
|
|
self.volumes
|
|
.draw_clouds(encoder, stamp(&mut self.timing, "hd clouds"));
|
|
} else {
|
|
@@ -593,6 +720,8 @@ impl Hd {
|
|
if self.fog_active {
|
|
self.smoke
|
|
.draw(encoder, stamp(&mut self.timing, "hd smoke grid"));
|
|
+ self.smoke
|
|
+ .draw_far(encoder, stamp(&mut self.timing, "hd smoke grid far"));
|
|
self.volumes.draw_fog(
|
|
device,
|
|
encoder,
|
|
@@ -601,6 +730,7 @@ impl Hd {
|
|
&self.shadows.array,
|
|
&self.sky.lut,
|
|
&self.smoke.atlas,
|
|
+ &self.smoke.far_atlas,
|
|
stamp(&mut self.timing, "hd fog"),
|
|
);
|
|
}
|
|
@@ -636,8 +766,14 @@ mod tests {
|
|
#[test]
|
|
fn noise_scales_divide_the_camera_wrap() {
|
|
let mut found = 0;
|
|
- for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
|
|
- for key in ["w / ", "p3 / "] {
|
|
+ for (name, src) in [
|
|
+ ("cloud_common", CLOUD_COMMON),
|
|
+ ("cloud", CLOUD),
|
|
+ ("fog", FOG),
|
|
+ ] {
|
|
+ for key in [
|
|
+ "w / ", "p3 / ", "wxz / ", "cir.x / ", "cir.y / ", "cw.x / ", "cw.y / ",
|
|
+ ] {
|
|
for part in src.split(key).skip(1) {
|
|
let num: String = part
|
|
.chars()
|
|
@@ -654,7 +790,7 @@ mod tests {
|
|
}
|
|
}
|
|
}
|
|
- assert!(found >= 8, "found only {found} noise lookups");
|
|
+ assert!(found >= 16, "found only {found} noise lookups");
|
|
}
|
|
|
|
/// The cloud noise's height is measured from the layer's base, not from the camera: the same
|
|
diff --git a/crates/horizon-client/src/gfx/hd/water.rs b/crates/horizon-client/src/gfx/hd/water.rs
|
|
index da972c5f2..403892348 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/water.rs
|
|
+++ b/crates/horizon-client/src/gfx/hd/water.rs
|
|
@@ -31,7 +31,7 @@ pub const WATER_NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
|
pub(super) const WATER: &str = include_str!("water.wgsl");
|
|
/// The composite's group 0: frame, lit, G-buffer depth, water colour, normal, depth, shadow array
|
|
/// and sampler, lightmap, linear sampler, sky texture, air.
|
|
-pub(super) const WATER_BINDINGS: [wgpu::BindingType; 12] = [
|
|
+pub(super) const WATER_BINDINGS: [wgpu::BindingType; 13] = [
|
|
UNIFORM,
|
|
FLOAT,
|
|
DEPTH,
|
|
@@ -44,6 +44,7 @@ pub(super) const WATER_BINDINGS: [wgpu::BindingType; 12] = [
|
|
SAMPLER,
|
|
FLOAT,
|
|
UNFILTERED,
|
|
+ FLOAT,
|
|
];
|
|
|
|
/// The water effect is on unless `HORIZON_HD_WATER=0`.
|
|
@@ -60,7 +61,8 @@ pub(super) struct Water {
|
|
composite: wgpu::TextureView,
|
|
layout: wgpu::BindGroupLayout,
|
|
pipeline: wgpu::RenderPipeline,
|
|
- group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
|
|
+ /// One group per flip of the cloud panorama it reads.
|
|
+ group: [Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>; 2],
|
|
/// The water effect runs this frame (the scene allows it and there is translucent terrain on
|
|
/// screen), and `lit_view` is the composite.
|
|
pub(super) active: bool,
|
|
@@ -82,7 +84,7 @@ impl Water {
|
|
composite: target(device, "HD lit with water", [1, 1], HDR).view,
|
|
layout,
|
|
pipeline,
|
|
- group: None,
|
|
+ group: [None, None],
|
|
active: false,
|
|
composited: false,
|
|
}
|
|
@@ -94,7 +96,7 @@ impl Water {
|
|
self.normal = target(device, "HD water normal", size, WATER_NORMAL).view;
|
|
self.depth = target(device, "HD water depth", size, DEPTH_FORMAT).view;
|
|
self.composite = target(device, "HD lit with water", size, HDR).view;
|
|
- self.group = None;
|
|
+ self.group = [None, None];
|
|
}
|
|
|
|
/// The nearest water surface's depth (0 where there is none), for the lighting.
|
|
@@ -170,7 +172,11 @@ impl Hd {
|
|
return;
|
|
}
|
|
let key = (lightmap.0.clone(), lightmap.1.clone());
|
|
- if self.water.group.as_ref().is_none_or(|(k, _)| *k != key) {
|
|
+ let flip = self.volumes.flip_index();
|
|
+ if self.water.group[flip]
|
|
+ .as_ref()
|
|
+ .is_none_or(|(k, _)| *k != key)
|
|
+ {
|
|
let group = self.make_group(
|
|
device,
|
|
"HD water",
|
|
@@ -188,11 +194,12 @@ impl Hd {
|
|
wgpu::BindingResource::Sampler(&self.sampler),
|
|
wgpu::BindingResource::TextureView(&self.sky.lut),
|
|
wgpu::BindingResource::TextureView(&self.lighting.air),
|
|
+ wgpu::BindingResource::TextureView(self.volumes.pano_view()),
|
|
],
|
|
);
|
|
- self.water.group = Some((key, group));
|
|
+ self.water.group[flip] = Some((key, group));
|
|
}
|
|
- let Some((_, group)) = &self.water.group else {
|
|
+ let Some((_, group)) = &self.water.group[flip] else {
|
|
return;
|
|
};
|
|
Self::draw(
|
|
diff --git a/crates/horizon-client/src/gfx/hd/water.wgsl b/crates/horizon-client/src/gfx/hd/water.wgsl
|
|
index a1a30897d..69e295980 100644
|
|
--- a/crates/horizon-client/src/gfx/hd/water.wgsl
|
|
+++ b/crates/horizon-client/src/gfx/hd/water.wgsl
|
|
@@ -16,6 +16,7 @@ const REFLECT_RANGE: f32 = 96.0;
|
|
// What the lighting left beside the lit world: over water the air in front of the surface (scattered
|
|
// light, transmittance), in front of the water the lit floor.
|
|
@group(0) @binding(11) var air_tex: texture_2d<f32>;
|
|
+//PANO12
|
|
|
|
fn ign(px: vec2<f32>) -> f32 {
|
|
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
|
@@ -131,7 +132,7 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
|
fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
|
|
var c = frame.fog_color.rgb;
|
|
if frame.sky_sun.w > 0.5 {
|
|
- c = atmosphere_sky(r);
|
|
+ c = sky_with_clouds(atmosphere_sky(r), r);
|
|
} else if frame.sky_color.w > 0.5 {
|
|
c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
|
|
}
|
|
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
|
|
index 3d67ff25b..057878a8e 100644
|
|
--- a/crates/horizon-client/src/gfx/mod_post.rs
|
|
+++ b/crates/horizon-client/src/gfx/mod_post.rs
|
|
@@ -303,7 +303,7 @@ impl ModPost {
|
|
1.0 / size[1].max(1) as f32,
|
|
],
|
|
time: [
|
|
- self.start.elapsed().as_secs_f32() % 3600.0,
|
|
+ crate::film_clock::since(self.start).as_secs_f32() % 3600.0,
|
|
partial,
|
|
sun[1],
|
|
0.0,
|
|
diff --git a/crates/horizon-client/src/gfx/native_post.rs b/crates/horizon-client/src/gfx/native_post.rs
|
|
index bf4293157..52a63c1b0 100644
|
|
--- a/crates/horizon-client/src/gfx/native_post.rs
|
|
+++ b/crates/horizon-client/src/gfx/native_post.rs
|
|
@@ -85,7 +85,7 @@ impl NativeFrame {
|
|
|
|
/// Writes this frame's inputs, for a world of `size`.
|
|
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
|
- let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
|
+ let seconds = crate::film_clock::since(self.start).as_secs_f32() % 3600.0;
|
|
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
|
self.frames = self.frames.wrapping_add(1);
|
|
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
|
diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs
|
|
index 4da492b00..665c0111f 100644
|
|
--- a/crates/horizon-client/src/gfx/pack_uniforms.rs
|
|
+++ b/crates/horizon-client/src/gfx/pack_uniforms.rs
|
|
@@ -277,7 +277,7 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
|
impl FrameUniforms {
|
|
/// This frame's values for a render size of `size` pixels.
|
|
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
|
- let now = std::time::Instant::now();
|
|
+ let now = crate::film_clock::now();
|
|
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
|
self.last = Some(now);
|
|
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
|
diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs
|
|
index d4ef4c209..58afe409e 100644
|
|
--- a/crates/horizon-client/src/lib.rs
|
|
+++ b/crates/horizon-client/src/lib.rs
|
|
@@ -63,6 +63,7 @@ pub mod extras;
|
|
pub mod extras_hud;
|
|
pub mod falling_block;
|
|
pub mod fast_hash;
|
|
+pub mod film_clock;
|
|
pub mod fire_render;
|
|
pub mod first_person;
|
|
pub mod flatgen;
|
|
@@ -181,6 +182,7 @@ pub mod weather;
|
|
pub mod widgets;
|
|
pub mod win_screen;
|
|
pub mod world;
|
|
+pub mod world_log;
|
|
pub mod world_spawn;
|
|
pub mod worldborder;
|
|
pub mod zip;
|
|
diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs
|
|
index f2d636ac8..40ecbdf7d 100644
|
|
--- a/crates/horizon-client/src/world.rs
|
|
+++ b/crates/horizon-client/src/world.rs
|
|
@@ -105,6 +105,10 @@ pub struct World {
|
|
/// The sections whose blocks changed since a mod last asked, kept only
|
|
/// while a mod watches (`render.world-changes`).
|
|
pub mod_changes: ModChanges,
|
|
+ /// AirDef fork: the log of block changes a mod asked for (`render.world-log-start`) and the
|
|
+ /// game tick the changes are stamped with.
|
|
+ pub log: Option<crate::world_log::WorldLog>,
|
|
+ pub log_tick: u64,
|
|
}
|
|
/// The block changes a mod's cache of the world must hear of.
|
|
#[derive(Default)]
|
|
@@ -227,6 +231,8 @@ impl World {
|
|
pois: Default::default(),
|
|
epoch: NEXT_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
|
mod_changes: ModChanges::default(),
|
|
+ log: None,
|
|
+ log_tick: 0,
|
|
}
|
|
}
|
|
/// A controlled blockstate scene for the native scene probes: material
|
|
@@ -290,14 +296,35 @@ impl World {
|
|
/// section borders) for remeshing and the surrounding chunks for relighting.
|
|
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
|
pub fn set(&mut self, x: i32, y: i32, z: i32, state: BlockStateId) -> crate::Result<()> {
|
|
- let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
|
|
+ if self.chunks.get(&(x >> 4, z >> 4)).is_none() {
|
|
return Err("write outside resident chunks".into());
|
|
- };
|
|
+ }
|
|
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
|
|
return Err("write outside world height".into());
|
|
}
|
|
- let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
|
|
+ // A mod's view of an earlier time keeps the blocks it shows: a real change is logged and
|
|
+ // held back (the state before it is the real one) until the view moves past it.
|
|
+ let deferred = self
|
|
+ .log
|
|
+ .as_ref()
|
|
+ .and_then(|l| l.deferred([x, y, z], self.block(x, y, z).0));
|
|
+ let previous = match deferred {
|
|
+ Some(real) => real,
|
|
+ None => {
|
|
+ let chunk = self.chunks.get_mut(&(x >> 4, z >> 4)).expect("checked");
|
|
+ Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0)
|
|
+ }
|
|
+ };
|
|
if previous != state.0 {
|
|
+ if let Some(log) = &mut self.log {
|
|
+ log.record(
|
|
+ self.log_tick,
|
|
+ [x, y, z],
|
|
+ previous,
|
|
+ state.0,
|
|
+ deferred.is_some(),
|
|
+ );
|
|
+ }
|
|
self.block_entities
|
|
.state_changed((x, y, z), previous, state.0);
|
|
let watches = |id: u32| self.watched.get(id as usize).copied().unwrap_or(false);
|
|
@@ -312,32 +339,90 @@ impl World {
|
|
log.push((x, y, z));
|
|
}
|
|
self.modified.insert((x >> 4, z >> 4));
|
|
- let sy = (y - MIN_Y) >> 4;
|
|
- for dz in -1..=1 {
|
|
- for dy in -1..=1 {
|
|
- for dx in -1..=1 {
|
|
- let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
|
- let s = (by - MIN_Y) >> 4;
|
|
- if !(0..SECTIONS as i32).contains(&s) {
|
|
- continue;
|
|
- }
|
|
- self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
|
+ if deferred.is_none() {
|
|
+ self.touched(x, y, z, previous, state.0);
|
|
+ }
|
|
+ }
|
|
+ Ok(())
|
|
+ }
|
|
+ /// Marks what a written block changes: the sections to remesh (and their neighbours on a
|
|
+ /// border) and the light.
|
|
+ fn touched(&mut self, x: i32, y: i32, z: i32, previous: u32, state: u32) {
|
|
+ for dz in -1..=1 {
|
|
+ for dy in -1..=1 {
|
|
+ for dx in -1..=1 {
|
|
+ let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
|
+ let s = (by - MIN_Y) >> 4;
|
|
+ if !(0..SECTIONS as i32).contains(&s) {
|
|
+ continue;
|
|
}
|
|
+ self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
|
}
|
|
}
|
|
- let _ = sy;
|
|
- if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
|
- self.light_edits.push(((x, y, z), previous, state.0));
|
|
- } else {
|
|
- for dz in -1..=1 {
|
|
- for dx in -1..=1 {
|
|
- self.dirty_light.insert(((x >> 4) + dx, (z >> 4) + dz));
|
|
- }
|
|
+ }
|
|
+ if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
|
+ self.light_edits.push(((x, y, z), previous, state));
|
|
+ } else {
|
|
+ for dz in -1..=1 {
|
|
+ for dx in -1..=1 {
|
|
+ self.dirty_light.insert(((x >> 4) + dx, (z >> 4) + dz));
|
|
}
|
|
}
|
|
}
|
|
- Ok(())
|
|
}
|
|
+ /// A mod's view of the world at an earlier tick (`render.set-world-time-view`): moves it
|
|
+ /// toward `target` by at most `budget` changes, leaving alone the blocks around `protect`
|
|
+ /// (a block position whose neighbourhood stays as it is: the player's). Only the client's copy of the blocks changes. True when it has got there.
|
|
+ pub fn view_log(
|
|
+ &mut self,
|
|
+ target: Option<u64>,
|
|
+ budget: usize,
|
|
+ protect: Option<[i32; 3]>,
|
|
+ ) -> bool {
|
|
+ let Some(mut log) = self.log.take() else {
|
|
+ return true;
|
|
+ };
|
|
+ let keep = |p: [i32; 3]| {
|
|
+ protect.is_none_or(|c| {
|
|
+ !((p[0] - c[0]).abs() <= 2
|
|
+ && (p[2] - c[2]).abs() <= 2
|
|
+ && (-3..=3).contains(&(p[1] - c[1])))
|
|
+ })
|
|
+ };
|
|
+ let done = log.view(&mut ShownBlocks(self), target, budget, keep);
|
|
+ self.log = Some(log);
|
|
+ done
|
|
+ }
|
|
+}
|
|
+/// The world as a log's view writes it: only what is drawn changes.
|
|
+struct ShownBlocks<'a>(&'a mut World);
|
|
+impl crate::world_log::Blocks for ShownBlocks<'_> {
|
|
+ fn get(&self, pos: [i32; 3]) -> Option<u32> {
|
|
+ let w = &*self.0;
|
|
+ (w.chunks.get(&(pos[0] >> 4, pos[2] >> 4)).is_some()
|
|
+ && (MIN_Y..MIN_Y + (SECTIONS as i32) * 16).contains(&pos[1]))
|
|
+ .then(|| w.block(pos[0], pos[1], pos[2]).0)
|
|
+ }
|
|
+ fn put(&mut self, [x, y, z]: [i32; 3], state: u32) -> bool {
|
|
+ let w = &mut *self.0;
|
|
+ if !(MIN_Y..MIN_Y + (SECTIONS as i32) * 16).contains(&y) {
|
|
+ return false;
|
|
+ }
|
|
+ let Some(chunk) = w.chunks.get_mut(&(x >> 4, z >> 4)) else {
|
|
+ return false;
|
|
+ };
|
|
+ let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state);
|
|
+ if previous != state {
|
|
+ w.revision = w.revision.wrapping_add(1);
|
|
+ if w.mod_changes.watching {
|
|
+ w.mod_changes.note(x, y, z);
|
|
+ }
|
|
+ w.touched(x, y, z, previous, state);
|
|
+ }
|
|
+ true
|
|
+ }
|
|
+}
|
|
+impl World {
|
|
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
|
|
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
|
|
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
|
|
diff --git a/crates/horizon-client/src/world_log.rs b/crates/horizon-client/src/world_log.rs
|
|
new file mode 100644
|
|
index 000000000..d0c0b1158
|
|
--- /dev/null
|
|
+++ b/crates/horizon-client/src/world_log.rs
|
|
@@ -0,0 +1,372 @@
|
|
+//! AirDef fork: a bounded log of the block changes the client's world took, and a view of the
|
|
+//! world as it stood at an earlier game tick (`render.world-log-start`, `set-world-time-view`).
|
|
+//!
|
|
+//! The view is a cursor into the log: the world shown is the real one with every change from
|
|
+//! the cursor on undone. Moving the cursor back undoes changes (newest first), moving it on
|
|
+//! redoes them, a few at a time so a long seek is spread over frames. A change that arrives
|
|
+//! while the cursor is behind the end is logged and kept as the block's real state, but not
|
|
+//! written (it lies after the view's time): it shows when the cursor passes it, or when the
|
|
+//! view ends and every displaced block is put back. Pure over [`Blocks`]; the world is one.
|
|
+use std::collections::{HashMap, VecDeque};
|
|
+
|
|
+pub type Pos = [i32; 3];
|
|
+
|
|
+/// Changes a log keeps (about 24 MB): the oldest go first.
|
|
+pub const CAP: usize = 1_000_000;
|
|
+
|
|
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
+pub struct Entry {
|
|
+ pub tick: u64,
|
|
+ pub pos: Pos,
|
|
+ pub old: u32,
|
|
+ pub new: u32,
|
|
+}
|
|
+
|
|
+/// The blocks a log shows its view in.
|
|
+pub trait Blocks {
|
|
+ /// The state at `pos`; None where the chunk is not loaded.
|
|
+ fn get(&self, pos: Pos) -> Option<u32>;
|
|
+ /// Writes a state as a display only (no gameplay, no network); false where it is not loaded.
|
|
+ fn put(&mut self, pos: Pos, state: u32) -> bool;
|
|
+}
|
|
+
|
|
+pub struct WorldLog {
|
|
+ pub id: u32,
|
|
+ entries: VecDeque<Entry>,
|
|
+ /// The sequence number of `entries[0]`.
|
|
+ base: u64,
|
|
+ /// Entries from this sequence number on are undone in the world shown.
|
|
+ cursor: u64,
|
|
+ /// Blocks shown other than they are: their real state.
|
|
+ shown: HashMap<Pos, u32>,
|
|
+ cap: usize,
|
|
+ /// The tick the log began, and the tick of the newest change it let go.
|
|
+ start: u64,
|
|
+ lost: Option<u64>,
|
|
+}
|
|
+
|
|
+impl WorldLog {
|
|
+ pub fn new(id: u32, tick: u64) -> Self {
|
|
+ Self::with_cap(id, tick, CAP)
|
|
+ }
|
|
+ pub fn with_cap(id: u32, tick: u64, cap: usize) -> Self {
|
|
+ Self {
|
|
+ id,
|
|
+ entries: VecDeque::new(),
|
|
+ base: 0,
|
|
+ cursor: 0,
|
|
+ shown: HashMap::new(),
|
|
+ cap: cap.max(1),
|
|
+ start: tick,
|
|
+ lost: None,
|
|
+ }
|
|
+ }
|
|
+ fn end(&self) -> u64 {
|
|
+ self.base + self.entries.len() as u64
|
|
+ }
|
|
+ pub fn len(&self) -> usize {
|
|
+ self.entries.len()
|
|
+ }
|
|
+ pub fn is_empty(&self) -> bool {
|
|
+ self.entries.is_empty()
|
|
+ }
|
|
+ /// The earliest tick whose world the log can show.
|
|
+ pub fn earliest(&self) -> u64 {
|
|
+ self.lost.unwrap_or(self.start)
|
|
+ }
|
|
+ /// Whether the world shown differs from the real one (a view is on, or blocks wait to be put back).
|
|
+ pub fn active(&self) -> bool {
|
|
+ self.cursor < self.end() || !self.shown.is_empty()
|
|
+ }
|
|
+ /// The blocks shown other than they are.
|
|
+ pub fn displaced(&self) -> usize {
|
|
+ self.shown.len()
|
|
+ }
|
|
+ /// The first sequence number whose change came after tick `t` (the world at `t` is the real
|
|
+ /// one with those undone).
|
|
+ fn seq_after(&self, t: u64) -> u64 {
|
|
+ let t = t.max(self.earliest());
|
|
+ self.base + self.entries.partition_point(|e| e.tick <= t) as u64
|
|
+ }
|
|
+
|
|
+ /// A write of `new` to `pos` is about to happen with `stored` in the world: Some(the real
|
|
+ /// state before it) when it must not be written (the view is behind it, or the block is
|
|
+ /// displaced), None for an ordinary write (the caller's previous state is the real one).
|
|
+ pub fn deferred(&self, pos: Pos, stored: u32) -> Option<u32> {
|
|
+ (self.cursor < self.end() || self.shown.contains_key(&pos))
|
|
+ .then(|| self.shown.get(&pos).copied().unwrap_or(stored))
|
|
+ }
|
|
+
|
|
+ /// Logs a change that happened at `tick` (`deferred`: it was not written, see [`deferred`]).
|
|
+ pub fn record(&mut self, tick: u64, pos: Pos, old: u32, new: u32, deferred: bool) {
|
|
+ if old == new {
|
|
+ return;
|
|
+ }
|
|
+ // Behind the end the new change is undone too: the cursor stays where it is.
|
|
+ let at_end = self.cursor == self.end();
|
|
+ self.entries.push_back(Entry {
|
|
+ tick,
|
|
+ pos,
|
|
+ old,
|
|
+ new,
|
|
+ });
|
|
+ if at_end && !deferred {
|
|
+ self.cursor = self.end();
|
|
+ }
|
|
+ if deferred {
|
|
+ self.shown.insert(pos, new);
|
|
+ if at_end {
|
|
+ // Displaced blocks wait for their restoration with the cursor at the end.
|
|
+ self.cursor = self.end();
|
|
+ }
|
|
+ }
|
|
+ while self.entries.len() > self.cap {
|
|
+ let gone = self.entries.pop_front().expect("over the cap");
|
|
+ self.base += 1;
|
|
+ self.lost = Some(gone.tick);
|
|
+ }
|
|
+ self.cursor = self.cursor.max(self.base);
|
|
+ }
|
|
+
|
|
+ /// Moves the view toward the world at `target` (None: the real world), at most `budget`
|
|
+ /// changes, leaving alone the blocks `keep` refuses. True when it has got there.
|
|
+ pub fn view(
|
|
+ &mut self,
|
|
+ blocks: &mut impl Blocks,
|
|
+ target: Option<u64>,
|
|
+ budget: usize,
|
|
+ keep: impl Fn(Pos) -> bool,
|
|
+ ) -> bool {
|
|
+ self.cursor = self.cursor.max(self.base);
|
|
+ let goal = target.map_or(self.end(), |t| self.seq_after(t));
|
|
+ let mut left = budget;
|
|
+ while left > 0 && self.cursor > goal {
|
|
+ self.cursor -= 1;
|
|
+ let e = self.entries[(self.cursor - self.base) as usize];
|
|
+ self.show(blocks, e.pos, e.old, &keep);
|
|
+ left -= 1;
|
|
+ }
|
|
+ while left > 0 && self.cursor < goal {
|
|
+ let e = self.entries[(self.cursor - self.base) as usize];
|
|
+ self.cursor += 1;
|
|
+ self.show(blocks, e.pos, e.new, &keep);
|
|
+ left -= 1;
|
|
+ }
|
|
+ if self.cursor == goal && goal == self.end() && !self.shown.is_empty() {
|
|
+ // Back at the real world: whatever is still displaced goes back.
|
|
+ let rest: Vec<(Pos, u32)> = self
|
|
+ .shown
|
|
+ .iter()
|
|
+ .map(|(p, s)| (*p, *s))
|
|
+ .take(left)
|
|
+ .collect();
|
|
+ for (pos, real) in rest {
|
|
+ blocks.put(pos, real);
|
|
+ self.shown.remove(&pos);
|
|
+ }
|
|
+ }
|
|
+ self.cursor == goal && (goal != self.end() || self.shown.is_empty())
|
|
+ }
|
|
+
|
|
+ fn show(
|
|
+ &mut self,
|
|
+ blocks: &mut impl Blocks,
|
|
+ pos: Pos,
|
|
+ state: u32,
|
|
+ keep: &impl Fn(Pos) -> bool,
|
|
+ ) {
|
|
+ if !keep(pos) {
|
|
+ return;
|
|
+ }
|
|
+ let real = match self.shown.get(&pos) {
|
|
+ Some(&r) => r,
|
|
+ // Not loaded: skipped, silently.
|
|
+ None => match blocks.get(pos) {
|
|
+ Some(s) => s,
|
|
+ None => return,
|
|
+ },
|
|
+ };
|
|
+ if !blocks.put(pos, state) {
|
|
+ self.shown.remove(&pos);
|
|
+ } else if state == real {
|
|
+ self.shown.remove(&pos);
|
|
+ } else {
|
|
+ self.shown.insert(pos, real);
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+
|
|
+ /// A world of blocks, some chunks (x >> 4) not loaded.
|
|
+ #[derive(Default)]
|
|
+ struct W {
|
|
+ b: HashMap<Pos, u32>,
|
|
+ unloaded: Vec<i32>,
|
|
+ }
|
|
+ impl W {
|
|
+ fn loaded(&self, p: Pos) -> bool {
|
|
+ !self.unloaded.contains(&(p[0] >> 4))
|
|
+ }
|
|
+ /// A real write, as the world's `set` does it.
|
|
+ fn set(&mut self, log: &mut WorldLog, tick: u64, p: Pos, s: u32) {
|
|
+ let stored = self.b.get(&p).copied().unwrap_or(0);
|
|
+ let deferred = log.deferred(p, stored);
|
|
+ let prev = deferred.unwrap_or(stored);
|
|
+ if deferred.is_none() {
|
|
+ self.b.insert(p, s);
|
|
+ }
|
|
+ log.record(tick, p, prev, s, deferred.is_some());
|
|
+ }
|
|
+ fn shows(&self, p: Pos) -> u32 {
|
|
+ self.b.get(&p).copied().unwrap_or(0)
|
|
+ }
|
|
+ }
|
|
+ impl Blocks for W {
|
|
+ fn get(&self, p: Pos) -> Option<u32> {
|
|
+ self.loaded(p).then(|| self.shows(p))
|
|
+ }
|
|
+ fn put(&mut self, p: Pos, s: u32) -> bool {
|
|
+ self.loaded(p).then(|| self.b.insert(p, s)).is_some()
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn all(_: Pos) -> bool {
|
|
+ true
|
|
+ }
|
|
+
|
|
+ /// Stone at 1 until a blast at tick 10 digs (0,0,0) and (1,0,0), tick 20 digs (2,0,0), tick 30 places 7 at (0,0,0).
|
|
+ fn raid() -> (W, WorldLog) {
|
|
+ let mut w = W::default();
|
|
+ let mut log = WorldLog::new(1, 0);
|
|
+ for x in 0..3 {
|
|
+ w.b.insert([x, 0, 0], 1);
|
|
+ }
|
|
+ w.set(&mut log, 10, [0, 0, 0], 0);
|
|
+ w.set(&mut log, 10, [1, 0, 0], 0);
|
|
+ w.set(&mut log, 20, [2, 0, 0], 0);
|
|
+ w.set(&mut log, 30, [0, 0, 0], 7);
|
|
+ (w, log)
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn undo_and_redo_show_the_world_at_each_tick() {
|
|
+ let (mut w, mut log) = raid();
|
|
+ let at = |w: &W| (w.shows([0, 0, 0]), w.shows([1, 0, 0]), w.shows([2, 0, 0]));
|
|
+ assert_eq!(at(&w), (7, 0, 0));
|
|
+ assert!(log.view(&mut w, Some(5), 100, all));
|
|
+ assert_eq!(at(&w), (1, 1, 1));
|
|
+ assert!(log.view(&mut w, Some(10), 100, all));
|
|
+ assert_eq!(at(&w), (0, 0, 1));
|
|
+ assert!(log.view(&mut w, Some(25), 100, all));
|
|
+ assert_eq!(at(&w), (0, 0, 0));
|
|
+ // Back in time and on again, whichever way.
|
|
+ assert!(log.view(&mut w, Some(12), 100, all));
|
|
+ assert_eq!(at(&w), (0, 0, 1));
|
|
+ assert!(log.view(&mut w, Some(30), 100, all));
|
|
+ assert_eq!(at(&w), (7, 0, 0));
|
|
+ assert!(!log.active());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn ending_the_view_puts_every_block_back() {
|
|
+ let (mut w, mut log) = raid();
|
|
+ log.view(&mut w, Some(0), 100, all);
|
|
+ assert!(log.active());
|
|
+ assert_eq!(log.displaced(), 3);
|
|
+ assert!(log.view(&mut w, None, 100, all));
|
|
+ assert_eq!(
|
|
+ (w.shows([0, 0, 0]), w.shows([1, 0, 0]), w.shows([2, 0, 0])),
|
|
+ (7, 0, 0)
|
|
+ );
|
|
+ assert!(!log.active());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn a_seek_is_spread_over_budgeted_steps() {
|
|
+ let (mut w, mut log) = raid();
|
|
+ let mut steps = 0;
|
|
+ while !log.view(&mut w, Some(0), 1, all) {
|
|
+ steps += 1;
|
|
+ assert!(steps < 10);
|
|
+ }
|
|
+ assert_eq!(steps, 3);
|
|
+ assert_eq!(w.shows([0, 0, 0]), 1);
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn a_change_that_arrives_during_the_view_waits_for_the_end() {
|
|
+ let (mut w, mut log) = raid();
|
|
+ log.view(&mut w, Some(5), 100, all);
|
|
+ // A new block at an untouched place and a new state of a displaced one, at tick 40.
|
|
+ w.set(&mut log, 40, [5, 0, 0], 9);
|
|
+ w.set(&mut log, 40, [0, 0, 0], 8);
|
|
+ assert_eq!(
|
|
+ w.shows([5, 0, 0]),
|
|
+ 0,
|
|
+ "not shown while the view is at tick 5"
|
|
+ );
|
|
+ assert_eq!(w.shows([0, 0, 0]), 1);
|
|
+ // The view moves past it: the real state shows.
|
|
+ log.view(&mut w, Some(40), 100, all);
|
|
+ assert_eq!(w.shows([5, 0, 0]), 9);
|
|
+ assert_eq!(w.shows([0, 0, 0]), 8);
|
|
+ // Seeking back hides it again, the end brings it back.
|
|
+ log.view(&mut w, Some(5), 100, all);
|
|
+ assert_eq!(w.shows([5, 0, 0]), 0);
|
|
+ log.view(&mut w, None, 100, all);
|
|
+ assert_eq!((w.shows([5, 0, 0]), w.shows([0, 0, 0])), (9, 8));
|
|
+ assert!(!log.active());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn the_cap_drops_the_oldest_and_the_view_starts_from_the_earliest_known() {
|
|
+ let mut w = W::default();
|
|
+ let mut log = WorldLog::with_cap(1, 0, 2);
|
|
+ for (i, x) in (0..4).enumerate() {
|
|
+ w.set(&mut log, 10 * (i as u64 + 1), [x, 0, 0], 1);
|
|
+ }
|
|
+ assert_eq!(log.len(), 2);
|
|
+ assert_eq!(log.earliest(), 20);
|
|
+ // Before the earliest shows the earliest: changes at ticks 30 and 40 undone only.
|
|
+ assert!(log.view(&mut w, Some(0), 100, all));
|
|
+ assert_eq!(
|
|
+ (
|
|
+ w.shows([0, 0, 0]),
|
|
+ w.shows([1, 0, 0]),
|
|
+ w.shows([2, 0, 0]),
|
|
+ w.shows([3, 0, 0])
|
|
+ ),
|
|
+ (1, 1, 0, 0)
|
|
+ );
|
|
+ assert!(log.view(&mut w, None, 100, all));
|
|
+ assert_eq!(w.shows([3, 0, 0]), 1);
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn unloaded_chunks_are_skipped_silently() {
|
|
+ let mut w = W::default();
|
|
+ let mut log = WorldLog::new(1, 0);
|
|
+ w.set(&mut log, 5, [0, 0, 0], 1);
|
|
+ w.set(&mut log, 5, [32, 0, 0], 1);
|
|
+ w.unloaded.push(2);
|
|
+ assert!(log.view(&mut w, Some(0), 100, all));
|
|
+ assert_eq!(w.shows([0, 0, 0]), 0);
|
|
+ assert_eq!(log.displaced(), 1);
|
|
+ assert!(log.view(&mut w, None, 100, all));
|
|
+ assert_eq!(w.shows([0, 0, 0]), 1);
|
|
+ assert!(!log.active());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn blocks_the_game_keeps_are_left_alone() {
|
|
+ let (mut w, mut log) = raid();
|
|
+ let keep = |p: Pos| p[0] != 0;
|
|
+ log.view(&mut w, Some(0), 100, keep);
|
|
+ assert_eq!((w.shows([0, 0, 0]), w.shows([1, 0, 0])), (7, 1));
|
|
+ log.view(&mut w, None, 100, all);
|
|
+ assert_eq!(w.shows([0, 0, 0]), 7);
|
|
+ }
|
|
+}
|
|
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
|
index a0cc60c72..a367a666f 100644
|
|
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
|
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
|
@@ -858,6 +858,32 @@ interface render {
|
|
/// Meant for replays and films; false restores the player's settings. A
|
|
/// capture turns it on for its duration.
|
|
set-film-quality: func(on: bool);
|
|
+ /// Starts a log of the world's block changes as the game's client sees
|
|
+ /// them (AirDef fork, level 18): each change keeps its position, the
|
|
+ /// state before and after, and the game tick (the number `tick` is
|
|
+ /// called with). It is bounded (about a million changes): the oldest go
|
|
+ /// first. Returns the log's id, which stays the same while it runs (a
|
|
+ /// second call returns it). Only the world open now has it: a new world
|
|
+ /// (leaving, rejoining) loses it. Not kept when the world runs in the
|
|
+ /// game's own frame (`--in-frame`), where `world-log-range` stays none.
|
|
+ world-log-start: func() -> u32;
|
|
+ /// Ends the log and forgets what it kept (a different id is ignored).
|
|
+ world-log-stop: func(id: u32);
|
|
+ /// What the log covers, as (the earliest tick whose world it can show,
|
|
+ /// the tick it is at now); none for an unknown id, a log of another
|
|
+ /// world, or one that has not been taken up yet (the tick after
|
|
+ /// `world-log-start`). Asking for a time before the earliest shows the
|
|
+ /// earliest.
|
|
+ world-log-range: func(id: u32) -> option<tuple<u64, u64>>;
|
|
+ /// Shows the world as it stood at a game tick, (log id, tick), to the
|
|
+ /// player's eyes only (AirDef fork, level 18): the changes logged after
|
|
+ /// it are undone in the game's own copy of the blocks, a little each
|
|
+ /// frame, and redone as the tick moves on; none (or the mod stopping,
|
|
+ /// the world changing) puts every block back. The server, the saves,
|
|
+ /// the player's own collision and the blocks next to the player are
|
|
+ /// untouched, and changes that arrive meanwhile are kept and shown
|
|
+ /// when the view ends. Chunks that are not loaded are skipped.
|
|
+ set-world-time-view: func(view: option<tuple<u32, u64>>);
|
|
}
|
|
|
|
/// What the game calls. Only the hooks `init` asks for are ever called.
|
|
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
|
index ce4555e32..55920c1da 100644
|
|
--- a/crates/horizon-mods/src/api_level.rs
|
|
+++ b/crates/horizon-mods/src/api_level.rs
|
|
@@ -7,13 +7,13 @@
|
|
//! version's level so the launcher can tell before it installs.
|
|
|
|
/// The newest level this Horizon serves.
|
|
-pub const LEVEL: u32 = 17;
|
|
+pub const LEVEL: u32 = 18;
|
|
|
|
/// Every function the game offers mods, as `interface.function`, with its
|
|
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
|
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
|
/// test fails until it is listed here).
|
|
-pub const FUNCTIONS: [(&str, u32); 74] = [
|
|
+pub const FUNCTIONS: [(&str, u32); 78] = [
|
|
("log.write", 0),
|
|
("game.get-player", 0),
|
|
("game.get-clock", 0),
|
|
@@ -95,6 +95,11 @@ pub const FUNCTIONS: [(&str, u32); 74] = [
|
|
("render.capture-stop", 17),
|
|
("render.capture-status", 17),
|
|
("render.set-film-quality", 17),
|
|
+ // AirDef fork: a log of block changes and a view of the world as it was.
|
|
+ ("render.world-log-start", 18),
|
|
+ ("render.world-log-stop", 18),
|
|
+ ("render.world-log-range", 18),
|
|
+ ("render.set-world-time-view", 18),
|
|
];
|
|
|
|
/// What the game's program carries so the launcher can read its level
|
|
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
|
index b77e32c43..89fa150d0 100644
|
|
--- a/crates/horizon-mods/src/runtime.rs
|
|
+++ b/crates/horizon-mods/src/runtime.rs
|
|
@@ -317,8 +317,17 @@ pub struct ModelTable {
|
|
capture_state: Option<CaptureState>,
|
|
/// `render.set-film-quality`.
|
|
film_quality: bool,
|
|
+ /// `render.world-log-start`: the id of the log a mod asked for, if one runs.
|
|
+ world_log: Option<u32>,
|
|
+ /// What the game says the log covers: (id, earliest tick, tick now).
|
|
+ world_log_state: Option<(u32, u64, u64)>,
|
|
+ /// `render.set-world-time-view`: (log id, tick).
|
|
+ time_view: Option<(u32, u64)>,
|
|
}
|
|
|
|
+/// The ids of block-change logs: never reused in a run of the game.
|
|
+static WORLD_LOG_IDS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
|
|
+
|
|
type ModelSlot = Arc<Mutex<ModelTable>>;
|
|
|
|
/// A model as a mod set it: by name, or by a handle of the [`ModelTable`].
|
|
@@ -1383,6 +1392,20 @@ impl Mods {
|
|
t.capture_state = Some(state);
|
|
}
|
|
}
|
|
+ /// The block-change log a mod asked for (`render.world-log-start`), if any.
|
|
+ pub fn world_log_wanted(&self) -> Option<u32> {
|
|
+ self.model_table.lock().ok()?.world_log
|
|
+ }
|
|
+ /// The game's note of what the log covers: (id, earliest tick, tick now), or none.
|
|
+ pub fn set_world_log_state(&self, state: Option<(u32, u64, u64)>) {
|
|
+ if let Ok(mut t) = self.model_table.lock() {
|
|
+ t.world_log_state = state;
|
|
+ }
|
|
+ }
|
|
+ /// The world a mod asked to be shown at a tick (`render.set-world-time-view`): (log id, tick).
|
|
+ pub fn time_view(&self) -> Option<(u32, u64)> {
|
|
+ self.model_table.lock().ok()?.time_view
|
|
+ }
|
|
/// Whether a mod asked for the best picture (`render.set-film-quality`).
|
|
pub fn film_quality(&self) -> bool {
|
|
self.model_table.lock().is_ok_and(|t| t.film_quality)
|
|
@@ -2458,6 +2481,33 @@ impl render::Host for State {
|
|
message: String::new(),
|
|
})
|
|
}
|
|
+ fn world_log_start(&mut self) -> u32 {
|
|
+ let Ok(mut t) = self.model_table.lock() else {
|
|
+ return 0;
|
|
+ };
|
|
+ *t.world_log.get_or_insert_with(|| {
|
|
+ WORLD_LOG_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
+ })
|
|
+ }
|
|
+ fn world_log_stop(&mut self, id: u32) {
|
|
+ if let Ok(mut t) = self.model_table.lock()
|
|
+ && t.world_log == Some(id)
|
|
+ {
|
|
+ t.world_log = None;
|
|
+ t.time_view = None;
|
|
+ }
|
|
+ }
|
|
+ fn world_log_range(&mut self, id: u32) -> Option<(u64, u64)> {
|
|
+ let t = self.model_table.lock().ok()?;
|
|
+ t.world_log_state
|
|
+ .filter(|s| s.0 == id && t.world_log == Some(id))
|
|
+ .map(|s| (s.1, s.2))
|
|
+ }
|
|
+ fn set_world_time_view(&mut self, view: Option<(u32, u64)>) {
|
|
+ if let Ok(mut t) = self.model_table.lock() {
|
|
+ t.time_view = view.filter(|v| t.world_log == Some(v.0));
|
|
+ }
|
|
+ }
|
|
fn set_film_quality(&mut self, on: bool) {
|
|
if let Ok(mut t) = self.model_table.lock() {
|
|
t.film_quality = on;
|
|
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
|
index d33e26705..c1dc88f54 100644
|
|
--- a/docs/api/mods-reference.md
|
|
+++ b/docs/api/mods-reference.md
|
|
@@ -736,6 +736,10 @@ Where a capture is.
|
|
| `capture-stop` | `func(cancel: bool)` | Ends the capture: finishes the file, or (`cancel`) throws it away. |
|
|
| `capture-status` | `func() -> capture-state` | The capture's state; `done` and `failed` stay until the next start. |
|
|
| `set-film-quality` | `func(on: bool)` | Asks for the best picture the renderer can give (AirDef fork, level 17): the HD world at the full size of the window (no resolution scale, so no upscaling), and the volumetric clouds and fog marched with more steps. Meant for replays and films; false restores the player's settings. A capture turns it on for its duration. |
|
|
+| `world-log-start` | `func() -> u32` | Starts a log of the world's block changes as the game's client sees them (AirDef fork, level 18): each change keeps its position, the state before and after, and the game tick (the number `tick` is called with). It is bounded (about a million changes): the oldest go first. Returns the log's id, which stays the same while it runs (a second call returns it). Only the world open now has it: a new world (leaving, rejoining) loses it. Not kept when the world runs in the game's own frame (`--in-frame`), where `world-log-range` stays none. |
|
|
+| `world-log-stop` | `func(id: u32)` | Ends the log and forgets what it kept (a different id is ignored). |
|
|
+| `world-log-range` | `func(id: u32) -> option<tuple<u64, u64>>` | What the log covers, as (the earliest tick whose world it can show, the tick it is at now); none for an unknown id, a log of another world, or one that has not been taken up yet (the tick after `world-log-start`). Asking for a time before the earliest shows the earliest. |
|
|
+| `set-world-time-view` | `func(view: option<tuple<u32, u64>>)` | Shows the world as it stood at a game tick, (log id, tick), to the player's eyes only (AirDef fork, level 18): the changes logged after it are undone in the game's own copy of the blocks, a little each frame, and redone as the tick moves on; none (or the mod stopping, the world changing) puts every block back. The server, the saves, the player's own collision and the blocks next to the player are untouched, and changes that arrive meanwhile are kept and shown when the view ends. Chunks that are not loaded are skipped. |
|
|
|
|
## `events`
|
|
|
|
--
|
|
2.54.0 (Apple Git-157)
|
|
|