Files
airdef/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
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>
2026-10-30 23:44:14 +03:00

1090 lines
50 KiB
Diff

From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:01:09 +0300
Subject: [PATCH 34/40] HD: coloured sun through glass and leaves, rain ripples
on water, film motion blur and depth of field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 152 ++++++++++++-
crates/horizon-client/src/gfx/hd/film.rs | 204 ++++++++++++++++++
crates/horizon-client/src/gfx/hd/film.wgsl | 139 ++++++++++++
crates/horizon-client/src/gfx/hd/frame.rs | 6 +-
crates/horizon-client/src/gfx/hd/light.wgsl | 32 ++-
crates/horizon-client/src/gfx/hd/lighting.rs | 7 +-
crates/horizon-client/src/gfx/hd/mod.rs | 31 ++-
crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 +
crates/horizon-client/src/gfx/hd/shadows.rs | 44 ++++
crates/horizon-client/src/gfx/hd/taa.rs | 7 +-
crates/horizon-client/src/gfx/hd/tonemap.rs | 1 +
crates/horizon-client/src/gfx/hd/water.rs | 8 +-
crates/horizon-client/src/gfx/hd/water.wgsl | 47 +++-
crates/horizon-client/src/terrain.wgsl | 29 +++
14 files changed, 696 insertions(+), 12 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/hd/film.rs
create mode 100644 crates/horizon-client/src/gfx/hd/film.wgsl
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 5b221fd7e..a2a6e78b6 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -595,6 +595,11 @@ pub struct Renderer {
gbuffer_entity: wgpu::RenderPipeline,
/// The HD sun shadows: depth only, from the light, and the sections in its farther cascade.
shadow_terrain: wgpu::RenderPipeline,
+ /// The near cascade's depth without leaves, and its colour map's leaves and glass
+ /// (coloured shadows, `HORIZON_HD_COLOR_SHADOW`).
+ shadow_terrain_near: wgpu::RenderPipeline,
+ shadow_tint_leaves: wgpu::RenderPipeline,
+ shadow_tint_glass: wgpu::RenderPipeline,
shadow_entity: wgpu::RenderPipeline,
hd_shadow_visible: Vec<resident::Visible>,
/// The far cascade as last drawn (matrix, camera, light) and the frames since.
@@ -1534,6 +1539,84 @@ impl Renderer {
multiview_mask: None,
cache: None,
});
+ // Coloured shadows: the near cascade's depth leaves the leaves out, a second pass writes
+ // what leaves and glass do to the light (see `fs_shadow_tint`).
+ let shadow_terrain_near = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD shadows terrain near"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs_terrain"),
+ compilation_options: Default::default(),
+ buffers: &terrain_buffers,
+ },
+ primitive: Default::default(),
+ depth_stencil: shadow_depth.clone(),
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs_shadow_near"),
+ compilation_options: wgpu::PipelineCompilationOptions {
+ constants: &sprite_constants,
+ ..Default::default()
+ },
+ targets: &[],
+ }),
+ multiview_mask: None,
+ cache: None,
+ });
+ let tint_target = [Some(wgpu::ColorTargetState {
+ format: hd::SHADOW_TINT,
+ // Colours multiply; the alpha (distance from the light) keeps the nearest.
+ blend: Some(wgpu::BlendState {
+ color: wgpu::BlendComponent {
+ src_factor: wgpu::BlendFactor::Dst,
+ dst_factor: wgpu::BlendFactor::Zero,
+ operation: wgpu::BlendOperation::Add,
+ },
+ alpha: wgpu::BlendComponent {
+ src_factor: wgpu::BlendFactor::One,
+ dst_factor: wgpu::BlendFactor::One,
+ operation: wgpu::BlendOperation::Min,
+ },
+ }),
+ write_mask: wgpu::ColorWrites::ALL,
+ })];
+ let tint_pipeline = |label: &str, leaves: bool| {
+ let constants = [
+ ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
+ ("SPRITE_SPLIT", f64::from(split)),
+ ("WATER_STILL", f64::from(water_layers[0])),
+ ("WATER_FLOW", f64::from(water_layers[1])),
+ ("SHADOW_SOLID", if leaves { 1.0 } else { 0.0 }),
+ ];
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(label),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs_terrain"),
+ compilation_options: Default::default(),
+ buffers: &terrain_buffers,
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs_shadow_tint"),
+ compilation_options: wgpu::PipelineCompilationOptions {
+ constants: &constants,
+ ..Default::default()
+ },
+ targets: &tint_target,
+ }),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
+ let shadow_tint_leaves = tint_pipeline("HD shadow colour leaves", true);
+ let shadow_tint_glass = tint_pipeline("HD shadow colour glass", false);
let solid = pipeline(
"Terrain solid",
"vs_terrain",
@@ -2911,6 +2994,9 @@ impl Renderer {
gbuffer_water,
gbuffer_entity,
shadow_terrain,
+ shadow_terrain_near,
+ shadow_tint_leaves,
+ shadow_tint_glass,
shadow_entity,
hd_shadow_visible: Vec::new(),
hd_far: None,
@@ -5342,7 +5428,13 @@ impl Renderer {
};
let mut pass = hd.shadow_pass(&mut encoder, c);
pass.set_bind_group(0, &self.group, &[]);
- pass.set_pipeline(&self.shadow_terrain);
+ // The near cascade leaves the leaves to the colour map.
+ let tinted = c == 0 && hd::color_shadows_enabled();
+ pass.set_pipeline(if tinted {
+ &self.shadow_terrain_near
+ } else {
+ &self.shadow_terrain
+ });
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
let mut page = None;
@@ -5376,6 +5468,62 @@ impl Renderer {
pass.draw_indexed(first * 6..end * 6, 0, 0..1);
}
}
+ drop(pass);
+ if tinted {
+ // What leaves and glass do to the sunlight in the near box: colours
+ // multiplied, the nearest one's distance in alpha (cleared to white, far).
+ let mut pass = hd.shadow_tint_pass(&mut encoder);
+ pass.set_bind_group(0, &self.group, &[]);
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
+ pass.set_vertex_buffer(1, self.instances.slice(..));
+ pass.set_pipeline(&self.shadow_tint_leaves);
+ let mut page = None;
+ for (k, v) in hd_shadow_visible.iter().enumerate() {
+ let s = self.sections.get(v.slot);
+ let min = Vec3::from(v.origin);
+ if s.solid == 0 || !box_.intersects(min, min + Vec3::splat(16.0)) {
+ continue;
+ }
+ if page != Some(s.page) {
+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..));
+ page = Some(s.page);
+ }
+ let instance = hd_shadow_first + k as u32;
+ pass.draw_indexed(
+ 0..s.solid * 6,
+ s.start as i32,
+ instance..instance + 1,
+ );
+ }
+ pass.set_pipeline(&self.shadow_tint_glass);
+ let mut page = None;
+ for (k, v) in hd_shadow_visible.iter().enumerate() {
+ let s = self.sections.get(v.slot);
+ let min = Vec3::from(v.origin);
+ let Some((index_page, index_start)) = s.sorted_indices else {
+ continue;
+ };
+ if s.translucent == 0 || !box_.intersects(min, min + Vec3::splat(16.0))
+ {
+ continue;
+ }
+ if page != Some((s.page, index_page)) {
+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..));
+ pass.set_index_buffer(
+ self.sorted_indices.buffer(index_page).slice(..),
+ wgpu::IndexFormat::Uint32,
+ );
+ page = Some((s.page, index_page));
+ }
+ let instance = hd_shadow_first + k as u32;
+ let first = index_start as u32;
+ pass.draw_indexed(
+ first..first + s.translucent * 6,
+ (s.start + u64::from(s.solid) * 4) as i32,
+ instance..instance + 1,
+ );
+ }
+ }
}
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
}
@@ -5994,10 +6142,12 @@ impl Renderer {
);
post.run(&self.device, &mut encoder, (hd.lit_view(), depth), &hd.fx);
let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
+ let source = hd.film(&self.device, &mut encoder, source, depth);
hd.finish(&self.device, &mut encoder, source, &view_target);
}
_ => {
let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Lit, depth);
+ let source = hd.film(&self.device, &mut encoder, source, depth);
hd.finish(&self.device, &mut encoder, source, &view_target);
}
}
diff --git a/crates/horizon-client/src/gfx/hd/film.rs b/crates/horizon-client/src/gfx/hd/film.rs
new file mode 100644
index 000000000..89413af41
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/film.rs
@@ -0,0 +1,204 @@
+//! The film camera's motion blur and depth of field. Only while film quality is on (a film is
+//! playing or exported, `set_film_quality`); normal play never runs these passes.
+//!
+//! Contract:
+//! - Runs after `taa`, before bloom: reads the colour source (display space, may exceed 1, output
+//! size) and the world depth (reversed-Z; world size, equal to the output's in film quality),
+//! plus `HdFrame`: `film` (x: motion blur on; y: depth of field on; z: focus distance in blocks,
+//! 0 = automatic; w: depth of field strength), `inverse_view_proj`, `prev_view_proj` and `taa.xyz`
+//! (the camera's move), `taa.w` (the previous frame is usable: not after a cut or a teleport).
+//! - Writes `motion` (`fs_motion`: a blur along the camera's motion on screen, rebuilt from depth;
+//! things that move by themselves are not blurred) and then `dof` (`fs_dof`: a disc gather whose
+//! radius follows the circle of confusion of each pixel's distance from the focus). Either pass
+//! is skipped when its toggle is off; `film` returns the source the next pass reads.
+//! - Deterministic: the only time the shaders read is `HdFrame.water.y`, the film clock's seconds
+//! (`film_clock.rs`), for the sample noise; nothing reads wall time or keeps a history.
+//! - Toggles: `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`; `HORIZON_HD_DOF_FOCUS=<blocks>`
+//! fixes the focus, `HORIZON_HD_DOF_STRENGTH=<number>` scales the blur (default 0.8).
+use super::{
+ DEPTH, FLOAT, HDR, Hd, HdSource, SAMPLER, UNIFORM, entries, film_quality, fullscreen,
+ make_group, module, stamp, target,
+};
+
+pub(super) const FILM: &str = include_str!("film.wgsl");
+/// Group 0 of both passes: frame, the colour, the world depth, the linear sampler.
+pub(super) const FILM_BINDINGS: [wgpu::BindingType; 4] = [UNIFORM, FLOAT, DEPTH, SAMPLER];
+
+fn flag(name: &str) -> bool {
+ std::env::var(name).map_or(true, |v| v != "0")
+}
+
+/// Motion blur is allowed (on, unless `HORIZON_HD_MOTION_BLUR=0`); it still needs film quality.
+pub fn motion_blur_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| flag("HORIZON_HD_MOTION_BLUR"))
+}
+
+/// Depth of field is allowed (on, unless `HORIZON_HD_DOF=0`); it still needs film quality.
+pub fn dof_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| flag("HORIZON_HD_DOF"))
+}
+
+/// The focus distance in blocks (0 = automatic) and the blur strength.
+pub(super) fn dof_settings() -> (f32, f32) {
+ static SETTINGS: std::sync::OnceLock<(f32, f32)> = std::sync::OnceLock::new();
+ *SETTINGS.get_or_init(|| {
+ let read = |name: &str, default: f32| {
+ std::env::var(name)
+ .ok()
+ .and_then(|v| v.parse::<f32>().ok())
+ .filter(|v| v.is_finite() && *v >= 0.0)
+ .unwrap_or(default)
+ };
+ (
+ read("HORIZON_HD_DOF_FOCUS", 0.0),
+ read("HORIZON_HD_DOF_STRENGTH", 0.8),
+ )
+ })
+}
+
+type Key = (wgpu::TextureView, wgpu::TextureView);
+
+pub(super) struct Film {
+ layout: wgpu::BindGroupLayout,
+ motion_pipeline: wgpu::RenderPipeline,
+ dof_pipeline: wgpu::RenderPipeline,
+ pub(super) motion: wgpu::TextureView,
+ pub(super) dof: wgpu::TextureView,
+ motion_group: Option<(Key, wgpu::BindGroup)>,
+ dof_group: Option<(Key, wgpu::BindGroup)>,
+ /// The target the last frame's passes ended in, for `HdSource::Film`.
+ pub(super) result: wgpu::TextureView,
+ /// The targets' size (0 until a film plays: normal play never pays for them).
+ size: [u32; 2],
+}
+
+impl Film {
+ pub(super) fn new(device: &wgpu::Device) -> Self {
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD film"),
+ entries: &entries(&FILM_BINDINGS),
+ });
+ let shader = module(device, "HD film", FILM);
+ let pipeline =
+ |entry: &str| fullscreen(device, "HD film", &layout, &shader, entry, HDR, None);
+ let motion_pipeline = pipeline("fs_motion");
+ let dof_pipeline = pipeline("fs_dof");
+ let motion = target(device, "HD film motion", [1, 1], HDR).view;
+ Self {
+ layout,
+ motion_pipeline,
+ dof_pipeline,
+ dof: target(device, "HD film depth of field", [1, 1], HDR).view,
+ result: motion.clone(),
+ motion,
+ motion_group: None,
+ dof_group: None,
+ size: [0, 0],
+ }
+ }
+
+ /// The output size changed: the targets are made again when a film next needs them.
+ pub(super) fn resize(&mut self) {
+ self.size = [0, 0];
+ }
+
+ /// Output-size targets, made when a film first plays (or after a resize).
+ fn ensure(&mut self, device: &wgpu::Device, out: [u32; 2]) {
+ if self.size == out {
+ return;
+ }
+ self.size = out;
+ self.motion = target(device, "HD film motion", out, HDR).view;
+ self.dof = target(device, "HD film depth of field", out, HDR).view;
+ self.result = self.motion.clone();
+ self.motion_group = None;
+ self.dof_group = None;
+ }
+}
+
+impl Hd {
+ /// Whether the film passes run this frame, and which (motion blur, depth of field).
+ pub(super) fn film_passes(&self) -> (bool, bool) {
+ let on = film_quality();
+ (on && motion_blur_enabled(), on && dof_enabled())
+ }
+
+ /// Motion blur and depth of field over `source` (after TAA) with the world's `depth`; returns
+ /// the source `finish` reads: the same one when film quality is off.
+ pub fn film(
+ &mut self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ source: HdSource,
+ depth: &wgpu::TextureView,
+ ) -> HdSource {
+ let (blur, dof) = self.film_passes();
+ if !blur && !dof {
+ return source;
+ }
+ self.film.ensure(device, self.out);
+ let mut input = match source {
+ HdSource::Lit => self.lit_view().clone(),
+ HdSource::Fx => self.fx.clone(),
+ HdSource::Taa => self.taa.target.clone(),
+ HdSource::Film => return source,
+ };
+ let mut passes: Vec<(&str, bool)> = Vec::new();
+ if blur {
+ passes.push(("hd film motion", true));
+ }
+ if dof {
+ passes.push(("hd film dof", false));
+ }
+ for (name, is_motion) in passes {
+ let key = (input.clone(), depth.clone());
+ let slot = if is_motion {
+ &mut self.film.motion_group
+ } else {
+ &mut self.film.dof_group
+ };
+ if slot.as_ref().is_none_or(|(k, _)| *k != key) {
+ let group = make_group(
+ &self.groups_made,
+ device,
+ "HD film",
+ &self.film.layout,
+ &[
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
+ wgpu::BindingResource::TextureView(&key.0),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::Sampler(&self.sampler),
+ ],
+ );
+ *slot = Some((key, group));
+ }
+ let group = if is_motion {
+ &self.film.motion_group
+ } else {
+ &self.film.dof_group
+ };
+ let Some((_, group)) = group else {
+ return source;
+ };
+ let (to, pipeline) = if is_motion {
+ (&self.film.motion, &self.film.motion_pipeline)
+ } else {
+ (&self.film.dof, &self.film.dof_pipeline)
+ };
+ Self::draw(
+ encoder,
+ name,
+ to,
+ false,
+ pipeline,
+ group,
+ stamp(&mut self.timing, name),
+ );
+ input = to.clone();
+ }
+ self.film.result = input;
+ HdSource::Film
+ }
+}
diff --git a/crates/horizon-client/src/gfx/hd/film.wgsl b/crates/horizon-client/src/gfx/hd/film.wgsl
new file mode 100644
index 000000000..2759513f1
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/film.wgsl
@@ -0,0 +1,139 @@
+// The film camera: motion blur from the camera's own move, then depth of field (see film.rs).
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var source: texture_2d<f32>;
+@group(0) @binding(2) var depth_tex: texture_depth_2d;
+@group(0) @binding(3) var linear: sampler;
+
+// Where the sky counts as being, in blocks.
+const FAR: f32 = 3000.0;
+// The shutter stays open for half the frame (a 180 degree shutter).
+const SHUTTER: f32 = 0.5;
+// The longest blur, and the widest circle of confusion at strength 1, as shares of the screen's height.
+const MAX_BLUR: f32 = 0.045;
+const MAX_COC: f32 = 0.007;
+const MOTION_TAPS: i32 = 14;
+const DOF_TAPS: i32 = 24;
+
+// Noise for the taps: a function of the film clock's time only, so the same film frame gets the
+// same noise whenever and however fast it is rendered.
+fn film_noise(px: vec2<f32>) -> f32 {
+ let step = floor(fract(frame.water.y * 1.618034) * 64.0);
+ let p = px + vec2<f32>(5.588238 * step);
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
+}
+
+fn depth_load(uv: vec2<f32>) -> f32 {
+ let size = vec2<i32>(textureDimensions(depth_tex));
+ return textureLoad(depth_tex, clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - 1), 0);
+}
+
+// The camera-relative point a depth value at uv stands for.
+fn world_at(uv: vec2<f32>, d: f32) -> vec3<f32> {
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, max(d, 0.000001), 1.0);
+ return h.xyz / h.w;
+}
+
+// The depth along the view axis in blocks (the inverse matrix's w row gives 1 / w of the clip
+// position: one dot product, not a whole reconstruction).
+fn planar(uv: vec2<f32>, d: f32) -> f32 {
+ let m = frame.inverse_view_proj;
+ let w = m[0][3] * (uv.x * 2.0 - 1.0) + m[1][3] * (1.0 - uv.y * 2.0) + m[2][3] * d + m[3][3];
+ return select(FAR, clamp(1.0 / w, 0.05, FAR), d > 0.0 && w > 0.0);
+}
+
+fn distance_at(uv: vec2<f32>) -> f32 {
+ return planar(uv, depth_load(uv));
+}
+
+// Motion blur: each pixel is smeared along where the camera moved it on screen during the shutter,
+// found by projecting its point with last frame's matrix. Samples much nearer than the pixel are
+// weighed down, so a close object does not smear over the far scene behind it.
+@fragment
+fn fs_motion(in: FullOut) -> @location(0) vec4<f32> {
+ let uv = in.uv;
+ let base = textureSampleLevel(source, linear, uv, 0.0);
+ if frame.film.x < 0.5 || frame.taa.w < 0.5 { return vec4<f32>(base.rgb, 1.0); }
+ let d = depth_load(uv);
+ let sky = d <= 0.0;
+ let p = world_at(uv, d);
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
+ if prev.w <= 0.0 { return vec4<f32>(base.rgb, 1.0); }
+ let before = vec2<f32>(prev.x / prev.w * 0.5 + 0.5, 0.5 - prev.y / prev.w * 0.5);
+ let size = vec2<f32>(textureDimensions(source));
+ let moved = (uv - before) * size;
+ let len = length(moved);
+ // Under about a pixel is the jitter's noise, not motion.
+ let amount = smoothstep(1.0, 2.5, len);
+ if amount <= 0.0 { return vec4<f32>(base.rgb, 1.0); }
+ let blur = moved * (SHUTTER * amount * min(1.0, MAX_BLUR * size.y / (len * SHUTTER)));
+ let here = planar(uv, d);
+ let n = film_noise(in.position.xy);
+ var sum = base.rgb;
+ var total = 1.0;
+ for (var i = 0; i < MOTION_TAPS; i++) {
+ let t = (f32(i) + n) / f32(MOTION_TAPS) - 0.5;
+ let suv = uv + blur * t / size;
+ let nearer = (here - distance_at(suv)) / (0.25 * here + 1.0);
+ let w = 1.0 - clamp(nearer, 0.0, 1.0);
+ sum += textureSampleLevel(source, linear, suv, 0.0).rgb * w;
+ total += w;
+ }
+ return vec4<f32>(sum / total, 1.0);
+}
+
+// Where the lens is focused, in blocks: the setting, or what the middle of the picture shows (the
+// harmonic mean of nine distances, the middle counting thrice, so near things win).
+fn focus_distance() -> f32 {
+ if frame.film.z > 0.0 { return frame.film.z; }
+ var inverse = 0.0;
+ var weight = 0.0;
+ for (var j = -1; j <= 1; j++) {
+ for (var i = -1; i <= 1; i++) {
+ let uv = vec2<f32>(0.5) + vec2<f32>(f32(i) * 0.012, f32(j) * 0.02);
+ let d = depth_load(uv);
+ if d <= 0.0 { continue; }
+ let w = select(1.0, 3.0, i == 0 && j == 0);
+ inverse += w / planar(uv, d);
+ weight += w;
+ }
+ }
+ return select(400.0, weight / inverse, weight > 0.0);
+}
+
+// The signed circle of confusion in pixels (far positive, near negative): nothing within a few
+// per cent of the focus, then growing as the reciprocal distances differ.
+fn circle_of_confusion(dist: f32, focus: f32, height: f32) -> f32 {
+ let a = 1.0 - focus / max(dist, 0.1);
+ return sign(a) * smoothstep(0.03, 0.5, abs(a)) * frame.film.w * MAX_COC * height;
+}
+
+// Depth of field: a disc gather. Each tap counts if its own circle reaches this pixel; a tap behind
+// a sharper pixel is limited to that pixel's circle, so far blur does not creep over the subject.
+@fragment
+fn fs_dof(in: FullOut) -> @location(0) vec4<f32> {
+ let uv = in.uv;
+ let base = textureSampleLevel(source, linear, uv, 0.0);
+ if frame.film.y < 0.5 { return vec4<f32>(base.rgb, 1.0); }
+ let size = vec2<f32>(textureDimensions(source));
+ let focus = focus_distance();
+ let here = distance_at(uv);
+ let coc = circle_of_confusion(here, focus, size.y);
+ let radius = MAX_COC * size.y * max(frame.film.w, 0.25);
+ let angle = film_noise(in.position.xy) * 6.2831853;
+ var sum = base.rgb / max(coc * coc, 1.0);
+ var total = 1.0 / max(coc * coc, 1.0);
+ for (var i = 0; i < DOF_TAPS; i++) {
+ let r = sqrt((f32(i) + 0.5) / f32(DOF_TAPS)) * radius;
+ let a = f32(i) * 2.39996323 + angle;
+ let suv = uv + vec2<f32>(cos(a), sin(a)) * r / size;
+ let there = distance_at(suv);
+ var reach = abs(circle_of_confusion(there, focus, size.y));
+ if there > here * 1.05 + 0.5 {
+ reach = min(reach, max(abs(coc), 1.0));
+ }
+ let w = clamp(reach - r + 1.0, 0.0, 1.0) / max(reach * reach, 1.0);
+ sum += textureSampleLevel(source, linear, suv, 0.0).rgb * w;
+ total += w;
+ }
+ return vec4<f32>(sum / total, 1.0);
+}
diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs
index a98bbd2b9..d61cbfc4f 100644
--- a/crates/horizon-client/src/gfx/hd/frame.rs
+++ b/crates/horizon-client/src/gfx/hd/frame.rs
@@ -111,7 +111,8 @@ pub(super) struct HdFrame {
/// w: the number of spot lights.
pub(super) vfog: [f32; 4],
/// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when
- /// the specular and the wet look are on.
+ /// the specular and the wet look are on; z: film quality; w: 1 when glass and leaves colour the
+ /// sunlight (`shadows.rs`).
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: 1 when the far grid has smoke (x: the near one has).
@@ -126,6 +127,9 @@ pub(super) struct HdFrame {
/// Up to `MAX_SPOTS` spot lights, four vec4 each (`HdSpot`: pos+range, dir+cos outer,
/// colour+intensity, cone); their number is `vfog.w`.
pub(super) spots: [[f32; 4]; 64],
+ /// The film camera (`film.rs`). x: motion blur on; y: depth of field on; z: focus distance in
+ /// blocks (0 = automatic); w: depth of field strength.
+ pub(super) film: [f32; 4],
}
/// What the world around looks like this frame (from the renderer's globals).
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index bbeb35b86..6505c5c76 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -33,6 +33,8 @@ struct Light {
// 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
+// The near cascade's colour map: glass and leaves (`shadow_tint`).
+@group(0) @binding(22) var tint_map: texture_2d<f32>;
const SKY_RAY: f32 = 20000.0;
const CLOUD_SHADOW_R: f32 = 1200.0;
// The pixel being lit, for the shadow filter's noise.
@@ -140,6 +142,32 @@ fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
}
+// Coloured shadows: the colour the near cascade's glass and leaves give the sunlight that reaches
+// p (white where it is clear). The map (`shadows.rs`) holds, per light-space texel, the colour
+// multiplier of the quads there and the nearest one's distance from the light; a tap counts when
+// that quad is nearer the light than p. A ring of taps turned by the pixel's noise softens the
+// leaves into dappled light; the temporal pass averages it.
+fn shadow_tint(p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
+ let dist = length(p);
+ let reach = frame.shadow.y * 0.92;
+ if frame.surface.w < 0.5 || dist >= reach { return vec3<f32>(1.0); }
+ let texel = frame.shadow.y * 2.0 / 2048.0;
+ let s = cascade_at(p + n * texel * 1.5, 0);
+ if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return vec3<f32>(1.0); }
+ // The receiver's distance in the map's units (see fs_shadow_tint).
+ let local = ((s.z * 799.0 + 1.0) - 320.0) / 160.0;
+ let angle = ign(frag_px) * 6.2831853;
+ var sum = vec3<f32>(0.0);
+ for (var i = 0; i < 8; i++) {
+ let o = vogel(i, 8, angle) * (0.4 / (frame.shadow.y * 2.0));
+ let t = textureSampleLevel(tint_map, linear, s.xy + o, 0.0);
+ // About half a block nearer the light than the receiver counts as in front of it.
+ let in_front = smoothstep(0.0, 0.004, local - t.a - 0.003);
+ sum += mix(vec3<f32>(1.0), t.rgb, in_front);
+ }
+ return mix(vec3<f32>(1.0), sum / 8.0, clamp((reach - dist) / 6.0, 0.0, 1.0));
+}
+
// One shadow sample, for the rays through the air.
fn lit_at(p: vec3<f32>) -> f32 {
let s = cascade_at(p, 1);
@@ -304,7 +332,9 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
// direct light by the face's angle.
- let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
+ var sun_rgb = frame.sun_color.rgb;
+ if s > 0.0 && !water { sun_rgb *= shadow_tint(p, n); }
+ let direct = sun_rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
// Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
// the mods' lights by half, not on the sun or on emission.
var occlusion = 1.0;
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
index 23e16abfd..8d0cf4f0d 100644
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
@@ -8,7 +8,8 @@
//! 7 light levels; 8-9 shadow cascades and comparison sampler; 10-11 lightmap and sampler;
//! 12 the light tiles; 13 AO (half size); 14 the water depth; 15 the sky texture; 16-17 the
//! volumetric fog and its ray length (half size); 18-19 the volumetric clouds and the clouds'
-//! shadow map.
+//! shadow map; 21 the cloud panorama; 22 the near cascade's colour map (glass and leaves,
+//! `shadows.rs`).
//! - Writes two targets (`Rgba16Float`, display space, may exceed 1):
//! - `lit`: rgb the lit colour; **alpha is a marker**: 1 an ordinary pixel, **0.5 water** (rgb is
//! the *floor* under it, lit without any air), **0.6 something translucent in front of the
@@ -34,7 +35,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; 22] = [
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 23] = [
UNIFORM,
UNFILTERED,
DEPTH,
@@ -57,6 +58,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 22] = [
FLOAT,
UNFILTERED,
FLOAT,
+ FLOAT,
];
/// The outside views and sampler the lighting group reads.
@@ -225,6 +227,7 @@ impl Hd {
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
wgpu::BindingResource::TextureView(self.volumes.pano_view()),
+ wgpu::BindingResource::TextureView(&self.shadows.tint),
];
let entries: Vec<_> = resources
.into_iter()
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
index f7a64e7a4..d8c015a28 100644
--- a/crates/horizon-client/src/gfx/hd/mod.rs
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
@@ -32,6 +32,7 @@
//! | 4a | `water_composite` | `lit`, `air`, G-buffer depth, water targets, shadows, sky | `lit_water` |
//! | 5 | mod post pass (caller) | `lit_view()` + depth | `fx` |
//! | 6 | `taa` | `lit_view()` or `fx`, world depth, history | `taa` + next history (out) |
+//! | 6a | `film` (film quality only) | the colour source, world depth | motion blur, then depth of field (out) |
//! | 7 | `finish` | colour source (`HdSource`) | bloom chain, then the screen |
//!
//! - **Colour.** Everything between the lighting and the tone curve is in vanilla's display space
@@ -61,6 +62,7 @@
mod ao;
mod bloom;
+mod film;
mod frame;
mod gbuffer;
mod ground;
@@ -79,6 +81,7 @@ mod water;
use crate::gfx::pack_timing::PassTiming;
use ao::Ao;
use bloom::Bloom;
+use film::Film;
use gbuffer::GBuffer;
use lighting::Lighting;
use shadows::Shadows;
@@ -93,7 +96,7 @@ use water::Water;
pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanilla_cloud_tint};
pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL};
pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds};
-pub use shadows::{CASCADES, cascades};
+pub use shadows::{CASCADES, SHADOW_TINT, cascades, color_shadows_enabled};
pub use smoke::{HdSmoke, smoke_enabled};
pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled};
pub use volumes::clouds_enabled;
@@ -148,6 +151,8 @@ pub enum HdSource {
Fx,
/// The anti-aliased result of `taa`.
Taa,
+ /// The film camera's motion blur and depth of field over that (`film`).
+ Film,
}
/// The timestamps of the pass `name` while HD passes are timed.
@@ -389,6 +394,7 @@ pub struct Hd {
/// The surfaces' wetness and when it was last updated (see `wetness`).
wet: (f32, std::time::Instant),
taa: Taa,
+ film: Film,
bloom: Bloom,
tonemap: Tonemap,
/// The volumetric clouds and fog (see `volumes`).
@@ -457,6 +463,7 @@ impl Hd {
wet: (0.0, std::time::Instant::now()),
started: std::time::Instant::now(),
taa: Taa::new(device),
+ film: Film::new(device),
bloom: Bloom::new(device),
tonemap: Tonemap::new(device, screen),
volumes: HdVolumes::new(device, queue),
@@ -486,6 +493,7 @@ impl Hd {
self.fx = target(device, "HD post", size, HDR).view;
self.water.resize(device, size);
self.taa.resize(device, out);
+ self.film.resize();
self.ao
.resize(device, size, &self.frame, &self.gbuffer, &self.groups_made);
// Every group names a view that was just replaced.
@@ -598,7 +606,12 @@ impl Hd {
scene.sky_fog_end,
0.0,
(scene.rain / 0.7).clamp(0.0, 1.0) * 0.85,
- 0.0,
+ // Rain ripples on the water: the rain level, 0 when off or dry.
+ if scene.sky && water::ripples_enabled() {
+ (scene.rain / 0.7).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ },
],
sunrise: scene.sunrise,
clouds: [
@@ -637,6 +650,16 @@ impl Hd {
smoke_min: self.smoke.frame.min,
smoke_far: self.smoke.frame.far,
smoke_lights: self.smoke.frame.lights,
+ film: {
+ let (blur, dof) = self.film_passes();
+ let (focus, strength) = film::dof_settings();
+ [
+ if blur { 1.0 } else { 0.0 },
+ if dof { 1.0 } else { 0.0 },
+ focus,
+ strength,
+ ]
+ },
spots: {
let mut all = [[0.0; 4]; 64];
for (i, s) in self.spots.iter().enumerate() {
@@ -653,7 +676,8 @@ impl Hd {
},
// Film quality: more steps for the clouds and the fog.
if film_quality() { 1.0 } else { 0.0 },
- 0.0,
+ // Coloured shadows: the lighting reads the near cascade's colour map.
+ if color_shadows_enabled() { 1.0 } else { 0.0 },
],
taa_params: [
if self.upscaling() {
@@ -836,6 +860,7 @@ mod tests {
("ao blur", ao::AO_BLUR.to_owned(), &ao::AO_BLUR_BINDINGS),
("water", water::WATER.to_owned(), &water::WATER_BINDINGS),
("sky", sky::SKY_PASS.to_owned(), &sky::SKY_BINDINGS),
+ ("film", film::FILM.to_owned(), &film::FILM_BINDINGS),
];
all.extend(volumes::shader_sources());
all.extend(smoke::shader_sources());
diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl
index 6075cbb0b..11e4bcb31 100644
--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl
+++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl
@@ -30,6 +30,7 @@ struct HdFrame {
smoke_far: vec4<f32>,
smoke_lights: array<vec4<f32>, 32>,
spots: array<vec4<f32>, 64>,
+ film: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
diff --git a/crates/horizon-client/src/gfx/hd/shadows.rs b/crates/horizon-client/src/gfx/hd/shadows.rs
index daa34b3c6..e3340c17c 100644
--- a/crates/horizon-client/src/gfx/hd/shadows.rs
+++ b/crates/horizon-client/src/gfx/hd/shadows.rs
@@ -9,6 +9,12 @@
//! (comparison sampler, `LessEqual`).
//! - Space: `cascades()` returns camera-relative position to the light's clip space; each
//! cascade's square is snapped to its texels in the world, so shadows do not crawl.
+//! - Coloured shadows (`HORIZON_HD_COLOR_SHADOW`, on by default): the near cascade's depth leaves the
+//! leaves out; `shadow_tint_pass` writes `Shadows::tint` (`SHADOW_TINT`, `TINT_SIZE` squared, the
+//! same light matrix): rgb the sunlight's multiplier after glass and leaves (cleared to 1), alpha
+//! the nearest such quad's distance from the light, `(distance - 320) / 160` (cleared to 1; the
+//! light sits 400 blocks from the cascade's middle). `fs_light` tints a surface lit by the sun
+//! where that distance is nearer to the light than the surface's own (`shadow_tint`).
//! - Readers: lighting (`fs_light`, bindings 8-9), water composite (6-7) and the volumetric fog.
//! - The light is the sun by day and the moon at night (`frame::light_of`).
use super::{Hd, stamp};
@@ -17,11 +23,22 @@ use glam::Mat4;
/// The cascades' half sizes (blocks) and shadow map size.
pub const CASCADES: [f32; 2] = [40.0, 176.0];
pub const SHADOW_SIZE: u32 = 2048;
+/// The near cascade's colour map: its format and size.
+pub const SHADOW_TINT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
+pub const TINT_SIZE: u32 = 1024;
+
+/// Glass and leaves colour the sunlight unless `HORIZON_HD_COLOR_SHADOW=0`.
+pub fn color_shadows_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_COLOR_SHADOW").map_or(true, |v| v != "0"))
+}
/// The cascade array, a view per layer to draw, and the comparison sampler.
pub(super) struct Shadows {
pub(super) array: wgpu::TextureView,
layers: Vec<wgpu::TextureView>,
+ /// The near cascade's colour map (see the contract).
+ pub(super) tint: wgpu::TextureView,
pub(super) sampler: wgpu::Sampler,
}
@@ -62,9 +79,11 @@ impl Shadows {
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
});
+ let tint = super::target(device, "HD shadow colour", [TINT_SIZE; 2], SHADOW_TINT).view;
Self {
array,
layers,
+ tint,
sampler,
}
}
@@ -96,6 +115,31 @@ impl Hd {
}
}
+impl Hd {
+ /// The colour map's pass: cleared to white, far; the caller draws the leaves and the glass.
+ pub fn shadow_tint_pass<'e>(
+ &mut self,
+ encoder: &'e mut wgpu::CommandEncoder,
+ ) -> wgpu::RenderPass<'e> {
+ encoder
+ .begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("HD shadow colour"),
+ timestamp_writes: stamp(&mut self.timing, "hd shadow colour"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &self.shadows.tint,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ })
+ .forget_lifetime()
+ }
+}
+
/// The cascades' light view-projections (camera-relative), each square snapped to its texels in
/// the world so the shadows don't crawl as the camera moves.
pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
diff --git a/crates/horizon-client/src/gfx/hd/taa.rs b/crates/horizon-client/src/gfx/hd/taa.rs
index 2b758cec6..6083103c8 100644
--- a/crates/horizon-client/src/gfx/hd/taa.rs
+++ b/crates/horizon-client/src/gfx/hd/taa.rs
@@ -68,7 +68,10 @@ pub fn set_film_quality(on: bool) {
/// Whether film quality is on.
pub fn film_quality() -> bool {
- FILM.load(std::sync::atomic::Ordering::Relaxed)
+ // `HORIZON_HD_FILM_QUALITY=1` forces it on, to try the film passes without a film.
+ static FORCED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *FORCED.get_or_init(|| std::env::var("HORIZON_HD_FILM_QUALITY").is_ok_and(|v| v == "1"))
+ || FILM.load(std::sync::atomic::Ordering::Relaxed)
}
/// How many jitter positions the sequence cycles through for an output `ratio` times the world's
@@ -191,7 +194,7 @@ impl Hd {
let input = match source {
HdSource::Lit => self.lit_view().clone(),
HdSource::Fx => self.fx.clone(),
- HdSource::Taa => return source,
+ HdSource::Taa | HdSource::Film => return source,
};
let key = (input, depth.clone());
if self.taa.groups.as_ref().is_none_or(|(k, _)| *k != key) {
diff --git a/crates/horizon-client/src/gfx/hd/tonemap.rs b/crates/horizon-client/src/gfx/hd/tonemap.rs
index da79c43da..e20c04ac9 100644
--- a/crates/horizon-client/src/gfx/hd/tonemap.rs
+++ b/crates/horizon-client/src/gfx/hd/tonemap.rs
@@ -63,6 +63,7 @@ impl Hd {
HdSource::Lit => self.lit_view().clone(),
HdSource::Fx => self.fx.clone(),
HdSource::Taa => self.taa.target.clone(),
+ HdSource::Film => self.film.result.clone(),
};
if self
.bloom
diff --git a/crates/horizon-client/src/gfx/hd/water.rs b/crates/horizon-client/src/gfx/hd/water.rs
index 403892348..41ce7a195 100644
--- a/crates/horizon-client/src/gfx/hd/water.rs
+++ b/crates/horizon-client/src/gfx/hd/water.rs
@@ -13,7 +13,7 @@
//! front of the water: the lit floor rgb), the G-buffer depth, `tint`, `normal`, `depth`, the
//! shadow cascades, the lightmap and the sky texture. Writes `composite` (`Rgba16Float`); `lit_view`
//! is that target afterwards.
-//! - Time: the waves use `HdFrame.water.y`, wall-clock seconds since HD started modulo 600, and the
+//! - Time: the waves use `HdFrame.water.y`, film-clock seconds since HD started modulo 600, and the
//! camera's x/z modulo 256 (`water.zw`) so they stand still in the world (period 256 blocks,
//! whole cycles per 600 s). It is not game time: a replay paused in game time still has moving
//! water.
@@ -53,6 +53,12 @@ pub fn water_enabled() -> bool {
*ON.get_or_init(|| std::env::var("HORIZON_HD_WATER").map_or(true, |v| v != "0"))
}
+/// Rain makes ripples on the water unless `HORIZON_HD_RAIN_RIPPLES=0`.
+pub fn ripples_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_RAIN_RIPPLES").map_or(true, |v| v != "0"))
+}
+
pub(super) struct Water {
tint: wgpu::TextureView,
normal: wgpu::TextureView,
diff --git a/crates/horizon-client/src/gfx/hd/water.wgsl b/crates/horizon-client/src/gfx/hd/water.wgsl
index 69e295980..4f3de9a4e 100644
--- a/crates/horizon-client/src/gfx/hd/water.wgsl
+++ b/crates/horizon-client/src/gfx/hd/water.wgsl
@@ -97,6 +97,49 @@ fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
return grad * 1.25;
}
+fn hash21(p: vec2<f32>) -> f32 {
+ var q = fract(p * vec2<f32>(0.1031, 0.1030));
+ q += dot(q, q.yx + 33.33);
+ return fract((q.x + q.y) * q.x);
+}
+
+// Rain ripples: a drop falls in some cells of a 1.6-block grid (160 per 256 blocks, so the pattern
+// repeats with the waves), and its ring widens and fades over about a second. The cell's drop
+// repeats every 600/n seconds with n a whole number of cycles per 600 s, so water time wrapping at
+// 600 does not pop. `rain` (0..1) sets how many cells have a drop; `footprint` fades rings the
+// pixel cannot resolve. Returns the surface's slope.
+fn ripple_gradient(xz: vec2<f32>, rain: f32, footprint: f32) -> vec2<f32> {
+ let fade = clamp(1.0 - footprint * 6.0, 0.0, 1.0);
+ if rain <= 0.0 || fade <= 0.0 { return vec2<f32>(0.0); }
+ let cell_size = 1.6;
+ let cell = floor(xz / cell_size);
+ var grad = vec2<f32>(0.0);
+ for (var j = -1; j <= 1; j++) {
+ for (var i = -1; i <= 1; i++) {
+ let c = cell + vec2<f32>(f32(i), f32(j));
+ let id = c - 160.0 * floor(c / 160.0);
+ let h0 = hash21(id);
+ let h1 = hash21(id + vec2<f32>(37.0, 91.0));
+ let h2 = hash21(id + vec2<f32>(11.0, 53.0));
+ let n = 600.0 + 150.0 * floor(h1 * 3.0);
+ let x = frame.water.y * (n / 600.0) + h0;
+ let t = fract(x);
+ let cycle = x - t - n * floor((x - t) / n);
+ if hash21(id + vec2<f32>(cycle * 0.37, cycle * 0.11)) >= rain * 0.8 { continue; }
+ let centre = (c + vec2<f32>(0.2 + 0.6 * h1, 0.2 + 0.6 * h2)) * cell_size;
+ let to = xz - centre;
+ let d = max(length(to), 0.0001);
+ let width = 0.1;
+ let u = (d - t * 0.9) / width;
+ // d/dd of cos(2.4 u) * exp(-u^2 / 2), scaled by how young the ring is.
+ let slope = -(2.4 * sin(2.4 * u) + u * cos(2.4 * u)) * exp(-0.5 * u * u) / width;
+ let young = (1.0 - t) * (1.0 - t);
+ grad += (to / d) * (slope * 0.012 * young);
+ }
+ }
+ return grad * fade;
+}
+
fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
let s = frame.cascades[c] * vec4<f32>(p, 1.0);
return vec3<f32>(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5, s.z);
@@ -213,7 +256,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
let footprint = dist * frame.resolution.w * 1.4;
let grad = wave_gradient(p.xz + frame.water.zw, footprint);
let flat_part = smoothstep(0.6, 0.95, geometric.y);
- var n = normalize(geometric + vec3<f32>(-grad.x, 0.0, -grad.y) * flat_part);
+ // Rain: rings on the water that is open to the sky.
+ let ripples = ripple_gradient(p.xz + frame.water.zw, frame.sky_extra.w, footprint) * smoothstep(0.7, 0.95, nt.a);
+ var n = normalize(geometric + vec3<f32>(-(grad.x + ripples.x), 0.0, -(grad.y + ripples.y)) * flat_part);
n = normalize(n + v * max(0.03 - dot(n, v), 0.0));
let ndv = max(dot(n, v), 0.02);
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index de96226e9..bfd0830ad 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -273,6 +273,35 @@ struct WaterOut {
let a = sprite(in.uv, in.layer & 0x7fffu).a;
if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
}
+// AirDef fork, coloured shadows: the near cascade's depth map without the leaves (they are
+// filtered by `fs_shadow_tint` instead, so their shade is soft and green, not a hard cut-out).
+@fragment fn fs_shadow_near(in: TerrainOut) {
+ if material_class(in.layer) == MAT_LEAVES { discard; }
+ let a = sprite(in.uv, in.layer & 0x7fffu).a;
+ if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
+}
+// The colour map of the near cascade: what the sunlight is multiplied by after glass (translucent
+// quads) or a leaf (solid quads, SHADOW_SOLID), and in alpha the nearest such quad's distance from
+// the light, remapped to the cascade's middle +-80 blocks (0..1) so half floats hold it finely.
+// Blending multiplies the colours and keeps the smallest alpha. Water is left to the HD water.
+override SHADOW_SOLID: bool = false;
+@fragment fn fs_shadow_tint(in: TerrainOut) -> @location(0) vec4<f32> {
+ let layer = in.layer & 0x7fffu;
+ let s = sprite(in.uv, layer);
+ var t: vec3<f32>;
+ if SHADOW_SOLID {
+ if material_class(in.layer) != MAT_LEAVES || s.a < 0.5 { discard; }
+ // A leaf lets a little of its own green through.
+ let c = max(s.rgb * in.tint.rgb, vec3(0.02));
+ t = 0.55 * pow(c / max(max(c.r, c.g), c.b), vec3(0.8));
+ } else {
+ if layer == WATER_STILL || layer == WATER_FLOW || s.a < 0.05 { discard; }
+ // Stained glass filters the light by its colour as much as it is opaque.
+ t = pow(max(s.rgb * in.tint.rgb, vec3(0.02)), vec3(s.a * 0.9));
+ }
+ let dist = in.clip.z * 799.0 + 1.0;
+ return vec4(t, clamp((dist - 320.0) / 160.0, 0.0, 1.0));
+}
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.01 { discard; }
--
2.54.0 (Apple Git-157)