diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs index 479e9fb4..5297fe8e 100644 --- a/crates/benilla-app/src/torch_shadow.rs +++ b/crates/benilla-app/src/torch_shadow.rs @@ -64,7 +64,7 @@ use benilla_world::lighting::{ LightLitRooms, LightReach, LightRooms, ShadowDistance, ShadowProxyLight, SyntheticFireLight, WorldPointLight, WowLighting, }; -use bevy::camera::primitives::Aabb; +use bevy::camera::primitives::{Aabb, Frustum, Sphere as CullSphere}; // MONKEY (carried light stability): the settle verdict a carried light earns by standing still. // MONKEY (outdoor torch shadows): …and the marker that says a light is CARRIED BY A BODY. use crate::entities::{CarriedLightMotion, HeldLight}; @@ -495,6 +495,25 @@ fn fixture_eligible(player_d: f32, camera_d: f32, r: f32, distance: f32) -> bool && player_d <= TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK } +/// MONKEY (torch cull): can this fixture's cube land a shadow on anything the camera can see? The +/// cube spans the fixture's own reach, so a sphere test against the camera frustum — sized by that +/// reach — keeps every fixture whose shadows reach the view and drops only one tucked entirely +/// behind or beside it. That frees the scarce cube slots for the fires ahead instead of a bright +/// forge behind the player, and the lane's existing fade means turning back re-promotes it smoothly +/// rather than switching a shadow on. A missing frustum keeps every fixture, exactly as the +/// distance tests alone did. +fn shadow_reaches_view(frustum: Option<&Frustum>, pos: Vec3, reach: f32) -> bool { + frustum.is_none_or(|f| { + f.intersects_sphere( + &CullSphere { + center: pos.into(), + radius: reach.max(1e-3), + }, + false, + ) + }) +} + /// MONKEY (static torch cache): may a slot publish its cached map this frame? Yes while the /// fixture still stands where the map was baked from (see [`TORCH_STALE_DRIFT_SQ`]) — staleness in /// the SURROUNDINGS is invisible for the few frames a rebuild takes, whereas withdrawing the map @@ -768,7 +787,9 @@ fn update_torch_shadows( mut lane: ResMut, mut meshes: ResMut>, mut views: ResMut, - cameras: Query<&GlobalTransform, With>, + // MONKEY (torch cull): the camera frustum rides this same query, so a fixture whose pool cannot + // reach the view is not promoted to a cube slot (the parameter list is already at the limit). + cameras: Query<(&GlobalTransform, Option<&Frustum>), With>, // MONKEY (torch caster selection): the RANKING ANCHOR. Shadows are for the player's own // surroundings — a free-look camera swung across the room must not re-pick the casters — so // scoring is done at the body, and only falls back to the camera when there is none (the glue @@ -840,9 +861,10 @@ fn update_torch_shadows( teardown(&mut lane, &mut meshes, &mut views); return; } - let Some(cam) = cameras.iter().next().map(GlobalTransform::translation) else { + let Some((cam_gt, frustum)) = cameras.iter().next() else { return; }; + let cam = cam_gt.translation(); // (1) MONKEY (torch caster selection): who casts. Three rules, in this order. // @@ -963,6 +985,11 @@ fn update_torch_shadows( if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) { return None; } + // MONKEY (torch cull): the interior pool spans `r`, so that is the sphere the + // view test sizes against. + if !shadow_reaches_view(frustum, p, r) { + return None; + } let score = candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0); // MONKEY (torch lane perf): `r` rides along — the moving-caster gather sizes its @@ -976,6 +1003,11 @@ fn update_torch_shadows( if !exterior_eligible(d, p.distance(cam), ents.distance.0) { return None; } + // MONKEY (torch cull): an exterior cube packs no authored reach, so the view test + // sizes its sphere by the cube's own [`TORCH_RANGE`]. + if !shadow_reaches_view(frustum, p, TORCH_RANGE) { + return None; + } // Same synthetic-gain fold as the interior arm (and as the packer): dimming the // invented lights with `fireLightGain 0` must take their shadows with them, or a fire // that lights nothing would still carve a black wedge across the ground. diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 165bba5e..f7bbae8a 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -5,6 +5,8 @@ //! are never mutated after creation, since Bevy rebuilds the bind group of a mutated material. use benilla_formats::LiquidKind; +// MONKEY (light cull): the camera frustum test that keeps the packed point table view-bounded. +use bevy::camera::primitives::{Frustum, Sphere as CullSphere}; use bevy::ecs::system::SystemParam; use bevy::prelude::*; use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin}; @@ -454,6 +456,13 @@ pub fn interior_reach(reach: f32, scale: f32) -> f32 { /// membership rules, not the window, decide what a fragment sees). const INTERIOR_LEGACY_REACH: f32 = 48.0; +/// Mirror of `wow_model.wgsl`'s `INTERIOR_FILL_SPAN`: an interior fixture's outermost candidacy +/// radius is this times its packed lane reach — the fill dome, which reaches `1.5 R` where the +/// direct pool windows to zero at `R`. Read only to size the frustum cull's bounding sphere, so a +/// fixture whose fill still reaches into view is never dropped with the fixture behind the camera. +/// Keep in sync with the shader constant. +const INTERIOR_FILL_SPAN: f32 = 1.5; + /// MONKEY (fire GO lights): marks a `PointLight` whose colour/intensity were SYNTHESISED from a /// model's flame particle emitter rather than authored ([`benilla_assets::ModelLight::synthetic`]). /// @@ -1319,12 +1328,20 @@ impl std::fmt::Display for RoomClaim { } } +/// One packed point-table candidate, before the sort: `(camera distance², world position, shader +/// candidacy range, committed rgb, synthetic, interior lane reach, room claim, raw claim count)`. +/// Named so the packer reuses one scratch buffer across frames (`Local>`) instead of +/// allocating and dropping the whole in-range set every frame. +type PointCandidate = (f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize); + #[allow(clippy::type_complexity)] fn build_light_data( light: Res, debug: Res, view: Res, - cam: Query<&GlobalTransform, With>, + // MONKEY (light cull): the camera frustum rides the same query, so the point table is bounded + // by the view as well as by distance (the parameter ceiling leaves no room for a second query). + cam: Query<(&GlobalTransform, Option<&Frustum>), With>, // `Without`: the torch-shadow proxies are `WorldPointLight`s too, but they // exist only to cast a cube map — never to light benilla's receivers (see [`ShadowProxyLight`]). // MONKEY (fire GO lights): `Has` rides along so the live `fireLightGain` @@ -1378,6 +1395,9 @@ fn build_light_data( (emissive, fire_gain, spell_gain): (Res, Res, Res), mut last_dump: Local, mut last_rows_dump: Local, + // MONKEY (light cull): the reused candidate buffer — the in-range light set is rebuilt every + // frame, and collecting it into a fresh `Vec` reallocated the whole resident set each time. + mut scratch: Local>, ) { let l = &*light; let fog_enable = if debug.lighting.disable_fog { 0.0 } else { 1.0 }; @@ -1491,7 +1511,11 @@ fn build_light_data( // packed RAW as the reference commits it ([`commit_raw`]: the encode→decode round trip is // identity, over-gamut preserved). Entries past `count` stay stale in the blob — the count row // guards every reader. - let cam_pos = cam.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO); + // MONKEY (light cull): the frustum is the camera's own, absent on a headless/first frame (then + // the cull is a no-op and the distance cap alone bounds the table, exactly as before). + let cam_view = cam.single().ok(); + let cam_pos = cam_view.map_or(Vec3::ZERO, |(t, _)| t.translation()); + let frustum = cam_view.and_then(|(_, f)| f); // MONKEY (flame flicker): the one clock read for the whole table — every flame's phase is a // function of this absolute second and its own seed, so the frame is internally consistent and // the result does not depend on how long the frame took. @@ -1507,9 +1531,21 @@ fn build_light_data( // filter below decides whether it is packed at all. Printed by `WOW_POINTS_DUMP` so an // EXTERIOR-lane entry that HAS claims is visible as such: the shader reads claims only on // the interior lane, so `claims 4` beside `EXT` is the readout of exactly that trade. - let mut pts: Vec<(f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize)> = lights_q + // MONKEY (light cull): fill one reused buffer in place — the in-range set is rebuilt every + // frame, and collecting it into a fresh `Vec` reallocated the whole resident set each time. + let scratch = &mut *scratch; + scratch.clear(); + let candidates = lights_q .iter() .filter(|(_, gt, rooms, _, _, _, _, _, _, _)| { + // MONKEY (light cull): the cheap camera-distance bound FIRST. A light past + // [`POINT_PACK_RADIUS`] never enters the table, so it must not pay for the portal/PVS + // term below — that walk used to run on every light in the world before the distance + // test dropped it. + let d2 = gt.translation().distance_squared(cam_pos); + if d2 >= POINT_PACK_RADIUS * POINT_PACK_RADIUS { + return false; + } // The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a // visibility test bolted onto a faithful gather: the reference's register walk has no // such term either, it simply never has a culled room's torch to register. Ungated for @@ -1523,9 +1559,7 @@ fn build_light_data( crate::wmo_portal::room_admits( rooms.map(|r| &r.0), rooms.and_then(|r| portals.get(r.0.instance).ok()), - ) || (dynamic_interiors.enabled - && gt.translation().distance_squared(cam_pos) - < INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT) + ) || (dynamic_interiors.enabled && d2 < INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT) }) .filter_map( |(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight, spell)| { @@ -1625,13 +1659,35 @@ fn build_light_data( (d2, p, pl.range, rgb, synthetic, lane, claim, lit_n) }) }, - ) - .collect(); - pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + ); + scratch.extend(candidates); + // MONKEY (light cull): drop a light whose whole candidacy sphere is off-screen, so a city's + // torches behind the camera stop padding the packed table's per-vertex walk. The radius is the + // shader's own candidacy bound — `range` on the exterior lane, `1.5 * lane` (the fill dome) on + // the interior — so a light whose pool still reaches into view is never dropped. A missing + // frustum (headless, the first frame) keeps every light, exactly as the distance cap did. + scratch.retain(|(_, p, range, _, _, lane, _, _)| { + let reach = if *lane > 0.5 { + INTERIOR_FILL_SPAN * *lane + } else { + *range + }; + frustum.is_none_or(|f| { + f.intersects_sphere( + &CullSphere { + center: (*p).into(), + radius: reach.max(1e-3), + }, + false, + ) + }) + }); + scratch.sort_by(|a, b| a.0.total_cmp(&b.0)); // MONKEY (ext light k8): 255, not 256 — index 255 is the shaders' `EXT_SEL_EMPTY` sentinel now // that a draw unit's selection packs eight 8-bit ranks. See [`MAX_LIVE_POINT_LIGHTS`]. The sort // above is nearest-camera-first, so the entry this drops is the farthest of the set. - pts.truncate(MAX_LIVE_POINT_LIGHTS); + scratch.truncate(MAX_LIVE_POINT_LIGHTS); + let pts: &[PointCandidate] = scratch.as_slice(); // `.x` = the live entry count; MONKEY (dynamic interiors): `.yzw` = the interior lane's live // knobs (base ambient, per-fixture fill gain, exposure) — free lanes until now. // MONKEY (darkness gains): `interiorGain` scales two of the room lane's three inputs here (the @@ -1691,7 +1747,7 @@ fn build_light_data( let snap = |v: f32| (((half + v) / cell).floor() + 0.5) * cell - half; let anchor = Vec3::new(snap(cam_pos.x), cam_pos.y, snap(cam_pos.z)); let (mut boxed, mut sphere) = (0usize, 0usize); - for (_, p, _, _, _, _, _, _) in &pts { + for (_, p, _, _, _, _, _, _) in pts { let dv = *p - anchor; boxed += usize::from(dv.x.abs().max(dv.z.abs()) <= 33.570_166); sphere += usize::from(dv.length() <= 48.0); @@ -2191,6 +2247,46 @@ mod tests { app } + /// MONKEY (light cull): a light whose whole candidacy sphere sits behind the camera leaves the + /// packed table, so a city's torches behind the player stop padding the shaders' per-vertex + /// walk. The sphere is the light's own `range` (48 yd on the exterior lane), so a light merely + /// near the view still packs — only one whose pool cannot reach the frustum is dropped. A + /// missing frustum keeps every light, which is what every other packer test runs under. + #[test] + fn a_light_behind_the_camera_leaves_the_packed_table() { + let mut app = packer_app(); + let mut cam_q = app + .world_mut() + .query_filtered::>(); + let cam = cam_q.single(app.world()).expect("the packer app's camera"); + // A camera at the origin looking down Bevy -Z, given a real perspective frustum. + let clip = Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, 1000.0); + app.world_mut() + .entity_mut(cam) + .insert(Frustum::from_clip_from_world(&clip)); + + let point = crate::terrain_stream::point_light([1.0, 1.0, 1.0], 3.0); + // In front of the camera: packs. + app.world_mut().spawn(( + point, + GlobalTransform::from_translation(Vec3::new(0.0, 1.0, -10.0)), + )); + // 60 yd behind it — past its own 48 yd range, so its sphere cannot reach the view: drops. + app.world_mut().spawn(( + point, + GlobalTransform::from_translation(Vec3::new(0.0, 1.0, 60.0)), + )); + app.update(); + + let rows = &app.world().resource::().0; + assert_eq!(rows.rows[20][0], 1.0, "only the light in front packs"); + assert!( + (rows.points[0][2] + 10.0).abs() < 1e-4, + "and it is the one in front (-Z): {:?}", + rows.points[0] + ); + } + /// MONKEY (room gate): the claim a fixture is packed with is the SPAWNER'S OWN claim list when /// it built one, and it fails OPEN on every ambiguous arm. ///