1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
//! `SceneSpatial` — the renderer's single-source-of-truth BVH over mesh AABBs.
use glam::Vec3;
use rstar::RTree;
use slotmap::SecondaryMap;
use crate::{bounds::Aabb, frustum::Frustum, meshes::MeshKey};
use super::{
frustum_selector::{frustum_intersects_node, FrustumSelector, Leaf},
node::{aabb_to_rstar_envelope, aabb_to_rstar_rect, SceneNode, SceneNodeFlags},
query::{NodeFilter, SpatialQuery},
};
/// Knobs that scale with mesh count. The renderer picks these once at
/// construction and they're read-only thereafter; tune in profiling.
#[derive(Debug, Clone, Copy)]
pub struct SceneSpatialConfig {
/// Full-tree rebuild threshold (dirties accumulated since last rebuild).
pub rebuild_dirty_threshold: u32,
/// Periodic rebuild cadence in frames, regardless of dirty count.
pub rebuild_period_frames: u32,
}
impl Default for SceneSpatialConfig {
fn default() -> Self {
// Sized for the 1k-mesh tier. A future dynamic-sidecar policy
// could adapt these by total static-node count.
Self {
rebuild_dirty_threshold: 200,
rebuild_period_frames: 600,
}
}
}
/// Spatial index over every mesh's world-space AABB.
///
/// The R*-tree holds the "static" set (everything that doesn't move every
/// frame). Meshes flagged `dynamic` live in `dynamic` instead — they are
/// linearly scanned per query, which is strictly cheaper than churning
/// the tree with their per-frame remove+reinsert cost. Either set can be
/// transitioned via [`SceneSpatial::set_dynamic`].
pub struct SceneSpatial {
rtree: RTree<Leaf>,
dynamic: Vec<SceneNode>,
/// Mirror of every mesh's authoritative AABB + flags. Used to:
/// * look up the previous envelope for an in-place update (rstar
/// has no `update`, so we re-key by remove+insert),
/// * answer flag queries without dereferencing through `Meshes`,
/// * support `query_envelope` / `nearest` from the trait.
nodes: SecondaryMap<MeshKey, SceneNode>,
/// Tracks which `mesh_key` lives in the dynamic sidecar (vs the tree).
/// Stored separately from `SceneNode::flags.dynamic` so we can rely on
/// it during transitions without re-reading the node.
in_dynamic: SecondaryMap<MeshKey, ()>,
/// Dirty count since the last full rebuild. Drives the periodic
/// `RTree::bulk_load` refresh.
dirty_since_rebuild: u32,
/// Frame counter for the periodic rebuild cadence. Bumped once per
/// `rebuild_if_needed` call.
frames_since_rebuild: u32,
config: SceneSpatialConfig,
}
impl Default for SceneSpatial {
fn default() -> Self {
Self::new(SceneSpatialConfig::default())
}
}
impl SceneSpatial {
pub fn new(config: SceneSpatialConfig) -> Self {
Self {
rtree: RTree::new(),
dynamic: Vec::new(),
nodes: SecondaryMap::new(),
in_dynamic: SecondaryMap::new(),
dirty_since_rebuild: 0,
frames_since_rebuild: 0,
config,
}
}
/// Returns the rebuild-cadence config picked at construction.
/// Read-only — the index doesn't expose a setter because the
/// thresholds are baked into the periodic-rebuild loop. Used by
/// [`crate::AwsmRenderer::remove_all`] to preserve the cadence
/// across a scene-clear rebuild.
pub fn config(&self) -> SceneSpatialConfig {
self.config
}
/// Total leaf count (static + dynamic). The debug invariant in
/// `update.rs` asserts this matches the meshes-with-world-aabb count.
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Inserts a node. If a node already exists for `mesh_key`, its prior
/// envelope is removed first (the caller can just call `insert` to
/// replace; `update` is identical for changed-AABB cases).
pub fn insert(&mut self, node: SceneNode) {
if self.nodes.contains_key(node.mesh_key) {
self.remove(node.mesh_key);
}
let is_dynamic = node.flags.dynamic;
if is_dynamic {
self.dynamic.push(node.clone());
self.in_dynamic.insert(node.mesh_key, ());
} else {
self.rtree
.insert(Leaf::new(node.rstar_rect(), node.mesh_key));
}
self.nodes.insert(node.mesh_key, node);
self.dirty_since_rebuild = self.dirty_since_rebuild.saturating_add(1);
}
/// Replaces the AABB of an existing node. Inserts the node if it
/// doesn't yet exist (callers can pass the full `SceneNode` through
/// `insert` instead — `update` is the lighter-weight path when only
/// the AABB has changed).
pub fn update(&mut self, mesh_key: MeshKey, aabb: Aabb) {
let in_dynamic = self.in_dynamic.contains_key(mesh_key);
if !self.nodes.contains_key(mesh_key) {
return;
}
if in_dynamic {
if let Some(slot) = self.dynamic.iter_mut().find(|n| n.mesh_key == mesh_key) {
slot.aabb = aabb.clone();
}
} else {
let prev_rect = self
.nodes
.get(mesh_key)
.map(|n| n.rstar_rect())
.expect("nodes.contains_key was just checked");
// rstar has no in-place mutation; locate the leaf by exact
// envelope + data, then reinsert with the new envelope.
self.rtree.remove(&Leaf::new(prev_rect, mesh_key));
self.rtree
.insert(Leaf::new(aabb_to_rstar_rect(&aabb), mesh_key));
self.dirty_since_rebuild = self.dirty_since_rebuild.saturating_add(1);
}
if let Some(node) = self.nodes.get_mut(mesh_key) {
node.aabb = aabb;
}
}
/// Removes a node. No-op if the key is unknown.
pub fn remove(&mut self, mesh_key: MeshKey) {
let Some(node) = self.nodes.remove(mesh_key) else {
return;
};
if self.in_dynamic.remove(mesh_key).is_some() {
self.dynamic.retain(|n| n.mesh_key != mesh_key);
} else {
self.rtree.remove(&Leaf::new(node.rstar_rect(), mesh_key));
self.dirty_since_rebuild = self.dirty_since_rebuild.saturating_add(1);
}
}
/// Sets flags on a live node without re-keying it. The `dynamic`
/// flag is intentionally NOT honored here — moving between the tree
/// and the sidecar happens through [`Self::set_dynamic`] so the caller is
/// explicit about the migration.
pub fn set_flags(&mut self, mesh_key: MeshKey, flags: SceneNodeFlags) {
if let Some(node) = self.nodes.get_mut(mesh_key) {
let preserve_dynamic = node.flags.dynamic;
node.flags = SceneNodeFlags {
dynamic: preserve_dynamic,
..flags
};
}
if let Some(slot) = self.dynamic.iter_mut().find(|n| n.mesh_key == mesh_key) {
let preserve_dynamic = slot.flags.dynamic;
slot.flags = SceneNodeFlags {
dynamic: preserve_dynamic,
..flags
};
}
}
/// Moves a node between the static R*-tree and the dynamic sidecar.
/// Called automatically for skinned/instanced meshes.
pub fn set_dynamic(&mut self, mesh_key: MeshKey, dynamic: bool) {
let Some(node) = self.nodes.get(mesh_key).cloned() else {
return;
};
let already_dynamic = self.in_dynamic.contains_key(mesh_key);
if already_dynamic == dynamic {
if let Some(node_mut) = self.nodes.get_mut(mesh_key) {
node_mut.flags.dynamic = dynamic;
}
return;
}
if dynamic {
self.rtree.remove(&Leaf::new(node.rstar_rect(), mesh_key));
let mut moved = node;
moved.flags.dynamic = true;
self.dynamic.push(moved.clone());
self.in_dynamic.insert(mesh_key, ());
if let Some(stored) = self.nodes.get_mut(mesh_key) {
stored.flags.dynamic = true;
}
// The `rtree.remove` above erodes tree quality the same way
// `remove` / `update` do; without bumping the dirty counter
// a large static→dynamic flip would never contribute to
// the rebuild threshold and `rebuild_if_needed` would lose
// its only signal for "tree quality has degraded".
self.dirty_since_rebuild = self.dirty_since_rebuild.saturating_add(1);
} else {
self.dynamic.retain(|n| n.mesh_key != mesh_key);
self.in_dynamic.remove(mesh_key);
self.rtree.insert(Leaf::new(node.rstar_rect(), mesh_key));
if let Some(stored) = self.nodes.get_mut(mesh_key) {
stored.flags.dynamic = false;
}
self.dirty_since_rebuild = self.dirty_since_rebuild.saturating_add(1);
}
}
/// Whether `mesh_key` is currently routed through the dynamic sidecar.
pub fn is_dynamic(&self, mesh_key: MeshKey) -> bool {
self.in_dynamic.contains_key(mesh_key)
}
/// Force a full `RTree::bulk_load` rebuild on the next
/// [`Self::rebuild_if_needed`] call. Called by the renderer when a large
/// batch of inserts has just landed (asset stream-in, scene swap).
pub fn mark_rebuild_needed(&mut self) {
self.dirty_since_rebuild = u32::MAX;
}
/// Single per-frame maintenance entry point. Called once after
/// `update_transforms` in `update_all`. Rebuilds the tree from
/// scratch when dirty pressure crosses the configured threshold,
/// which restores R*-tree query quality that successive remove+
/// insert pairs have eroded.
pub fn rebuild_if_needed(&mut self) {
self.frames_since_rebuild = self.frames_since_rebuild.saturating_add(1);
let cadence_due = self.frames_since_rebuild >= self.config.rebuild_period_frames;
let dirty_due = self.dirty_since_rebuild >= self.config.rebuild_dirty_threshold;
if !cadence_due && !dirty_due {
return;
}
let leaves: Vec<Leaf> = self
.nodes
.iter()
.filter(|(key, _)| !self.in_dynamic.contains_key(*key))
.map(|(key, node)| Leaf::new(node.rstar_rect(), key))
.collect();
self.rtree = RTree::bulk_load(leaves);
self.dirty_since_rebuild = 0;
self.frames_since_rebuild = 0;
}
/// Iterate over every node currently in the dynamic sidecar.
pub fn dynamic_iter(&self) -> impl Iterator<Item = &SceneNode> {
self.dynamic.iter()
}
/// Iterate every node in the index (tree + sidecar). Order is
/// unspecified.
pub fn iter_all(&self) -> impl Iterator<Item = &SceneNode> {
self.nodes.values()
}
/// Borrow the node for a mesh, if present.
pub fn get(&self, mesh_key: MeshKey) -> Option<&SceneNode> {
self.nodes.get(mesh_key)
}
// ── Internal queries used by both the trait and the borrowing API ──
/// Frustum query returning borrowed `SceneNode` references.
/// Reserved for the renderer's own call sites where the per-call
/// `Vec` allocation matters. External crates use [`SpatialQuery`].
pub fn query_frustum<'a>(
&'a self,
frustum: &'a Frustum,
filter: NodeFilter,
) -> impl Iterator<Item = &'a SceneNode> + 'a {
let frustum_copy = *frustum;
let selector = FrustumSelector {
frustum: frustum_copy,
};
let from_tree = self
.rtree
.locate_with_selection_function(selector)
.filter_map(move |leaf| self.nodes.get(leaf.data))
.filter(move |node| filter.matches(node));
let from_dyn = self
.dynamic
.iter()
.filter(move |node| frustum_intersects_node(&frustum_copy, node))
.filter(move |node| filter.matches(node));
from_tree.chain(from_dyn)
}
/// AABB-overlap query returning borrowed `SceneNode` references.
pub fn query_envelope<'a>(&'a self, aabb: &Aabb) -> impl Iterator<Item = &'a SceneNode> + 'a {
let envelope = aabb_to_rstar_envelope(aabb);
let from_tree = self
.rtree
.locate_in_envelope_intersecting(&envelope)
.filter_map(move |leaf| self.nodes.get(leaf.data));
let aabb_min = aabb.min;
let aabb_max = aabb.max;
let from_dyn = self
.dynamic
.iter()
.filter(move |node| aabb_overlap(aabb_min, aabb_max, node.aabb.min, node.aabb.max));
from_tree.chain(from_dyn)
}
/// Returns the node whose AABB-center is closest to `point`. Linear
/// scan over the sidecar plus rstar's accelerated tree nearest.
pub fn nearest_node(&self, point: Vec3) -> Option<&SceneNode> {
let tree_candidate = self
.rtree
.nearest_neighbor(&point.to_array())
.and_then(|leaf| self.nodes.get(leaf.data));
let mut best = tree_candidate;
let mut best_dist = best.map(|n| (n.aabb.center() - point).length_squared());
for node in &self.dynamic {
let d = (node.aabb.center() - point).length_squared();
if best_dist.map(|cur| d < cur).unwrap_or(true) {
best = Some(node);
best_dist = Some(d);
}
}
best
}
/// Frustum query restricted to the dynamic sidecar (linear scan).
pub fn query_frustum_dynamic<'a>(
&'a self,
frustum: &'a Frustum,
filter: NodeFilter,
) -> impl Iterator<Item = &'a SceneNode> + 'a {
let frustum_copy = *frustum;
self.dynamic
.iter()
.filter(move |node| frustum_intersects_node(&frustum_copy, node))
.filter(move |node| filter.matches(node))
}
/// Iterate every node whose AABB intersects the frustum, ignoring
/// any flag filter. Used by sites that want the raw geometric set.
pub fn query_frustum_raw<'a>(
&'a self,
frustum: &'a Frustum,
) -> impl Iterator<Item = &'a SceneNode> + 'a {
self.query_frustum(frustum, NodeFilter::default())
}
/// Iterate every node in the index that survives `filter`, regardless
/// of frustum. Used by the directional-cascade fit pass where the
/// "frustum" is the union of all cascade frusta.
pub fn iter_filtered<'a>(
&'a self,
filter: NodeFilter,
) -> impl Iterator<Item = &'a SceneNode> + 'a {
self.nodes.values().filter(move |node| filter.matches(node))
}
}
impl SpatialQuery for SceneSpatial {
fn query_frustum(&self, frustum: &Frustum, filter: NodeFilter) -> Vec<MeshKey> {
SceneSpatial::query_frustum(self, frustum, filter)
.map(|n| n.mesh_key)
.collect()
}
fn query_envelope(&self, aabb: &Aabb) -> Vec<MeshKey> {
SceneSpatial::query_envelope(self, aabb)
.map(|n| n.mesh_key)
.collect()
}
fn nearest(&self, point: Vec3) -> Option<MeshKey> {
SceneSpatial::nearest_node(self, point).map(|n| n.mesh_key)
}
}
fn aabb_overlap(a_min: Vec3, a_max: Vec3, b_min: Vec3, b_max: Vec3) -> bool {
a_min.x <= b_max.x
&& a_max.x >= b_min.x
&& a_min.y <= b_max.y
&& a_max.y >= b_min.y
&& a_min.z <= b_max.z
&& a_max.z >= b_min.z
}