diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index e5a2d977..80a40925 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -243,9 +243,10 @@ pub(super) fn spawn_wmo_gameobject_props( // inside a cabin. let (live, peak) = probes.occupancy(); warn_once!( - "interior-prop probe table full (live {live}, peak {peak}); \ - cabin props take the room's day/night law instead of their own \ - folded probe" + "interior-prop probe table full (live {live}, peak {peak}, {} \ + distinct probe(s) refused so far); cabin props take the room's \ + day/night law instead of their own folded probe", + probes.overflow() ); benilla_world::mesh_tag::PropSeat::Matte } diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs index 805b8594..5ccb9376 100644 --- a/crates/benilla-world/src/interior.rs +++ b/crates/benilla-world/src/interior.rs @@ -1012,8 +1012,10 @@ fn resolve_anchor_law( None => { let (live, peak) = probes.occupancy(); warn_once!( - "interior-prop probe table full (live {live}, peak {peak}); \ - indoor entities fall back to the day/night law" + "interior-prop probe table full (live {live}, peak {peak}, {} \ + distinct probe(s) refused so far); indoor entities fall back to \ + the day/night law", + probes.overflow() ); AppliedLaw::Matte } diff --git a/crates/benilla-world/src/lighting/prop_probes.rs b/crates/benilla-world/src/lighting/prop_probes.rs index 01043ece..86624c30 100644 --- a/crates/benilla-world/src/lighting/prop_probes.rs +++ b/crates/benilla-world/src/lighting/prop_probes.rs @@ -3,7 +3,7 @@ //! group's MOLR lights) and uploaded behind the shared light blob. The prop's `MeshTag` payload //! carries its slot, which the [`PropProbeSlot`] hook frees on despawn. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use bevy::ecs::lifecycle::HookContext; @@ -16,16 +16,63 @@ use bevy::render::extract_resource::ExtractResource; /// a city WMO streams in thousands of lit interior props at once. pub const MAX_PROP_PROBES: usize = 8192; -/// A probe's rows, bit for bit: identical props fold to identical bits and share a slot. +/// The step [`ProbeKey`] rounds a coefficient onto, so probes that agree to within it share one +/// slot. `WOW_PROBE_KEY_STEP=` overrides it for a measurement. +/// +/// Chosen by measurement, in Ironforge at grom's login spot. Keying on exact bits, as this did +/// before, shares only between props no MOLR light reaches *and* whose baked MODD colour matches +/// exactly — 3,883 distinct probes from one standing position, 5,543 at a far clip of 777, and the +/// table saturated during his world load. The distinctness is the per-prop baked colour, not the +/// prop's position: quantizing the *position* the probe folds at, even onto a 16 yd grid, removed +/// only 4.5% of the slots, while this step removes 35%. +/// +/// The cost is that a sharing prop reads the first claimer's rows instead of its own, so its light +/// is wrong by at most one step. Measured against a render of a lit Ironforge storeroom: the whole +/// probe lane is worth ≤ 14/255 on 7.6% of the frame (`WOW_PROBE_CAP=0` against the full table), +/// and every step from 1/128 up to a deliberately absurd 1.0 — which collapses the scene to 13 +/// probes — renders **byte-identical** to exact keying. 1/32 is well inside that, and leaves the +/// table ~1.5× further from saturation. +const PROBE_KEY_STEP: f32 = 1.0 / 32.0; + +/// A probe's rows, quantized to [`PROBE_KEY_STEP`]: probes that agree to within one step share a +/// slot, which keeps the **first claimer's exact rows** — the quantization decides who shares, it +/// never reaches the light the shader reads. #[derive(Clone, PartialEq, Eq, Hash)] -struct ProbeKey([[u32; 4]; 7]); +struct ProbeKey([[i32; 4]; 7]); impl ProbeKey { fn of(coeffs: &[Vec4; 7]) -> Self { - Self(coeffs.map(|v| v.to_array().map(f32::to_bits))) + let inv = 1.0 / probe_key_step(); + Self(coeffs.map(|v| v.to_array().map(|c| (c * inv).round() as i32))) } } +/// The table's usable height. `WOW_PROBE_CAP=` shrinks it, which is the only way to see what +/// overflow costs on screen without walking a city until it saturates: `WOW_PROBE_CAP=0` renders +/// every interior prop on the fallback law. +fn probe_cap() -> usize { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + *C.get_or_init(|| { + std::env::var("WOW_PROBE_CAP") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|&n| n <= MAX_PROP_PROBES) + .unwrap_or(MAX_PROP_PROBES) + }) +} + +/// [`PROBE_KEY_STEP`], overridable for a measurement. +fn probe_key_step() -> f32 { + static S: std::sync::OnceLock = std::sync::OnceLock::new(); + *S.get_or_init(|| { + std::env::var("WOW_PROBE_KEY_STEP") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|s: &f32| *s > 0.0) + .unwrap_or(PROBE_KEY_STEP) + }) +} + /// The main-world slot table: a slab whose freed slots recycle first, with identical probes sharing /// a refcounted slot (without it a login scene's prop WMOs overflow the table). The rows sit behind /// an `Arc`: the render extract is a pointer bump, and `make_mut` copies when a change races it. @@ -44,6 +91,10 @@ pub struct PropProbes { peak: usize, /// Slots written since the last publish, `[lo, hi)`: the upload writes only this span. dirty: Option<(usize, usize)>, + /// The distinct probes refused while the table was full, so the overflow report can say how + /// far past capacity the demand actually ran. That number is the whole question when the table + /// saturates: a small overflow is answered by sharing harder, a large one only by more slots. + overflow: HashSet, } impl Default for PropProbes { @@ -57,6 +108,7 @@ impl Default for PropProbes { generation: 0, peak: 0, dirty: None, + overflow: HashSet::new(), } } } @@ -72,7 +124,10 @@ impl PropProbes { return Some(slot); } } - let slot = self.take_free_slot()?; + let Some(slot) = self.take_free_slot() else { + self.overflow.insert(key); + return None; + }; Arc::make_mut(&mut self.rows)[slot as usize] = coeffs.map(|v| v.to_array()); self.by_key.insert(key.clone(), slot); self.slots[slot as usize] = Some((1, Some(key))); @@ -115,7 +170,7 @@ impl PropProbes { fn take_free_slot(&mut self) -> Option { match self.free.pop() { Some(s) => Some(s), - None if self.high < MAX_PROP_PROBES => { + None if self.high < probe_cap() => { self.high += 1; Some((self.high - 1) as u16) } @@ -128,6 +183,11 @@ impl PropProbes { (self.high - self.free.len(), self.peak) } + /// How many distinct probes have been refused for want of a slot: the demand past capacity. + pub fn overflow(&self) -> usize { + self.overflow.len() + } + /// Drops one reference, freeing at zero; besides the [`PropProbeSlot`] hook, the interior /// classifier calls it for a slot whose anchor was despawned before its component landed. pub(crate) fn release(&mut self, slot: u16) { @@ -254,6 +314,49 @@ fn free_prop_probe_slot(mut world: DeferredWorld, ctx: HookContext) { mod tests { use super::*; + /// The step decides who shares; it never reaches the rows the shader reads. A prop that + /// joins another's slot takes the **first claimer's exact** light, not a rounded one — which + /// is why quantizing the key is safe where quantizing the fold's inputs would not be. + #[test] + fn probes_within_one_step_share_the_first_claimers_exact_rows() { + let mut t = PropProbes::default(); + let exact = [Vec4::splat(0.5); 7]; + let near = [Vec4::splat(0.5 + PROBE_KEY_STEP / 4.0); 7]; + let a = t.alloc(exact).unwrap(); + let b = t.alloc(near).unwrap(); + assert_eq!(a, b, "a probe within a step of a live one shares its slot"); + assert_eq!( + t.rows[a as usize], + exact.map(|v| v.to_array()), + "the slot keeps the first claimer's exact rows, not the rounded key" + ); + + // A step away is a different probe, and gets its own slot. + let far = [Vec4::splat(0.5 + PROBE_KEY_STEP * 2.0); 7]; + assert_ne!(t.alloc(far).unwrap(), a); + } + + /// The overflow count is the whole question when the table saturates: a small one is answered + /// by sharing harder, a large one only by more slots. It counts *distinct* refusals, so a room + /// asking for the same probe a hundred times does not read as a hundred missing slots. + #[test] + fn a_full_table_counts_the_distinct_probes_it_refused() { + let mut t = PropProbes::default(); + // Fill it: each probe is more than a step from the last, so none share. + for i in 0..MAX_PROP_PROBES { + let v = (i as f32) * PROBE_KEY_STEP * 2.0; + assert!(t.alloc([Vec4::splat(v); 7]).is_some(), "slot {i}"); + } + assert_eq!(t.overflow(), 0, "a table that fits refuses nothing"); + + let past = |k: usize| [Vec4::splat((MAX_PROP_PROBES + k) as f32 * PROBE_KEY_STEP * 2.0); 7]; + assert!(t.alloc(past(1)).is_none(), "the table is full"); + assert!(t.alloc(past(1)).is_none()); + assert_eq!(t.overflow(), 1, "the same refused probe counts once"); + assert!(t.alloc(past(2)).is_none()); + assert_eq!(t.overflow(), 2, "a second distinct probe is a second count"); + } + #[test] fn identical_probes_share_one_slot_and_refcount() { let mut t = PropProbes::default(); diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs index 36df4050..0b50b3b1 100644 --- a/crates/benilla-world/src/rig_palette.rs +++ b/crates/benilla-world/src/rig_palette.rs @@ -887,7 +887,7 @@ fn census_rig_palettes( "[rig-census] slots={s}/{} bones={b}/{MAX_PALETTE_BONES} peak={ps}/{pb} | \ doodad={}({}) parked={}({}) unit={}({}) other={}({}) | \ bones/slot ≤2:{} 3-4:{} 5-8:{} 9-16:{} 17-32:{} 33+:{} | \ - denied +{denied} | probes {p_live}/{p_peak} of {}", + denied +{denied} | probes {p_live}/{p_peak} of {} overflow {}", MAX_RIG_SLOTS - 1, doodad.0, doodad.1, @@ -904,6 +904,7 @@ fn census_rig_palettes( hist[4], hist[5], crate::lighting::MAX_PROP_PROBES, + probes.overflow(), ); } diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 68f15a86..f3bd09c5 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -729,9 +729,10 @@ pub(super) fn spawn_loaded_placements( // indoors, and only its *probe* is missing. let (live, peak) = probes.occupancy(); warn_once!( - "interior-prop probe table full (live {live}, peak {peak}); \ - overflowing props take the room's day/night law instead of their \ - own folded probe" + "interior-prop probe table full (live {live}, peak {peak}, {} \ + distinct probe(s) refused so far); overflowing props take the \ + room's day/night law instead of their own folded probe", + probes.overflow() ); crate::mesh_tag::PropSeat::Matte }