diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2130d8d88..f21b3dcdd 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1838,6 +1838,8 @@ struct Game<'a> { events: events::Events, /// The overworld's last `sky_darken` and its inputs (time, rain, thunder). sky_darken_memo: std::cell::Cell>, + /// The same for the time a mod shows (a replay's): render only. + shown_darken_memo: std::cell::Cell>, persist: persist::EntityMemory, /// Connected to a server: the world comes from the network (game/remote.rs). remote: Option, @@ -8020,7 +8022,7 @@ impl Game<'_> { .flatten(); if let Some(sprite) = shadow_sprite { let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 }; - let darken = self.sky_darken(); + let darken = self.shown_sky_darken(); let a = alpha; let lerp = |p: Vec3, q: Vec3| { glam::DVec3::new( @@ -8378,33 +8380,43 @@ impl Game<'_> { .map_or(self.world.day_time, |t| t as i64) } + /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always + /// the world's real time, never a mod's render-only override. fn sky_darken(&self) -> i32 { + self.sky_darken_at(self.world.day_time, &self.sky_darken_memo) + } + + /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's + /// time override, so a replay's night looks dark without touching the rules. + fn shown_sky_darken(&self) -> i32 { + self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo) + } + + fn sky_darken_at( + &self, + time: i64, + memo: &std::cell::Cell>, + ) -> i32 { if self.dim == Dim::Nether { return 11; } if self.dim == Dim::End { - let sky = self - .res - .end_environment() - .sample(self.shown_day_time(), None, None); + let sky = self.res.end_environment().sample(time, None, None); return (15.0 - 15.0 * sky.sky_light_level).round() as i32; } let (rain, thunder) = self.world.weather.levels(1.0); // Sampled once per (time, weather): every mob asks each tick. - let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits()); - if let Some((k, darken)) = self.sky_darken_memo.get() + let key = (time, rain.to_bits(), thunder.to_bits()); + if let Some((k, darken)) = memo.get() && k == key { return darken; } - let mut sky = self - .res - .environment() - .sample(self.shown_day_time(), None, None); + let mut sky = self.res.environment().sample(time, None, None); horizon_client::environment::apply_weather(&mut sky, rain, thunder); // Level.updateSkyBrightness: darkening from the sky light level. let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32; - self.sky_darken_memo.set(Some((key, darken))); + memo.set(Some((key, darken))); darken } fn mob_hit(&self) -> Option<(usize, f64)> { @@ -13954,6 +13966,7 @@ impl<'a> Game<'a> { mods: Default::default(), native_shaders: Default::default(), sky_darken_memo: Default::default(), + shown_darken_memo: Default::default(), persist: Default::default(), remote: None, lan: None, diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 6807d7718..9912970a5 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -241,7 +241,7 @@ impl Game<'_> { eye[2].floor() as i32, ]; let packed = self.world.light(b[0], b[1], b[2]); - let sky = i32::from(packed >> 4) - self.sky_darken(); + let sky = i32::from(packed >> 4) - self.shown_sky_darken(); let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8; lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw) } diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl index fad234a46..39a239f4d 100644 --- a/crates/horizon-client/src/gfx/hd/cloud.wgsl +++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl @@ -1,6 +1,17 @@ @group(0) @binding(6) var history: texture_2d; @group(0) @binding(7) var smoke_tex: texture_2d; +@group(0) @binding(8) var scene_depth: texture_depth_2d; +@group(0) @binding(9) var history_dist: texture_2d; + +// What the sky pixels report as the length of their ray (nothing stops them). +const SKY_LEN: f32 = 20000.0; + +struct CloudOut { + @location(0) cloud: vec4, + // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky. + @location(1) dist: vec4, +} fn ign(px: vec2) -> f32 { let p = px + vec2(5.588238 * frame.params.w); @@ -65,7 +76,26 @@ fn cloud_scatter( } @fragment -fn fs_cloud(in: FullOut) -> @location(0) vec4 { +fn fs_cloud(in: FullOut) -> CloudOut { + // The nearest of the four world pixels this texel stands for: the march ends at the surface + // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front + // and what stands behind it is hidden by it. + let size = vec2(frame.resolution.xy); + let base = vec2(in.position.xy) * 2; + var zmax = 0.0; + var best = base; + for (var k = 0; k < 4; k++) { + let q = min(base + vec2(k & 1, k >> 1), size - 1); + let z = textureLoad(scene_depth, q, 0); + if z > zmax { + zmax = z; + best = q; + } + } + let suv = (vec2(best) + 0.5) * frame.resolution.zw; + let sndc = vec4(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0); + let sh = frame.inverse_view_proj * sndc; + let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0); let ndc = vec4(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); @@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4 { var hit_t = 900.0; let y0 = frame.clouds.x; let y1 = y0 + frame.clouds.y; - if abs(dir.y) > 0.004 { - let ta = y0 / dir.y; - let tb = y1 / dir.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), 4200.0); + let hi = min(max(ta, tb), min(4200.0, scene_len)); if hi > lo { let dither = ign(in.position.xy); let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26); @@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4 { cur = vec4(colour, trans); } } + var out: CloudOut; + out.dist = vec4(scene_len, 0.0, 0.0, 0.0); // Reproject the history by where the clouds are (rotation and the camera's step) and blend. if frame.cloud_cam.w > 0.5 { let hp = frame.prev_view_proj * vec4(dir * hit_t + frame.taa.xyz, 1.0); @@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4 { let huv = vec2(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5); if all(huv > vec2(0.0)) && all(huv < vec2(1.0)) { let old = textureSampleLevel(history, linear, huv, 0.0); + // A history that was marched to another surface (an edge, something that moved in + // front) does not describe this ray. + let hs = vec2(textureDimensions(history_dist)); + let old_len = textureLoad(history_dist, vec2(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). let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a); - let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)); + let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len; cur = mix(cur, old, keep); } } } - return cur; + out.cloud = cur; + return out; } diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl index e2352f008..8ee014420 100644 --- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl +++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl @@ -44,7 +44,8 @@ fn cloud_density(pos: vec3, detail: f32) -> f32 { d *= smoothstep(0.0, 0.2, h); if d < 0.002 { return 0.0; } if detail > 0.0 { - let p3 = vec3(w.x, pos.y, w.y); + // Height above the layer's base, not the camera's height: the noise stays put in the world. + let p3 = vec3(w.x, pos.y - frame.clouds.x, w.y); let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4; let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3(warp), 0.0).r; let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3(warp * 3.0), 0.0).g; diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl index 38b9098e3..d035bbf0d 100644 --- a/crates/horizon-client/src/gfx/hd/fog.wgsl +++ b/crates/horizon-client/src/gfx/hd/fog.wgsl @@ -40,7 +40,7 @@ fn sun_visible(p: vec3) -> 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; none +// 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. fn fog_sigma(p: vec3) -> f32 { let wy = p.y + frame.vfog.y; @@ -50,7 +50,7 @@ fn fog_sigma(p: vec3) -> f32 { s += frame.sky_extra.z * 0.003; let w = vec3(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y) + vec3(0.5, 0.0, 0.35) * frame.clouds.w; - let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b; + 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); @@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3, core: f32) -> f32 { let w = vec3(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y) - vec3(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z; let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r; - let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g; + let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g; let n = 0.55 * n1 + 0.45 * n2; // Thin smoke breaks up into wisps, thick smoke stays whole. let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12); diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl index 2534a3db8..870ae0d7d 100644 --- a/crates/horizon-client/src/gfx/hd/light.wgsl +++ b/crates/horizon-client/src/gfx/hd/light.wgsl @@ -30,6 +30,9 @@ struct Light { @group(0) @binding(17) var fog_dist_tex: texture_2d; @group(0) @binding(18) var cloud_tex: texture_2d; @group(0) @binding(19) var cloud_shadow_tex: texture_2d; +// 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; +const SKY_RAY: f32 = 20000.0; const CLOUD_SHADOW_R: f32 = 1200.0; // The pixel being lit, for the shadow filter's noise. var frag_px: vec2; @@ -194,6 +197,34 @@ fn fog_at(px: vec2, dist: f32) -> vec4 { return sum / total; } +// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four +// nearest half-resolution texels weighted by closeness and by how near the length they were +// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it. +fn cloud_at(px: vec2, dist: f32) -> vec4 { + let size = vec2(textureDimensions(cloud_tex)); + let f = vec2(px) * 0.5 - vec2(0.25); + let base = vec2(floor(f)); + let t = f - floor(f); + var sum = vec4(0.0); + var total = 0.0; + for (var j = 0; j < 2; j++) { + for (var i = 0; i < 2; i++) { + let q = clamp(base + vec2(i, j), vec2(0), size - 1); + let s = textureLoad(cloud_tex, q, 0); + let sd = textureLoad(cloud_dist_tex, q, 0).x; + let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1); + let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4); + let w = bw / (1.0 + dz * dz * dz * dz); + sum += s * w; + total += w; + } + } + if total < 0.0001 { + return textureLoad(cloud_tex, clamp(px / 2, vec2(0), size - 1), 0); + } + return sum / total; +} + struct LightOut { // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an // object in front of the water (the scene as drawn). @@ -246,7 +277,7 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { let flat_up = smoothstep(0.9, 0.98, n.y); let soak = wet * open_sky * (0.35 + 0.65 * flat_up); let wp = frame.cloud_cam.xy + p.xz; - let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2(17.0, 5.0)); + let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2(17.0, 5.0)); puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up; surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle); rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal)); @@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut { c = sky + kept; // The volumetric clouds over the sky and the sun. if frame.clouds.z > 0.5 { - let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0); + let cl = cloud_at(px, SKY_RAY); c = c * cl.a + cl.rgb; } } @@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut { air_t = fg.a; air_s = fg.rgb; } + // The clouds between the camera and the surface (inside the layer, or in front of a mountain + // or a flying thing): laid over the surface and under the fog, as the sky's are. + if d > 0.0 && atmosphere && frame.clouds.z > 0.5 { + let cl = cloud_at(px, length(ps)); + air_s += air_t * cl.rgb; + air_t *= cl.a; + } if !volumetric && sun_on > 0.0 && density > 0.0 { let steps = 14; let jitter = ign(in.position.xy); diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs index da56d00ba..d26f1e513 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; 20] = [ +pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [ UNIFORM, UNFILTERED, DEPTH, @@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [ UNFILTERED, FLOAT, FLOAT, + UNFILTERED, ]; /// The outside views and sampler the lighting group reads. @@ -219,6 +220,7 @@ impl Hd { wgpu::BindingResource::TextureView(&self.volumes.fog_dist), wgpu::BindingResource::TextureView(self.volumes.cloud_view()), wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow), + wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()), ]; let entries: Vec<_> = resources .into_iter() diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs index a338d756c..d3b55e2f2 100644 --- a/crates/horizon-client/src/gfx/hd/volumes.rs +++ b/crates/horizon-client/src/gfx/hd/volumes.rs @@ -26,8 +26,8 @@ use super::noise as hd_noise; use super::{ - DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module, - stamp, stamped_pass, target, + DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries, + fullscreen, module, stamp, stamped_pass, target, }; pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -86,6 +86,9 @@ pub struct HdVolumes { shadow_sampler: wgpu::Sampler, /// The clouds' half-resolution target and its history (read one, write the other). pub clouds: [wgpu::TextureView; 2], + /// How far each cloud texel's ray was marched (ping-pong with `clouds`). + cloud_dist: [wgpu::TextureView; 2], + cloud_depth: Option, flip: usize, history_ready: bool, pub cloud_shadow: wgpu::TextureView, @@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture { }; /// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky -/// texture, the previous cloud frame. -pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] = - [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT]; +/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths. +pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [ + UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED, +]; /// `fs_cloud_shadow`'s group 0: as the clouds' without the history. pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] = [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT]; @@ -218,15 +222,40 @@ impl HdVolumes { let cloud_module = module(device, "HD clouds", &cloud_src); let shadow_module = module(device, "HD cloud shadows", &shadow_src); let fog_module = module(device, "HD fog", &fog_body()); - let cloud_pipeline = fullscreen( - device, - "HD clouds", - &cloud_layout, - &cloud_module, - "fs_cloud", - CLOUD_FORMAT, - None, - ); + let cloud_pipeline = { + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("HD clouds"), + bind_group_layouts: &[Some(&cloud_layout)], + ..Default::default() + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD clouds"), + 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"), + compilation_options: Default::default(), + targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| { + Some(wgpu::ColorTargetState { + 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", @@ -286,6 +315,11 @@ impl HdVolumes { one("HD clouds", [1, 1], CLOUD_FORMAT), one("HD clouds", [1, 1], CLOUD_FORMAT), ], + cloud_dist: [ + one("HD cloud distance", [1, 1], FOG_DIST_FORMAT), + one("HD cloud distance", [1, 1], FOG_DIST_FORMAT), + ], + cloud_depth: None, flip: 0, history_ready: false, cloud_shadow: one( @@ -316,6 +350,10 @@ impl HdVolumes { one("HD clouds", CLOUD_FORMAT), one("HD clouds", CLOUD_FORMAT), ]; + self.cloud_dist = [ + one("HD cloud distance", FOG_DIST_FORMAT), + one("HD cloud distance", FOG_DIST_FORMAT), + ]; self.fog = one("HD fog", FOG_FORMAT); self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT); self.history_ready = false; @@ -342,6 +380,11 @@ impl HdVolumes { &self.clouds[self.flip] } + /// The march lengths that go with `cloud_view`. + pub fn cloud_dist_view(&self) -> &wgpu::TextureView { + &self.cloud_dist[self.flip] + } + fn group( &mut self, device: &wgpu::Device, @@ -372,7 +415,12 @@ impl HdVolumes { frame: &wgpu::Buffer, sky_lut: &wgpu::TextureView, atlas: &wgpu::TextureView, + depth: &wgpu::TextureView, ) { + if self.cloud_depth.as_ref() != Some(depth) { + self.cloud_groups = None; + self.cloud_depth = Some(depth.clone()); + } if self.shadow_group.is_none() { let layout = self.shadow_layout.clone(); let (n3, n2, clamp, repeat) = ( @@ -405,6 +453,7 @@ impl HdVolumes { self.repeat.clone(), ); let hist = self.clouds.clone(); + let hist_dist = self.cloud_dist.clone(); let groups = [0, 1].map(|read| { self.group( device, @@ -419,6 +468,8 @@ impl HdVolumes { wgpu::BindingResource::TextureView(sky_lut), wgpu::BindingResource::TextureView(&hist[read]), wgpu::BindingResource::TextureView(atlas), + wgpu::BindingResource::TextureView(depth), + wgpu::BindingResource::TextureView(&hist_dist[read]), ], ) }); @@ -455,7 +506,7 @@ impl HdVolumes { stamped_pass( encoder, "HD clouds", - &[&self.clouds[write]], + &[&self.clouds[write], &self.cloud_dist[write]], &self.cloud_pipeline, &groups[read], stamp, @@ -525,8 +576,13 @@ impl Hd { depth: &wgpu::TextureView, ) { if self.clouds_active { - self.volumes - .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas); + self.volumes.prepare_clouds( + device, + &self.frame, + &self.sky.lut, + &self.smoke.atlas, + depth, + ); self.volumes .draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows")); self.volumes @@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp ("fog", fog_body(), &FOG_BINDINGS), ] } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to + /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0). + #[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 part in src.split(key).skip(1) { + let num: String = part + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let Ok(scale) = num.parse::() else { + continue; + }; + found += 1; + assert!( + scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0, + "{name}: the scale {scale} does not divide 65536" + ); + } + } + } + assert!(found >= 8, "found only {found} noise lookups"); + } + + /// The cloud noise's height is measured from the layer's base, not from the camera: the same + /// world point gives the same noise coordinate at any camera height. + #[test] + fn cloud_noise_height_is_anchored_to_the_layer() { + assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x")); + // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera. + for cam in [64.0f32, 210.0, 1000.0] { + let world_y = 230.0f32; + let rel = world_y - cam; + let base_rel = CLOUD_BASE - cam; + assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3); + } + } +}