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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 21:53:25 +01:00
co-authored by Claude Opus 5.5
parent b36848bc68
commit e970cedcf2
2 changed files with 55 additions and 49 deletions
+2 -46
View File
@@ -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::<FogView>::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::<AssetPlugin>() {
@@ -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<VideoConfig>,
override_value: Res<FogOverride>,
gx: Option<Res<StaticGx>>,
mut lane: ResMut<WorldLane>,
mut seen: Local<Option<u64>>,
) {
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<f32>) -> @location(0) vec4<f32> {
mod tests {
use super::*;
#[test]
fn shadow_cache_refresh_is_gated_and_quiet_when_unchanged() {
let mut app = App::new();
app.init_resource::<VideoConfig>()
.init_resource::<StaticGx>()
.init_resource::<WorldLane>()
.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::<WorldLane>().is_changed());
app.world_mut().resource_mut::<FogOverride>().fog = Some(1);
app.world_mut().run_schedule(Update);
assert!(app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().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);
+53 -3
View File
@@ -49,10 +49,34 @@ impl Plugin for WorldShadowPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
.init_resource::<WorldLane>()
// 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<VideoConfig>,
gx: Option<Res<StaticGx>>,
mut lane: ResMut<WorldLane>,
mut seen: Local<Option<u64>>,
) {
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<Entity>,
@@ -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::<StaticGx>()
.init_resource::<WorldLane>()
.add_systems(Update, refresh_streamed_shadows);
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().resource_mut::<VideoConfig>().world_shadows = true;
app.world_mut().run_schedule(Update);
assert!(app.world().resource_ref::<WorldLane>().is_changed());
app.world_mut().clear_trackers();
app.world_mut().run_schedule(Update);
assert!(!app.world().resource_ref::<WorldLane>().is_changed());
}
}