From e970cedcf2c0510583e51151b5d01a14392da493 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 21:53:25 +0100 Subject: [PATCH] MONKEY (followups): refresh the streamed shadow cache without volumetric fog The static world caster rebuilds only on 16 yd of camera drift. The refresh that invalidates it when streamed geometry arrives lived in the volumetric-fog plugin and was gated on that cvar, so with volumetricFog 0 a standing camera never picked up trees and doodads that streamed in late. The refresh now lives in world_shadow.rs and is gated on worldShadows only (Classic unaffected). Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/volumetric_fog.rs | 48 +------------------- crates/benilla-app/src/world_shadow.rs | 56 ++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index b5753771..2d608273 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -5,11 +5,9 @@ use crate::{ shadow_core::{ShadowSet, ShadowSun}, video::VideoConfig, - world_shadow::WorldLane, }; use benilla_world::{ lighting::{ResolvedPointLight, ResolvedPointLights, WorldTime, WowLighting}, - static_gx::StaticGx, view::WorldCamera, weather::WeatherState, wmo_portal::CameraInteriorClaim, @@ -119,7 +117,8 @@ impl Plugin for VolumetricFogPlugin { shaft_gain, }) .add_plugins(ExtractComponentPlugin::::default()) - .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes)) + // MONKEY (followups): the streamed-caster refresh moved to `world_shadow.rs`; it no longer + // depends on this cvar. .add_systems(Last, update_fog.after(ShadowSet::Lanes)); // Keep headless policy tests independent of the renderer. if !app.is_plugin_added::() { @@ -149,27 +148,6 @@ impl Plugin for VolumetricFogPlugin { } } -// The legacy caster cache otherwise misses streamed trees at a stationary camera. -// Off must leave even this old cache behaviour untouched for the exact baseline. -fn refresh_streamed_shadows( - video: Res, - override_value: Res, - gx: Option>, - mut lane: ResMut, - mut seen: Local>, -) { - if override_value.fog.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows { - *seen = None; - return; - } - let Some(gx) = gx else { return }; - let generation = gx.torch_residency_generation(); - if *seen != Some(generation) { - lane.invalidate_static(); - *seen = Some(generation); - } -} - 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); @@ -704,28 +682,6 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { mod tests { use super::*; #[test] - fn shadow_cache_refresh_is_gated_and_quiet_when_unchanged() { - let mut app = App::new(); - app.init_resource::() - .init_resource::() - .init_resource::() - .insert_resource(FogOverride { - fog: Some(0), - lamp: None, - shaft_gain: 2.0, - }) - .add_systems(Update, refresh_streamed_shadows); - app.world_mut().clear_trackers(); - app.world_mut().run_schedule(Update); - assert!(!app.world().resource_ref::().is_changed()); - app.world_mut().resource_mut::().fog = Some(1); - app.world_mut().run_schedule(Update); - assert!(app.world().resource_ref::().is_changed()); - app.world_mut().clear_trackers(); - app.world_mut().run_schedule(Update); - assert!(!app.world().resource_ref::().is_changed()); - } - #[test] fn dawn_weather_and_rooms_control_density() { let noon = density(720.0, 0.0, false); assert!(density(390.0, 0.0, false) > noon * 2.0); diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs index 8e787dce..e04aa5a4 100644 --- a/crates/benilla-app/src/world_shadow.rs +++ b/crates/benilla-app/src/world_shadow.rs @@ -49,10 +49,34 @@ impl Plugin for WorldShadowPlugin { fn build(&self, app: &mut App) { app.add_plugins(MaterialPlugin::::default()) .init_resource::() + // MONKEY (followups): refresh the static caster cache when streamed geometry arrives. + // This used to live in the volumetric-fog plugin and so only ran with that cvar on. + .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes)) .add_systems(Last, update_world_shadows.in_set(ShadowSet::Lanes)); } } +/// MONKEY (followups): the static caster rebuilds on 16 yd of camera drift, so a standing camera +/// never picks up trees and doodads that stream in later. Invalidate it whenever the retained +/// scene's residency changes, while world shadows are on (Classic has them off, so it is inert). +fn refresh_streamed_shadows( + video: Res, + gx: Option>, + mut lane: ResMut, + mut seen: Local>, +) { + if !video.world_shadows { + *seen = None; + return; + } + let Some(gx) = gx else { return }; + let generation = gx.torch_residency_generation(); + if *seen != Some(generation) { + lane.invalidate_static(); + *seen = Some(generation); + } +} + /// The alpha-tested twin of [`ShadowCasterMaterial`]: same invisible-in-forward proxy, but its /// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE casts a /// leaf-shaped silhouette instead of the solid box a positions-only proxy throws. One instance per @@ -106,7 +130,7 @@ struct CutoutCaster { /// The world lane's retained casters. #[derive(Resource, Default)] -// MONKEY (volumetric fog): allow the fog plugin to invalidate a stale streamed caster cache. +// MONKEY (volumetric fog, followups): the streamed-residency refresh invalidates the cache. pub(crate) struct WorldLane { /// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift. static_caster: Option, @@ -123,8 +147,7 @@ pub(crate) struct WorldLane { env_rate: RebuildRate, } -// MONKEY (volumetric fog): retain ownership of the cache here; the fog plugin decides -// when streamed geometry needs a fresh map, without changing the legacy Off path. +// MONKEY (followups): `refresh_streamed_shadows` decides when streamed geometry needs a fresh map. impl WorldLane { pub(crate) fn invalidate_static(&mut self) { self.static_rebuilt_at = None; @@ -359,3 +382,30 @@ fn teardown( // The mesh the gate was pacing is gone — re-arm so a re-enable builds on its first frame. lane.env_rate.reset(); } + +// MONKEY (followups): the refresh must not depend on volumetric fog. +#[cfg(test)] +mod streamed_refresh_tests { + use super::*; + + #[test] + fn shadow_cache_refresh_runs_without_volumetric_fog_and_is_quiet_when_unchanged() { + let mut app = App::new(); + let mut video = VideoConfig::default(); + video.volumetric_fog = 0; + video.world_shadows = false; + app.insert_resource(video) + .init_resource::() + .init_resource::() + .add_systems(Update, refresh_streamed_shadows); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + app.world_mut().resource_mut::().world_shadows = true; + app.world_mut().run_schedule(Update); + assert!(app.world().resource_ref::().is_changed()); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + } +}