probes: a prop shares a slot with one that agrees, not only one identical
The interior prop-probe table holds 8192 and cannot be widened — 13 mesh-tag bits, with every bit of that u32 already spoken for. It keyed on exact coefficient bits, so two props shared a slot only when no MOLR light reached either *and* their baked MODD colours matched to the last bit. Measured in Ironforge at grom's login spot: 3,883 distinct probes standing still, 5,543 at a far clip of 777, and his session saturated during the world load — after which every further interior prop falls back to the room's flat day/night law. The distinctness is the per-prop baked colour, not the prop's position: folding the probe on a 16 yd grid instead of each prop's own centre removed only 4.5% of the slots. Rounding the *key* onto a 1/32 step removes 35%. A shared slot keeps the first claimer's exact rows, so the quantization decides who shares and never reaches the light the shader reads. Verified by render, not by argument: that Ironforge storeroom at the new default is byte-identical to exact keying across 1.44M pixels, while starving the table outright (the new `WOW_PROBE_CAP=0`) moves 109,830 of them by up to 14/255 — so the lane does drive this view, and the sharing costs nothing measurable in it. The table now also counts the distinct probes it refused, and all three overflow warnings report it. 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, and until now the log could not tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=<f>` 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=<n>` 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<usize> = 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<f32> = 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<ProbeKey>,
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user