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/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-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 52448ece..56d540e2 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); @@ -236,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< @@ -249,6 +256,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,17 +312,32 @@ 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( - 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 { @@ -602,7 +626,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 +686,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 +713,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 +733,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-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. 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 { 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); } 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(