MONKEY (polish): the High cloud detail loops over the sky clock's day wrap

The detail drift was `(0.011, 0.0043) * t`, so the field jumped to a new
pattern when the sky clock wrapped at 86400 s. `value_noise_plane` now takes
a lattice period and hashes cell indices modulo it; the detail plane tiles
every CLOUD_TILE = 64 cells (octave o every 64 << o, the half-rate domain
warp every 32), and the drift is whole tiles per wrap, CLOUD_DRIFT_TILES =
(15, 6), i.e. 0.01111 and 0.00444 cells/s (+1% and +3% over WarcraftXL's
rates). The shader takes the drift as `fract(tiles * t / SKY_WRAP) * tile`,
so the field at the wrap equals the field at 0, and the drift term stays
below 64 instead of growing to ~950 in f32. One tile spans 1.78 of the
sheet's UV, wider than the dome shows, so no spatial repeat is visible.

A test reads `sky_fx.wgsl` and pins SKY_WRAP to SKY_CLOCK_WRAP_S, an even
tile, whole tiles of drift and the rates within 5% of the originals.

Verified: cargo test -p benilla-world --lib (644 passed, 8 ignored);
cloud.wgsl (SKY_FX_CLOUDS) and sky.wgsl (SKY_FX) compose and validate under
naga_oil 0.20 / naga 27 offline. No GPU or WoW data here; built on Linux only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6yUWgPosSakvL23gtgvpH
This commit is contained in:
Claude
2026-09-25 19:03:50 +00:00
parent 0412a7f362
commit 111742f83e
2 changed files with 62 additions and 10 deletions
+27 -8
View File
@@ -54,14 +54,18 @@ fn hash_cell(p: vec2<i32>, salt: u32) -> f32 {
// Value noise with the quintic fade: the cubic's second-derivative seam shows as square plateaus
// on a slowly drifting sky (the WarcraftXL note).
fn value_noise_plane(p: vec2<f32>, salt: u32) -> f32 {
// MONKEY (polish): the lattice repeats every `period` cells, so a shift by `period` is no change.
fn value_noise_plane(p: vec2<f32>, salt: u32, period: i32) -> f32 {
let i = vec2<i32>(floor(p));
let f = fract(p);
let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
let a = hash_cell(i, salt);
let b = hash_cell(i + vec2<i32>(1, 0), salt);
let c = hash_cell(i + vec2<i32>(0, 1), salt);
let d = hash_cell(i + vec2<i32>(1, 1), salt);
let per = vec2<i32>(period);
let lo = ((i % per) + per) % per;
let hi = (lo + vec2<i32>(1)) % per;
let a = hash_cell(lo, salt);
let b = hash_cell(vec2<i32>(hi.x, lo.y), salt);
let c = hash_cell(vec2<i32>(lo.x, hi.y), salt);
let d = hash_cell(hi, salt);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
@@ -244,6 +248,15 @@ fn milky_way(dir: vec3<f32>, band: f32) -> vec3<f32> {
// ---- S3: cloud detail -------------------------------------------------------------------------
// MONKEY (polish): the detail field loops over the sky clock's wrap. It tiles every `CLOUD_TILE`
// cells of the detail plane (octave o every `CLOUD_TILE << o` of its own lattice; the half-rate
// warp every `CLOUD_TILE / 2`), and the drift covers whole tiles per wrap, so the field at
// `SKY_WRAP` is the field at 0. One tile is 64 / 36 of the sheet, wider than the dome's UV.
const CLOUD_TILE: i32 = 64;
// Tiles of drift per wrap on u and v: 0.01111 and 0.00444 cells/s, WarcraftXL's 0.011 and 0.0043
// rounded to whole tiles.
const CLOUD_DRIFT_TILES: vec2<f32> = vec2<f32>(15.0, 6.0);
// WarcraftXL's billow blend: fold each octave toward rounded puffs by `cotton` (0.6).
fn billow(v: f32) -> f32 {
return v + (1.0 - abs(2.0 * v - 1.0) - v) * 0.6;
@@ -256,7 +269,7 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
var tot = 0.0;
var q = p;
for (var o = 0; o < octaves; o++) {
s += a * billow(value_noise_plane(q, u32(o) * 57u));
s += a * billow(value_noise_plane(q, u32(o) * 57u, CLOUD_TILE << u32(o)));
tot += a;
a *= 0.55;
q = q * 2.0 + vec2<f32>(3.7, 1.9);
@@ -267,8 +280,14 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
// The detail field in [0, 1] on the sheet: a broad warp twists the domain before the billowed fbm
// is read, which turns thresholded blobs into cauliflower edges.
fn cloud_detail(uv: vec2<f32>, t: f32, octaves: i32) -> f32 {
let p = uv * 36.0 + vec2<f32>(0.011, 0.0043) * t;
let w = vec2<f32>(value_noise_plane(p * 0.5, 173u), value_noise_plane(p * 0.5 + 5.3, 219u)) - 0.5;
// The drift modulo one tile: seamless at the wrap, and small enough to keep f32 precision.
let drift = fract(CLOUD_DRIFT_TILES * (t / SKY_WRAP)) * f32(CLOUD_TILE);
let p = uv * 36.0 + drift;
let half_tile = CLOUD_TILE / 2;
let w = vec2<f32>(
value_noise_plane(p * 0.5, 173u, half_tile),
value_noise_plane(p * 0.5 + 5.3, 219u, half_tile),
) - 0.5;
return cloud_billow(p + w * 2.6, octaves);
}
+35 -2
View File
@@ -47,8 +47,8 @@ impl SkyQuality {
///
/// MONKEY (fix-sky): it wrapped hourly in f32, and every star's twinkle jumped at the wrap. Now it
/// accumulates in f64 and wraps at [`SKY_CLOCK_WRAP_S`]; the shader's twinkle rates are whole
/// cycles per wrap (seamless). The High cloud-detail drift still re-patterns at the wrap, once
/// per 24 h of continuous play.
/// cycles per wrap (seamless). MONKEY (polish): the High cloud detail tiles and drifts whole
/// tiles per wrap (`CLOUD_TILE`, `CLOUD_DRIFT_TILES`), so it is seamless there too.
#[derive(Resource, Default)]
pub struct SkyClock {
pub secs: f32,
@@ -175,6 +175,39 @@ fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
mod tests {
use super::*;
/// A `const NAME ... = <value>;` line of `sky_fx.wgsl`, as text.
fn wgsl_const(name: &str) -> String {
let src = include_str!("shaders/sky_fx.wgsl");
let line = src
.lines()
.find(|l| l.starts_with(&format!("const {name}:")))
.unwrap_or_else(|| panic!("no const {name}"));
let value = line.split('=').nth(1).unwrap().trim();
value.trim_end_matches(';').trim().to_string()
}
#[test]
fn the_shader_wrap_and_the_cloud_loop_match_the_clock() {
let wrap: f64 = wgsl_const("SKY_WRAP").parse().unwrap();
assert_eq!(wrap, SKY_CLOCK_WRAP_S);
// The loop needs an even tile (the half-rate warp tiles at half of it) and whole tiles of
// drift per wrap; the rates stay within 5% of WarcraftXL's 0.011 and 0.0043 cells/s.
let tile: i32 = wgsl_const("CLOUD_TILE").parse().unwrap();
assert!(tile > 0 && tile % 2 == 0);
let drift = wgsl_const("CLOUD_DRIFT_TILES");
let tiles: Vec<f64> = drift
.trim_start_matches("vec2<f32>(")
.trim_end_matches(')')
.split(',')
.map(|v| v.trim().parse().unwrap())
.collect();
for (n, rate) in tiles.iter().zip([0.011, 0.0043]) {
assert_eq!(n.fract(), 0.0, "whole tiles per wrap");
let actual = n * f64::from(tile) / SKY_CLOCK_WRAP_S;
assert!((actual / rate - 1.0).abs() < 0.05, "drift {actual} vs {rate}");
}
}
#[test]
fn glow_fades_out_below_the_horizon_and_under_cloud() {
assert_eq!(glow_strength(-0.2, 0.0, 0.0), 0.0);