gfx: bound the torch and point-light tables by the camera frustum

A light whose whole candidacy sphere (the shader's own reach, the interior fill
dome included) sits off-screen leaves the packed table, so a city's torches
behind the player stop padding the per-vertex walk. The packer also reuses one
scratch buffer and tests the cheap camera distance before the portal walk.
This commit is contained in:
chairmanpow
2026-10-12 17:02:42 +01:00
parent a0e7087546
commit 799196e1d1
2 changed files with 142 additions and 14 deletions
+35 -3
View File
@@ -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<TorchLane>,
mut meshes: ResMut<Assets<Mesh>>,
mut views: ResMut<TorchShadowViews>,
cameras: Query<&GlobalTransform, With<WorldCamera>>,
// 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<WorldCamera>>,
// 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.
+107 -11
View File
@@ -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<Vec<..>>`) 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<WowLighting>,
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
// 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<WorldCamera>>,
// `Without<ShadowProxyLight>`: 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<SyntheticFireLight>` rides along so the live `fireLightGain`
@@ -1378,6 +1395,9 @@ fn build_light_data(
(emissive, fire_gain, spell_gain): (Res<EmissiveTier>, Res<FireLightGain>, Res<SpellLightGain>),
mut last_dump: Local<f64>,
mut last_rows_dump: Local<f64>,
// 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<Vec<PointCandidate>>,
) {
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::<Entity, With<crate::view::WorldCamera>>();
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::<WowLightData>().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.
///