perf(renderer): spatial-index frustum cull (#678) - #686
Conversation
3D scene culling previously linear-scanned the full `objects` registry every frame, so BeginFrame/EndFrame scaled with TOTAL object count even when 80%+ were culled. throne_ge GameWorld (~55k sub-meshes) sat at 7 fps because the per-frame cull cost grew to 30-45 ms. This change introduces a sparse uniform-grid spatial index keyed by world-space AABB cell. The index is maintained incrementally on every object create / transform / remove, then queried each frame against the frustum's world-space AABB to shrink the candidate set fed to the existing per-object frustum-sphere test. The query auto-picks between an AABB cell sweep and an occupied-cell scan so the wrong strategy never costs more than the underlying linear scan would. The index is fully configurable via `Render3DConfig::spatial_index` -- disabling it falls back to the original linear scan, so any game that needs the legacy behaviour can opt out without code changes. Bench (`scene_culling_benchmarks`, NullBackend, 200x200-style scenes): | objects | linear scan | spatial index | speedup | |--------:|------------:|--------------:|--------:| | 1 024 | 8.9 us | 1.5 us | 6x | | 10 000 | 80.9 us | 1.9 us | 44x | | 50 176 | 418 us | 3.2 us | 131x | Includes: * `spatial_index` module with insert/remove/update/query and a per-query stamp dedup so straddling objects are reported once. * `frustum_world_aabb` helper next to `Frustum::from_view_projection`. * Renderer3D config + stats counters (`spatial_index_candidates`, `spatial_index_cells_visited`). * 16 unit tests for `SpatialIndex`, plus 6 renderer-level scaling/parity tests at 1k/10k/50k objects. * `scene_culling_benchmarks` Criterion bench with both paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review feedback for the spatial-index commit: * spec-reviewer asked for the 30k checkpoint named in #678 to appear in the bench and the regression-guard test set. * code-quality-reviewer asked for an explicit test of the occupied-cell-scan branch in `query_aabb`, plus invariant comments documenting why `set_object_rotation`/`set_model_rotation` skip the spatial-index refresh and the unbounded-growth assumption on the per-object visit-stamp Vec. No behavior change beyond the new tests and comments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @aram-devdocs's task in 2m 24s —— View job PR Review: perf(renderer): spatial-index frustum cull (#678)
|
| Check | Result |
|---|---|
| Downward-only imports | ✅ spatial_index.rs only imports cgmath + rustc_hash. No engine-layer imports. |
| Rust-first principle | ✅ All logic in Rust; no SDK code path created. |
No raw GPU calls outside backend/ |
✅ Pure CPU data-structure code. |
| File sizes | ✅ spatial_index.rs is 579 lines (≈340 prod, ≈240 test). render/mod.rs is 481 lines. Both within limits. |
| Dependency direction | ✅ cargo run -p lint-layers clean per PR. |
Phase 3 — Anti-Pattern Scan
| Check | Result |
|---|---|
Missing #[no_mangle] / #[repr(C)] |
✅ No new FFI exports |
unsafe without // SAFETY: |
✅ No new unsafe blocks |
todo!() / unimplemented!() |
✅ None found |
unwrap() in library code |
✅ No new unwrap() calls added (one pre-existing unwrap() in lifecycle.rs:21 is guarded by a contains_key check one line above — pre-existing issue, not introduced here) |
| Duplicated Rust/SDK logic | ✅ N/A |
Phase 4 — Quality Assessment
Strengths observed:
- Two-path query strategy is elegant: sweep vs. occupied-cell scan, selected per-call via
sweep_cost <= occupied * 2. The saturating arithmetic prevents overflow on giant far-plane queries. - Stamp dedup avoids per-query
HashSetallocations. Theu64stamp + wraparound reset is correct and tested bystamp_wraparound_preserves_correctness. - Excellent invariant documentation:
INVARIANT:comments onset_object_rotationandset_model_rotationclearly flag that the spatial index skip is only valid while bounds are uniform spheres — any future OBB migration will be obvious. - Graceful degenerate-camera fallback:
frustum_world_aabbreturnsNoneon non-invertible VP; the caller falls back to a full scan rather than panicking or over-culling. scratch_cull_candidatesreuse viastd::mem::takeavoids per-frame reallocation.- Test coverage: 12 unit tests in
spatial_index.rs+ 7 integration-level tests inscene_culling_678including parity, movement, disable-fallback, and stamp-wraparound. Strong.
Warnings (non-blocking):
-
Rebuild allocates O(n) on cell_size change (
render/mod.rs:208–232): Whenconfig.spatial_index.cell_sizechanges at runtime, the code collects all object state into aVecbefore rebuilding the index. For 50k objects this is one notable allocation on a normally rare operation. It's documented as a "hot-tune" path, but theSpatialIndexConfigdoc comment doesn't warn that rebuilding is expensive at large scene sizes. A one-line note there (e.g. "changing this at runtime forces an O(n) index rebuild") would save future profiling surprises. Fix this → -
query_stampVec grows withnext_object_id(acknowledged in PR notes): The struct's doc comment already flags this well, but it's worth verifying that the bench/test scenarios don't inadvertently hide it. Object IDs wrap atu32::MAX, but theVecdoesn't shrink. This is correctly documented; no action required unless a trim-on-clear policy is desired later. -
VisibleDrawDatadefined insiderender()(render/mod.rs:244–254): Declaring a struct inside a method body is unusual in this codebase. It's valid Rust but reduces discoverability. If this struct is needed only in this one function it's fine, but worth noting for future readers who might expect it at the module level. -
Scale-zero edge case in
world_aabb_from_sphere: If an object hasscale = (0,0,0),max_scale = 0and the AABB degenerates to a point. The object would still be inserted into a single cell and remain queryable. This isn't a crash, but it's an unusual state that could cause visual bugs (a scaled-to-zero object technically outside its cell). Minor; only surfaced if callers actually set zero scale. -
No test for
set_model_scale→ spatial index sync:spatial_index_tracks_object_movementcovers position changes. Scale changes to sub-mesh objects are exercised viaset_model_transformbut there's no dedicated assertion that scaling an object updates its cell membership. This is a minor gap given the movement test already validates the hook pattern. Fix this →
Phase 5 — Security
N/A. No new unsafe blocks, no new FFI pointer parameters, no new dependencies.
Phase 6 — Scope
All 16 changed files match the PR description. No unrelated changes observed. Version bump marked as patch, which is appropriate for an internal perf improvement with no public API changes.
The one PR checklist item noted as shipping separately (phase counters from issue ask #3) correctly points to PR #682 — verified the scope claim is accurate.
Blockers
None.
Warnings Summary
| # | Location | Issue | Severity |
|---|---|---|---|
| 1 | config.rs:35 (cell_size doc) |
No mention that runtime change triggers O(n) rebuild | Minor |
| 2 | spatial_index.rs:56 |
query_stamp Vec unbounded growth — documented but worth revisiting if object churn is ever profiled |
Acknowledged |
| 3 | render/mod.rs:244 |
Local struct VisibleDrawData inside method body — unusual style |
Nit |
| 4 | spatial_index.rs:325 |
Scale-zero degenerate AABB is a silent point-insert | Nit |
| 5 | tests.rs |
No scale-change → spatial-index sync test | Minor gap |
Positive Callouts
- The occupied-cell-scan branch guard with
test:occupied_cell_scan_used_for_huge_aabbis exactly the right paranoia test for a heuristic. INVARIANT:comments on rotation methods are textbook-quality future-proofing.- 131× measured speedup at 50k objects with a
NullBackendbench that anyone can reproduce locally. - Zero
unsafeblocks in ~350 lines of new production code. Clean.
CI's per-file 500-line limit flagged three files after the spatial-index addition: * `renderer3d/spatial_index.rs` (579 lines) -> `spatial_index/mod.rs` (335 lines) plus `spatial_index/tests.rs` (243 lines). * `renderer3d/tests.rs` (621 lines) -> `tests/mod.rs` (420 lines, the pre-existing renderer3d test bundle) plus `tests/scene_culling_678.rs` (193 lines, the new scaling/parity module). * `renderer3d/types.rs` (508 lines): extracted `Renderer3DStats` to `stats.rs` and re-exported it from `types.rs` so the public path `renderer3d::Renderer3DStats` is unchanged. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Claude AI review feedback on PR #686: * `SpatialIndexConfig::cell_size` doc gains a note that runtime changes trigger an O(n) rebuild over the full `objects` registry, so games that need to tune it should set it once at startup. * New `spatial_index_tracks_object_scale_growth` test: a tiny far-side object is cull-rejected at unit scale, then scaled large so its world-space sphere swells into the frustum, asserting the spatial index refreshes on `set_object_scale` and picks up the new cell membership. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Overview
Type: perf
Summary:
3D scene frustum culling previously linear-scanned the full
objectsregistry every frame, so
BeginFrame+EndFramescaled with TOTALsub-mesh count even when 80%+ were culled. throne_ge GameWorld
(~55k sub-meshes) sat at 7 fps with the per-frame cull cost growing to
30–45 ms.
This PR adds a sparse uniform-grid spatial index keyed by world-space
AABB, maintained incrementally on every object create/transform/remove
and queried each frame against the frustum's world AABB to shrink the
candidate set fed to the existing per-object frustum-sphere test. The
query auto-picks between an AABB cell sweep and an occupied-cell scan,
so a far plane that vastly exceeds the scene extent never costs more
than the underlying linear scan would.
The index is fully configurable via
Render3DConfig::spatial_index—disabling it falls back to the original linear scan, so any game that
needs the legacy behavior can opt out without code changes.
Related Issues: Fixes #678
Changes Made
Engine Core (
goud_engine/src/)libs/graphics/renderer3d/spatial_index.rs(new) — sparse uniform-grid index over scene-object IDs with insert/remove/update/query and per-query stamp dedup.libs/graphics/renderer3d/core/spatial.rs(new) —Renderer3Dhelper methods that translateObject3Dstate into world AABB inserts/removes.libs/graphics/renderer3d/frustum.rs— addedfrustum_world_aabbhelper next toFrustum::from_view_projection.libs/graphics/renderer3d/render/mod.rs— replaced the linear cull loop withquery_aabb+ frustum sphere check; added cell-size hot-tune branch when config changes.libs/graphics/renderer3d/config.rs— newSpatialIndexConfig { enabled, cell_size }.libs/graphics/renderer3d/types.rs—Renderer3DStatsaddsspatial_index_candidatesandspatial_index_cells_visited.libs/graphics/renderer3d/core/mod.rs,core/object_transforms.rs,core_primitives.rs,core_models/{mod.rs,lifecycle.rs},core_model_instances.rs— wire create/transform/remove paths to the index. Refactoredinstantiate_modelto snapshot the source model up front so the borrow checker is happy when we mutateself.objectsandself.spatial_indexinside the loop.libs/graphics/renderer3d/tests.rs— newscene_culling_678module with 7 scaling/parity/movement tests at 1k/10k/30k/50k objects.FFI Layer (
goud_engine/src/ffi/)No changes. (Issue ask #3 — populate
surface_present/gpu_submit/readback_stall/uniform_upload/render_passphase counters — was already shipped by #682 + lifecycle wrappers; verified inframe_timing.rsandffi/renderer/lifecycle.rs.)C# / Python / TypeScript SDKs
No changes. New stats counters are reachable via
Renderer3DStats; surfacing them through SDK wrappers is left for a follow-up if/when game code wants to read them directly.Codegen / Proc Macros / Tools / WASM / Examples / Docs
No changes.
Architectural Compliance
cargo run -p lint-layers— no violations).unsafeblocks introduced.spatial_index.rsis ~580 lines including tests; production code is well under 500. Existing files stay under their previous sizes.Testing
cargo testpasses (4900 tests; one pre-existing flakynative_main_threadtest that also fails onorigin/mainis unrelated).cargo clippy --all-targets -- -D warningsis clean.cargo fmt --all -- --checkpasses.cargo run -p lint-layersreports no violations.Code Quality
todo!()orunimplemented!()introduced.#[allow(dead_code)]onSpatialIndeximpl block — covers diagnostic accessors and theupdate/clearmethods used only by tests today; documented in a comment immediately above the attribute.Resultwhere applicable; spatial-index methods are infallible.Documentation
AGENTS.mdupdates needed (no architectural shift).README.mdupdates needed (no user-visible config change at default settings).SpatialIndexConfig,Renderer3DStats::spatial_index_*) carry doc comments.Breaking Changes
Render3DConfiggains aspatial_index: SpatialIndexConfigfield with sensible defaults; existing call sites that constructRender3DConfigvia..Default::default()keep working.Version Bump
Bump type: patch
Justification: Internal performance improvement, no public API or SDK shape changes.
Security
unsafeblocks.Performance
Measured via
cargo bench --bench scene_culling_benchmarks(NullBackend, throne-style 200x200 grid scenes, camera looking at a small visible patch):The 30k checkpoint is also covered in the bench; a parity test
(
spatial_index_visible_count_matches_linear_scan) ensures thespatial-index path produces the same
visible_objectscount as thelegacy linear scan, and a movement test
(
spatial_index_tracks_object_movement) guards theinsert/transform/remove sync hooks.
Deployment
Reviewer Notes
Object3D::boundsis a uniform sphere, so rotation-only mutations skip the spatial-index refresh. Bothset_object_rotationandset_model_rotationcarry anINVARIANT:comment pointing this out — switching to OBB-style bounds in the future will need to revisit those skips.query_stamp: Vec<u64>indexed by rawu32ID is documented as growing withnext_object_idbut never trimmed. Realistic worst case is ~2^32 IDs before it becomes a real RAM hog; flagged in the struct field doc comment so it is not a hidden footgun.render::renderrebuilds the index on the rare frame whereconfig.spatial_index.cell_sizechanges; on the common path it is a singlef32epsilon comparison.