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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user