HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual only, gameplay sky darkening uses the world's time (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 03:26:09 +03:00
committed by airdef
parent b1aaf32851
commit 4a6ab8117a
8 changed files with 239 additions and 44 deletions
+25 -12
View File
@@ -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<Option<((i64, u32, u32), i32)>>,
/// The same for the time a mod shows (a replay's): render only.
shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
@@ -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<Option<((i64, u32, u32), i32)>>,
) -> 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,
@@ -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)
}
+47 -7
View File
@@ -1,6 +1,17 @@
@group(0) @binding(6) var history: texture_2d<f32>;
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
@group(0) @binding(8) var scene_depth: texture_depth_2d;
@group(0) @binding(9) var history_dist: texture_2d<f32>;
// 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<f32>,
// How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
@location(1) dist: vec4<f32>,
}
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -65,7 +76,26 @@ fn cloud_scatter(
}
@fragment
fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
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<i32>(frame.resolution.xy);
let base = vec2<i32>(in.position.xy) * 2;
var zmax = 0.0;
var best = base;
for (var k = 0; k < 4; k++) {
let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
let z = textureLoad(scene_depth, q, 0);
if z > zmax {
zmax = z;
best = q;
}
}
let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
let sndc = vec4<f32>(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<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
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<f32> {
cur = vec4<f32>(colour, trans);
}
}
var out: CloudOut;
out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
if frame.cloud_cam.w > 0.5 {
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(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<f32>(textureDimensions(history_dist));
let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
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;
}
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
d *= smoothstep(0.0, 0.2, h);
if d < 0.002 { return 0.0; }
if detail > 0.0 {
let p3 = vec3<f32>(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<f32>(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<f32>(warp), 0.0).r;
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
+3 -3
View File
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
}
// The air's extinction per block at a camera-relative point: a haze that thins with height above
// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; 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>) -> f32 {
let wy = p.y + frame.vfog.y;
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
s += frame.sky_extra.z * 0.003;
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
let 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<f32>, core: f32) -> f32 {
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
- vec3<f32>(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);
+40 -2
View File
@@ -30,6 +30,9 @@ struct Light {
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
const SKY_RAY: f32 = 20000.0;
const CLOUD_SHADOW_R: f32 = 1200.0;
// The pixel being lit, for the shadow filter's noise.
var<private> frag_px: vec2<f32>;
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
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<i32>, dist: f32) -> vec4<f32> {
let size = vec2<i32>(textureDimensions(cloud_tex));
let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
let base = vec2<i32>(floor(f));
let t = f - floor(f);
var sum = vec4<f32>(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<i32>(i, j), vec2<i32>(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<i32>(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<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
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<f32>(17.0, 5.0));
let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(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);
+3 -1
View File
@@ -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()
+118 -17
View File
@@ -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<wgpu::TextureView>,
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::<f64>() 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);
}
}
}