From 288bfb66a5bb04690f896436eb08e2986a8cfc96 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 Subject: [PATCH] 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 --- 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, /// 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=` +//! fixes the focus, `HORIZON_HD_DOF_STRENGTH=` 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 = 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 = 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::().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 frame: HdFrame; +@group(0) @binding(1) var source: texture_2d; +@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 { + let step = floor(fract(frame.water.y * 1.618034) * 64.0); + let p = px + vec2(5.588238 * step); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +fn depth_load(uv: vec2) -> f32 { + let size = vec2(textureDimensions(depth_tex)); + return textureLoad(depth_tex, clamp(vec2(uv * vec2(size)), vec2(0), size - 1), 0); +} + +// The camera-relative point a depth value at uv stands for. +fn world_at(uv: vec2, d: f32) -> vec3 { + let h = frame.inverse_view_proj * vec4(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, 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 { + 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 { + 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(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(select(p + frame.taa.xyz, p, sky), 1.0); + if prev.w <= 0.0 { return vec4(base.rgb, 1.0); } + let before = vec2(prev.x / prev.w * 0.5 + 0.5, 0.5 - prev.y / prev.w * 0.5); + let size = vec2(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(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(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(0.5) + vec2(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 { + let uv = in.uv; + let base = textureSampleLevel(source, linear, uv, 0.0); + if frame.film.y < 0.5 { return vec4(base.rgb, 1.0); } + let size = vec2(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(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(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; //PANO21 +// The near cascade's colour map: glass and leaves (`shadow_tint`). +@group(0) @binding(22) var tint_map: texture_2d; 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, n: vec3, 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, n: vec3) -> vec3 { + let dist = length(p); + let reach = frame.shadow.y * 0.92; + if frame.surface.w < 0.5 || dist >= reach { return vec3(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(0.0)) || any(s.xy > vec2(1.0)) || s.z > 1.0 { return vec3(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(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(1.0), t.rgb, in_front); + } + return mix(vec3(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 { let s = cascade_at(p, 1); @@ -304,7 +332,9 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { 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, smoke_lights: array, 32>, spots: array, 64>, + film: vec4, } struct FullOut { @builtin(position) position: vec4, 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 = 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, + /// 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 = 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 = 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, footprint: f32) -> vec2 { return grad * 1.25; } +fn hash21(p: vec2) -> f32 { + var q = fract(p * vec2(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, rain: f32, footprint: f32) -> vec2 { + let fade = clamp(1.0 - footprint * 6.0, 0.0, 1.0); + if rain <= 0.0 || fade <= 0.0 { return vec2(0.0); } + let cell_size = 1.6; + let cell = floor(xz / cell_size); + var grad = vec2(0.0); + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + let c = cell + vec2(f32(i), f32(j)); + let id = c - 160.0 * floor(c / 160.0); + let h0 = hash21(id); + let h1 = hash21(id + vec2(37.0, 91.0)); + let h2 = hash21(id + vec2(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(cycle * 0.37, cycle * 0.11)) >= rain * 0.8 { continue; } + let centre = (c + vec2(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, c: i32) -> vec3 { let s = frame.cascades[c] * vec4(p, 1.0); return vec3(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 { 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(-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(-(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 { + let layer = in.layer & 0x7fffu; + let s = sprite(in.uv, layer); + var t: vec3; + 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 { let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; if color.a < 0.01 { discard; }