From 1895461d5cf12fb876b45a2ca012b917254f5811 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 05:05:55 +0100 Subject: [PATCH 1/5] MONKEY (visualfix): volumetric haze meets the ocean at its surface, not the seabed Water writes no depth, so the haze pass measured the seabed behind it, and water with no seabed behind it (depth 0 below the horizon) read as sky and took no haze. The underwater slope of an island drew a hard-edged wedge across the sea (water-ocean at High). With an ocean footprint in reach and the eye above sea level, a point below sea level is now hazed at the ocean plane. Samples past the cascade's far bound take the ray's mean covered visibility instead of 0. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/volumetric_fog.rs | 56 +++++++++++++++++++++--- crates/benilla-world/src/liquid/query.rs | 10 +++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index 52448ece..374b99f5 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -155,6 +155,11 @@ impl Plugin for VolumetricFogPlugin { } } +/// MONKEY (visualfix): how near (yd, WoW XY) an ocean footprint must come to arm the sea plane. +const SEA_PLANE_REACH: f32 = 400.0; +/// MONKEY (visualfix): the eye must be this far above sea level (yd) for the plane to apply. +const SEA_LEVEL_MIN_EYE: f32 = 0.25; + fn density(minute: f32, weather: f32, indoors: bool) -> f32 { let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0); let dawn = dawn * dawn * (3.0 - 2.0 * dawn); @@ -249,6 +254,8 @@ fn update_fog( With, >, suns: Query<(Entity, &DirectionalLight), With>, + // MONKEY (visualfix): ocean surfaces, for the haze's sea-level plane. + water: Query<&benilla_world::liquid::WaterChunkInfo>, ) { let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2); let lamp_tier = override_value.lamp.unwrap_or(video.lamp_fog).min(2); @@ -303,13 +310,24 @@ fn update_fog( continue; } let fog = Vec3::from_array(lighting.fog_color); + // MONKEY (visualfix): 1 when the eye is above an ocean in reach. Water writes no depth, so + // without this the haze measured the SEABED behind it, and water over no seabed read as + // sky and took none: the underwater slope of an island drew a hard wedge across the sea. + let sea = camera_transform.map_or(0.0, |t| { + let eye = benilla_assets::coords::bevy_to_wow(t.translation()); + let over = eye[2] > SEA_LEVEL_MIN_EYE + && water + .iter() + .any(|w| w.ocean_within(eye[0], eye[1], SEA_PLANE_REACH)); + if over && tier != 0 { 1.0 } else { 0.0 } + }); // MONKEY (fog): rows 1-3 as packed for the light buffer. let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0)); commands.entity(entity).insert(FogView { mf_fog1: Vec4::from_array(rows[1]), mf_fog2: Vec4::from_array(rows[2]), mf_fog3: Vec4::from_array(rows[3]), - lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, 0.0), + lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, sea), lamp_positions: lamps.positions, lamp_colors: lamps.colors, color_density: fog.extend( @@ -602,7 +620,8 @@ fn visibility(light_index: u32, p: vec3) -> f32 { i32((*light).depth_texture_base_index + cascade), ndc.z); } // Unknown outside caster coverage is not evidence of direct sunlight. - return 0.0; + // MONKEY (visualfix): -1 marks "unknown"; the march fills it from the ray's covered samples. + return -1.0; } // MONKEY (lampfog): a mild Henyey-Greenstein lobe. The inverse-square term is integrated exactly; @@ -661,7 +680,21 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { let z = textureLoad(depth, xy, 0); let uv = (pixel.xy - view.viewport.xy) / view.viewport.zw; let q = view.world_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), max(z, 0.000001), 1.0); - let delta = q.xyz / q.w - view.world_position; + var delta = q.xyz / q.w - view.world_position; + // MONKEY (visualfix): over an ocean, a point below sea level (seabed, or no depth at all under + // the horizon) is hazed at the ocean surface (Bevy y = 0), where the eye actually meets it. + var sea_surface = false; + if (fog.lamp_meta.w > 0.5 && view.world_position.y > 0.0) { + let down = normalize(delta); + let below = (view.world_position + delta).y < 0.0; + if (down.y < -0.0001 && (below || z <= 0.0)) { + let to_sea = view.world_position.y / -down.y; + if (to_sea < length(delta)) { + delta = down * to_sea; + sea_surface = true; + } + } + } let distance = min(length(delta), 150.0); if (distance <= 10.0 && fog.lamp_meta.x < 0.5) { return source; } let ray = normalize(delta); @@ -674,7 +707,7 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { if (fog_hook::fog_is_modern(fog.mf_fog3)) { // MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own // horizon fog, so it takes no distance haze; the haze colour is the shared fog colour. - if (z <= 0.0) { opacity = 0.0; } + if (z <= 0.0 && !sea_surface) { opacity = 0.0; } haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta), fog.mf_fog1, fog.mf_fog2, fog.mf_fog3); } @@ -694,12 +727,23 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { var scatter = 0.0; for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) { if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; } + // MONKEY (visualfix): a sample past the cascade (beyond shadowDistance) takes the + // mean of this ray's covered samples, not 0: a hard 0 cut the sun lobe into rings + // of the far-bound step. A ray that starts in a dark room stays dark. + var known = 0.0; + var known_count = 0.0; for (var step = 0u; step < count; step += 1u) { // Fixed midpoint samples: no temporal noise in this gamma lane. let t = 10.0 + (f32(step) + 0.5) * step_size; let density = fog.color_density.w * smoothstep(10.0, 30.0, t); - scatter += visibility(light_index, view.world_position + ray*t) - * exp(-fog.color_density.w * fog_path(t)) * density * step_size; + var seen = visibility(light_index, view.world_position + ray*t); + if (seen >= 0.0) { + known += seen; + known_count += 1.0; + } else { + seen = select(0.0, known / known_count, known_count > 0.0); + } + scatter += seen * exp(-fog.color_density.w * fog_path(t)) * density * step_size; } } let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter; diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs index 6e56bfaf..9583613d 100644 --- a/crates/benilla-world/src/liquid/query.rs +++ b/crates/benilla-world/src/liquid/query.rs @@ -240,6 +240,16 @@ impl LiquidGrid { } impl WaterChunkInfo { + /// MONKEY (visualfix): an ocean surface (pinned to z = 0) whose footprint comes within `reach` + /// yards of the WoW XY, for the volumetric haze's sea-level plane. + pub fn ocean_within(&self, x: f32, y: f32, reach: f32) -> bool { + matches!(self.kind, LiquidKind::Ocean) + && x > self.min_x - reach + && x < self.max_x + reach + && y > self.min_y - reach + && y < self.max_y + reach + } + /// The grid's highest wet vertex, which `super::real_data` shows is not the surface. #[cfg(test)] pub(super) fn chunk_max_z(&self) -> f32 { From 9c2eadd98d70b673bba0334e25a8aea0051be9d4 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 06:00:26 +0100 Subject: [PATCH 2/5] MONKEY (visualfix): High cloud detail no longer punches a ring round the glow The erosion weight 4a(1-a) was 0.84 in dense cloud (a = 0.7), so one low base-octave detail cell (~5 degrees) cut a round hole in the mass, and the detail's mean (0.573, not 0.5) lifted the plateau around it: a hard ring near the sun, rimmed by the silver lining. Erosion is now centred on the detail's mean and weighted 6.75a(1-a)^2 (thin edges keep the cauliflower, dense cores keep their shape); thin cloud is not self-shadowed, and the silver lining reads a wider footprint ahead and is capped. Cloud behind the glow is a diffuse brightening again. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-world/src/shaders/cloud.wgsl | 19 ++++++++++++++----- crates/benilla-world/src/shaders/sky_fx.wgsl | 9 ++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/benilla-world/src/shaders/cloud.wgsl b/crates/benilla-world/src/shaders/cloud.wgsl index 36167bea..24697b91 100644 --- a/crates/benilla-world/src/shaders/cloud.wgsl +++ b/crates/benilla-world/src/shaders/cloud.wgsl @@ -32,7 +32,9 @@ fn detailed_cover(uv: vec2, oct: i32) -> f32 { if (a <= 0.0) { return 0.0; } - return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct)); + // MONKEY (visualfix): centred like the pixel's own erosion. + return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct) + 0.5 + - sky_fx::CLOUD_DETAIL_MEAN); } #endif @@ -46,7 +48,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { #ifdef SKY_FX_CLOUDS if (cfx.fx.x >= 1.5) { let n = sky_fx::cloud_detail(in.uv, sky_clock.x, 4); - a = sky_fx::cloud_erode(a, n); + // MONKEY (visualfix): erode about the detail's mean, so High does not add coverage; the + // bias lifted every mid-alpha plateau and drew a ring out of a soft hollow in the tile. + a = sky_fx::cloud_erode(a, n + 0.5 - sky_fx::CLOUD_DETAIL_MEAN); // A painterly tone inside the mass from the same detail. rgb = rgb * (0.93 + 0.14 * n); let strength = cfx.lit.w; @@ -59,9 +63,14 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { let transmit = exp(-occl * 1.44 * strength); // WarcraftXL's ambient floor 0.55, softened toward the painted texel. let light = 0.62 + 0.38 * transmit; - let ahead = detailed_cover(in.uv + sd * stp * 1.5, 2); - let rim = max(ahead - a, 0.0) * (1.0 - a) * transmit * strength; - rgb = rgb * mix(1.0, light, strength) + cfx.lit.rgb * rim * 0.9; + // MONKEY (visualfix): the silver lining reads a wider footprint ahead and is capped, + // and thin cloud is not self-shadowed: thin cloud before the glow stays a diffuse + // brightening instead of a dark disc inside a bright, rimmed ring. + let ahead = 0.5 * (detailed_cover(in.uv + sd * stp * 1.5, 2) + + detailed_cover(in.uv + sd * stp * 3.0, 2)); + let rim = min(max(ahead - a, 0.0) * (1.0 - a), 0.25) * transmit * strength; + let body = smoothstep(0.25, 0.85, a); + rgb = rgb * mix(1.0, light, strength * body) + cfx.lit.rgb * rim * 0.9; rgb = min(rgb, vec3(1.0)); } } diff --git a/crates/benilla-world/src/shaders/sky_fx.wgsl b/crates/benilla-world/src/shaders/sky_fx.wgsl index e9f8f16c..b239e0c5 100644 --- a/crates/benilla-world/src/shaders/sky_fx.wgsl +++ b/crates/benilla-world/src/shaders/sky_fx.wgsl @@ -291,9 +291,16 @@ fn cloud_detail(uv: vec2, t: f32, octaves: i32) -> f32 { return cloud_billow(p + w * 2.6, octaves); } +// MONKEY (visualfix): the mean of `cloud_detail` (4 octaves; 0.573 measured over 1e6 samples of +// the quintic value noise through `billow`). `cloud_erode` is centred on 0.5. +const CLOUD_DETAIL_MEAN: f32 = 0.573; + // Apply the detail to a base coverage: the edges (mid coverage) are pushed in and out by the // detail, solid cores and clear sky stay put, so the CPU tile still says where cloud is. fn cloud_erode(a: f32, n: f32) -> f32 { - let edge = 4.0 * a * (1.0 - a); + // MONKEY (visualfix): the weight peaks at a = 1/3 (thin edges) and is 0.43 at a = 0.7, where + // 4a(1-a) gave 0.84: one low base-octave cell (~5 degrees) punched a round hole in a dense + // mass, which read as a ring round the glow. + let edge = 6.75 * a * (1.0 - a) * (1.0 - a); return clamp(a + (n - 0.5) * 1.2 * edge, 0.0, 1.0); } From d26898a3e89145bb0bef2dd393422ccc7f9222a0 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 06:00:26 +0100 Subject: [PATCH 3/5] MONKEY (visualfix): whitecaps break in scattered short runs, not a lattice The fold mask came from the two long wave bands only, which cross in a regular diamond lattice: every crest broke into the same elongated patch. The mask adds the next two bands' compression, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd warped segments break each crest into short runs. Geometry is unchanged; crest foam stays on steep waves. Co-Authored-By: Claude Opus 5.5 --- WATER.md | 2 +- .../src/shaders/enhanced_water.wgsl | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/WATER.md b/WATER.md index ce61136c..fdf94e02 100644 --- a/WATER.md +++ b/WATER.md @@ -43,7 +43,7 @@ fork. Do not remove any of them. |---|---|---| | Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` | | Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier | -| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier | +| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier | | Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier | | Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier | | WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier | diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index a8906e63..5a83f818 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -282,6 +282,24 @@ fn water_gerstner(p: vec2, t: f32, shore: f32) -> vec4 { return vec4(displacement, fold); } +// MONKEY (visualfix): the whitecap fold. The two long bands alone cross in a regular diamond +// lattice, so every crest broke at the same spots; the next two bands (non-integer length and +// direction ratios to the first two) add their compression at a reduced weight. Mask only: the +// displaced geometry still moves on the two long bands. +fn whitecap_fold(p: vec2, t: f32, shore: f32) -> f32 { + var fold = water_gerstner(p, t, shore).w; + let energy = clamp(water.mode.y, 0.0, 1.0); + let tempo = mix(0.4, 1.0, sqrt(energy)); + for (var i = 2u; i < 4u; i += 1u) { + let wave = WATER_WAVES[i]; + let direction = vec2(cos(wave.x), sin(wave.x)); + let k = 6.2831853 / wave.y; + let phase = k * (dot(direction, p) - sqrt(10.72 / k) * tempo * t) + wave.w; + fold += 0.55 * WATER_GERSTNER_CHOP * wave.z * energy * shore * k * sin(phase); + } + return max(fold, 0.0); +} + // MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient // perturbation in world XZ; zero preserves today's image until that lane supplies rain data. // MONKEY (wet): rain rings. Each layer is a grid of cells, one drop per cell at a hashed spot and @@ -634,7 +652,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let wave = water_waves(p, t, length(eye_pos), footprint, shore, false); var open_sea_fold = 0.0; if water.mode.x > 1.5 && ocean_mesh { - open_sea_fold = water_gerstner(p, t, shore).w; + open_sea_fold = whitecap_fold(p, t, shore); } // One surface gradient: the procedural bands and the shore break. let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xyz, footprint, @@ -840,7 +858,17 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec // MONKEY (water): WarcraftXL's white belongs to a connected horizontal fold, not to every // steep normal. Threshold jitter stops identical crests drawing identical white contours. let fold_jitter = (noise - 0.5) * 0.04; - let breaking = smoothstep(0.13, 0.25, open_sea_fold + fold_jitter); + // MONKEY (visualfix): a slow, domain-warped gust field (tens of yards, unrelated to the wave + // lengths) moves the break threshold and calms whole patches, so the caps do not repeat. + let gust_warp = vec2(foam_noise(p * 0.013 + vec2(t * 0.004, 7.3)), + foam_noise(p * 0.013 + vec2(-4.1, t * 0.003))) - vec2(0.5); + let gust = foam_noise(p * 0.037 + gust_warp * 2.3 + t * vec2(0.006, -0.005)); + let cap_lo = 0.13 + (0.5 - gust) * 0.16; + // A crest breaks in short runs, not along its whole length: ~6 yd warped segments. + let segment = smoothstep(0.42, 0.62, + foam_noise(p * 0.16 + gust_warp * 1.7 + t * vec2(0.011, 0.007))); + let breaking = smoothstep(cap_lo, cap_lo + 0.14, open_sea_fold + fold_jitter) + * smoothstep(0.25, 0.6, gust) * segment; let whitecap_alpha = FOAM_WHITECAP * breaking * mix(0.55, 1.0, breakup) * smoothstep(0.55, 1.0, energy); // The cached derivatives from the top of the function — same expression as before, one tap. From 2193f9d33efef41f45a0093d4721797d284fc652 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 06:00:26 +0100 Subject: [PATCH 4/5] MONKEY (visualfix): skybox animation stops allocating and re-uploading per frame Review leftover #17. animate_skyboxes keeps its per-model clocks across frames (a path String is cloned only when a model first shows), poses into reused buffers (SkyRig::pose_into), and re-skins and re-uploads a skinned batch only when its model's pose changed (a generation counter). resolve_camera_skybox compares paths with an allocation-free norm_eq and owns only the winning WMO skybox name. Test: norm_eq agrees with norm. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-world/src/skybox.rs | 106 +++++++++++++++++++----- crates/benilla-world/src/skybox_anim.rs | 25 ++++-- 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index 225471bf..9df4635a 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -130,6 +130,9 @@ struct SkyboxPart { lane: SkyMatLane, /// MONKEY (skybox): how the rig moves the batch. pose: PartPose, + /// MONKEY (visualfix): the model-clock pose generation this skinned batch was last uploaded + /// at; an unchanged pose is not re-skinned or re-uploaded. + skinned_at: Option, } /// MONKEY (skybox): how a batch follows its model's rig. @@ -226,6 +229,23 @@ fn norm(path: &str) -> String { } } +/// MONKEY (visualfix): `norm(a) == norm(b)` without allocating (the per-frame resolver's test). +fn norm_eq(a: &str, b: &str) -> bool { + fn split(p: &str) -> (&str, bool) { + for ext in [".mdx", ".mdl", ".m2"] { + let cut = p.len().saturating_sub(ext.len()); + if let (Some(stem), Some(tail)) = (p.get(..cut), p.get(cut..)) { + if tail.eq_ignore_ascii_case(ext) { + return (stem, true); + } + } + } + (p, false) + } + let ((sa, ma), (sb, mb)) = (split(a), split(b)); + ma == mb && sa.eq_ignore_ascii_case(sb) +} + /// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately /// after the largest active celestial model. Oversized art compresses onto the last model rung but /// can never collide with the fog cone. @@ -238,8 +258,7 @@ fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) -> /// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the /// larger weight and every other layer is scaled by `1 − weight`. fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) { - let key = norm(path); - let at = match layers.iter().position(|l| norm(&l.path) == key) { + let at = match layers.iter().position(|l| norm_eq(&l.path, path)) { Some(i) => { layers[i].weight = layers[i].weight.max(weight); i @@ -332,7 +351,7 @@ fn resolve_camera_skybox( if w <= 0.0 { continue; } - match layers.iter_mut().find(|l| norm(&l.path) == def.path) { + match layers.iter_mut().find(|l| norm_eq(&l.path, &def.path)) { Some(l) => l.weight = l.weight.max(w), None => layers.push(SkyboxLayer { path: def.path.clone(), @@ -362,9 +381,11 @@ fn resolve_camera_skybox( nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false) }) - .then(|| sky.to_owned()) + .then_some(sky) }) - .min(); + .min() + // MONKEY (visualfix): one owned copy for the winner, not one per candidate. + .map(str::to_owned); // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a // doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw. if let Some(sky) = resolved { @@ -372,7 +393,7 @@ fn resolve_camera_skybox( let flags = flags_of(&sky); if t > 0.0 { collect_layer(&mut layers, &sky, t, flags); - } else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) { + } else if !layers.iter().any(|l| norm_eq(&l.path, &sky)) { layers.push(SkyboxLayer { path: sky, weight: 0.0, @@ -599,6 +620,7 @@ fn build_skybox( anim_alpha: 1.0, lane, pose, + skinned_at: None, }, )) .id(); @@ -705,11 +727,18 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { } /// MONKEY (skybox): one model's clock and pose this frame. +#[derive(Default)] struct ModelClock { band_t: f32, gseq: f64, live: bool, pose: Vec, + /// MONKEY (visualfix): bumped (from a system-wide counter) whenever `pose` changes. + generation: u64, + /// MONKEY (visualfix): the next pose, sampled here and swapped in when it differs. + next: Vec, + /// MONKEY (visualfix): the resolve memo, reused. + scratch: Vec>, } /// MONKEY (skybox): `WOW_SKYBOX_T=` holds a capture's skybox clock there instead of 0, to @@ -737,9 +766,13 @@ fn animate_skyboxes( mut table: ResMut, mut meshes: ResMut>, mut parts: Query<(&mut SkyboxPart, &mut SkyboxLocal)>, + // MONKEY (visualfix): kept across frames (keys cloned only when a model first shows), with + // a pose generation counter so an unchanged skinned pose is not re-uploaded. mut clocks: Local>, + mut generation: Local, ) { if want.0.is_empty() { + clocks.clear(); return; } let deterministic = crate::dev_state::deterministic_run(); @@ -747,11 +780,17 @@ fn animate_skyboxes( // narrow the wrapped sequence clock that the f32 track sampler consumes. let now = time.elapsed_secs_f64(); let day = clock.minute.min(1439) as f32 / 1440.0; - clocks.clear(); + clocks.retain(|path, _| want.0.iter().any(|l| l.weight > 0.0 && l.path == *path)); for layer in want.0.iter().filter(|l| l.weight > 0.0) { let Some(rig) = rigs.0.get(&layer.path) else { continue; }; + if !clocks.contains_key(&layer.path) { + clocks.insert(layer.path.clone(), ModelClock::default()); + } + let Some(c) = clocks.get_mut(&layer.path) else { + continue; + }; // A capture poses at `t = 0`, not the bind pose: a converted skybox places its layers with // bone keys, and every row at `t = 0` is its seed. let (band_t, gseq, live) = if layer.flags & SKYBOX_FULL_DAY != 0 { @@ -767,20 +806,20 @@ fn animate_skyboxes( }; (band_t, now, true) }; - let pose = if live && rig.animates() { - rig.pose(rig.band_time(band_t), gseq) + if live && rig.animates() { + let (next, scratch) = (&mut c.next, &mut c.scratch); + rig.pose_into(rig.band_time(band_t), gseq, scratch, next); } else { - Vec::new() - }; - clocks.insert( - layer.path.clone(), - ModelClock { - band_t, - gseq, - live, - pose, - }, - ); + c.next.clear(); + } + if c.next != c.pose { + std::mem::swap(&mut c.pose, &mut c.next); + *generation += 1; + c.generation = *generation; + } + c.band_t = band_t; + c.gseq = gseq; + c.live = live; } for (mut part, mut local) in &mut parts { let Some(c) = clocks.get(&part.path) else { @@ -803,6 +842,8 @@ fn animate_skyboxes( .copied() .unwrap_or(Affine3A::IDENTITY) }; + let skinned_at = part.skinned_at; + let mut uploaded = false; match &part.pose { PartPose::Static => {} PartPose::Rigid(b) => { @@ -817,7 +858,7 @@ fn animate_skyboxes( joints, weights, } => { - if c.pose.is_empty() { + if c.pose.is_empty() || skinned_at == Some(c.generation) { continue; } let skinned: Vec<[f32; 3]> = base @@ -836,8 +877,12 @@ fn animate_skyboxes( if let Some(m) = meshes.get_mut(mesh) { m.insert_attribute(Mesh::ATTRIBUTE_POSITION, skinned); } + uploaded = true; } } + if uploaded { + part.skinned_at = Some(c.generation); + } } } @@ -990,6 +1035,25 @@ mod tests { } } + /// MONKEY (visualfix): the allocation-free comparison agrees with comparing `norm` strings. + #[test] + fn norm_eq_matches_norm() { + let paths = [ + r"Environments\Stars\StratholmeSkybox.mdx", + r"environments\stars\stratholmeskybox.m2", + r"ENVIRONMENTS\STARS\STRATHOLMESKYBOX.MDL", + r"Environments\Stars\StratholmeSkybox.wmo", + r"Environments\Stars\Other.m2", + r"m2", + r"", + ]; + for a in paths { + for b in paths { + assert_eq!(norm_eq(a, b), norm(a) == norm(b), "{a} vs {b}"); + } + } + } + #[test] fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() { assert_eq!(skybox_batch_order(true, 0, 23), 24); diff --git a/crates/benilla-world/src/skybox_anim.rs b/crates/benilla-world/src/skybox_anim.rs index 408e4534..a0e8635a 100644 --- a/crates/benilla-world/src/skybox_anim.rs +++ b/crates/benilla-world/src/skybox_anim.rs @@ -240,15 +240,30 @@ impl SkyRig { } /// Every bone's model-space matrix at band time `band_t` and free clock `gseq_now` seconds. + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn pose(&self, band_t: f32, gseq_now: f64) -> Vec { + let mut out = Vec::new(); + self.pose_into(band_t, gseq_now, &mut Vec::new(), &mut out); + out + } + + /// MONKEY (visualfix): [`Self::pose`] into reused buffers (`scratch` is the resolve memo), so + /// the per-frame skybox animation allocates nothing once warm. + pub(crate) fn pose_into( + &self, + band_t: f32, + gseq_now: f64, + scratch: &mut Vec>, + out: &mut Vec, + ) { let n = self.parents.len(); - let mut out: Vec> = vec![None; n]; + scratch.clear(); + scratch.resize(n, None); for i in 0..n { - self.resolve(i, band_t, gseq_now, &mut out, 0); + self.resolve(i, band_t, gseq_now, scratch, 0); } - out.into_iter() - .map(|m| m.unwrap_or(Affine3A::IDENTITY)) - .collect() + out.clear(); + out.extend(scratch.iter().map(|m| m.unwrap_or(Affine3A::IDENTITY))); } fn resolve( From 5f78d55794e7ffa620ca811669a664b7bd1f9c8b Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 26 Sep 2026 06:00:26 +0100 Subject: [PATCH 5/5] MONKEY (visualfix): volumetric fog density follows the rendered clock The dawn/weather density curve read WorldTime, the server clock, which stays at its noon default offline, so a manual or capture time never got the dawn mist. It now reads the rendered minute through grading's rendered_minute (the same rule as ac9a1ef3). Baselines move: volfog-dawn is mistier (MAE 8.5), volfog-night is unchanged (0.07). Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 2 +- crates/benilla-app/src/post/grading.rs | 2 +- crates/benilla-app/src/post/mod.rs | 3 ++- crates/benilla-app/src/volumetric_fog.rs | 8 +++++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/LIGHTING.md b/LIGHTING.md index 2b40ac08..651e4aa7 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -25,7 +25,7 @@ behind each constant. | Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` | | Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` | | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | -| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | +| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | | Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` | | HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 | | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs index 82048096..c5ead119 100644 --- a/crates/benilla-app/src/post/grading.rs +++ b/crates/benilla-app/src/post/grading.rs @@ -362,7 +362,7 @@ fn update_views( /// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered, /// else the rendered (manual or capture) minute. -fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 { +pub(crate) fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 { match rendered { Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32, _ => time.minute_f, diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs index 2952e5a6..b25cbc26 100644 --- a/crates/benilla-app/src/post/mod.rs +++ b/crates/benilla-app/src/post/mod.rs @@ -1,7 +1,8 @@ //! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. mod bloom; -mod grading; +// MONKEY (visualfix): crate-visible for `rendered_minute` (volumetric fog). +pub(crate) mod grading; mod sun_shafts; pub(crate) struct PostPlugin; diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index 374b99f5..56d540e2 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -241,6 +241,8 @@ fn update_fog( interior: Res, // MONKEY (lampfog): the resolved point-table view published by global_light's packer. point_lights: Res, + // MONKEY (visualfix): the minute the lighting rendered (manual / capture clock offline). + rendered: Option>, // MONKEY (fog): the Modern fog rows (Option: absent in unit tests). monkey: Option>, mut cameras: Query< @@ -331,7 +333,11 @@ fn update_fog( lamp_positions: lamps.positions, lamp_colors: lamps.colors, color_density: fog.extend( - density(clock.minute_f, weather_amount, interior.0.is_some()) + density( + crate::post::grading::rendered_minute(&clock, rendered.as_deref()), + weather_amount, + interior.0.is_some(), + ) * if tier == 0 { 0.0 } else if tier == 2 {