Skip to content

Octree broadphase: exact octant classification, depth-blind retrieve() - #1580

Merged
obiot merged 1 commit into
masterfrom
octree-broadphase-fixes
Aug 9, 2026
Merged

Octree broadphase: exact octant classification, depth-blind retrieve()#1580
obiot merged 1 commit into
masterfrom
octree-broadphase-fixes

Conversation

@obiot

@obiot obiot commented Aug 9, 2026

Copy link
Copy Markdown
Member

Two defects in the Octree broadphase, found while validating the Camera3d + Octree + SAT stack ahead of the 2.5D platformer example (#1476). Both are invisible to 2D games, which use a QuadTree and never construct an Octree.

1. retrieve() pruned on depth and silently dropped real collisions

retrieve() descended only into the octant the query item classified into. But every consumer of it decides overlap in the XY plane:

consumer call site
SAT collision detection physics/builtin/detector.js:288
pointer picking / hit-testing input/pointerevent.ts:327
2D raycast physics/builtin/raycast.ts:213
adapter.queryAABB physics/builtin/builtin-adapter.ts:572

Two bodies at different z that overlap in XY genuinely collide under 2D SAT — and were never offered to each other as candidates. Whether a given pair got tested came down to which side of an octant boundary each happened to fall on.

Measured on a randomized 300-body scene: 12 of 20 genuinely overlapping pairs never surfaced.

getIndex is split into getIndex + _quadrantXY, and retrieve() now uses the latter — classify on x/y only, walk both depth halves of that quadrant. x/y pruning still applies at every level and in both halves, since an item lying wholly inside a different x/y quadrant cannot overlap, and midpoint-straddlers live at the parent and are visited regardless.

Worth recording: going through getIndex and walking index ^ 4 also restores correctness, but costs ~4× the nodes visited — the sibling rejects the item on its own depth out-of-bounds guard and falls back to an unpruned 8-way walk of that subtree. Per-probe fan-out on 600 bodies:

scene before (buggy) index ^ 4 _quadrantXY
z spread ±4000 10.8 cand / 8.7 nodes 96.8 / 91.2 24.5 / 22.9
z = 0 plane 24.6 / 6.6 24.6 / 10.5 24.6 / 10.5

Both scenes share a seed, so their x/y distributions are identical — and under correct depth-blind retrieval they converge on the same candidate count, which is the tell that depth has genuinely left the decision. The old 10.8 was low only because it was dropping 60% of real pairs.

This removes the incidental "parallax at a distant z drops out of collision for free" behaviour that the 2.5D wiki page described as best-effort. It was this defect seen from its good side. Exclude parallax deliberately instead — isKinematic = true, or collisionType / collisionMask — which is what the 2D path has always done.

2. Items sitting exactly ON a midpoint were misfiled to the parent

-1 from getIndex means "straddles a midpoint, keep at this level". But a point-z item cannot straddle the depth midpoint at all, and an item whose far edge merely touches a vertical midpoint lies wholly inside the near child.

It matters because the root box is origin-centred, so its midpoints are (0, 0, 0) — the default pos of every renderable, and the shared gameplay z the 2.5D recipe prescribes.

Measured: 200 bodies on a z = 0 plane all stayed at the root and retrieve() returned 200 of 200 — a linear scan for exactly the layer holding the most bodies. The same 200 spread across z left only 10 at the root.

Classification is now exact on all three axes; a midpoint belongs to the far / right / bottom child. Genuine straddlers and out-of-bounds items still stay at the parent, both under regression test.

octree.spec.js:247 asserted the old depth-midpoint behaviour as correct — which is why this survived. It's flipped, with the reasoning recorded in the test.

Tests

New tests/octree_adversarial.spec.js:

  • midpoint ties on all three axes, plus the default (0,0,0) position
  • regression guards that genuine straddlers and out-of-bounds items still stay at the parent
  • gameplay-plane partitioning, with a z-spread control
  • structural invariants under randomized insert/remove churn (_subtreeCount vs real count, no duplicate results)
  • randomized differential testing against a brute-force scan for queryAABB, querySphere and retrieve() — 100 queries each, seeded LCG for reproducibility

queryAABB and querySphere passed unchanged: the genuinely-3D queries were already correct and still prune on depth. The differential sweep is what found defect 1.

Gates

gate result
full suite 234 files, 5851 passed, 9 skipped, 0 failed
octree + adversarial + quadtree 183/183
eslint 0 errors, 10 warnings (all pre-existing)
biome clean
tsc --noEmit clean

Closes #1581

🤖 Generated with Claude Code

https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi

Two defects found while validating the Camera3d + Octree + SAT stack for
the 2.5D platformer example (#1476), plus an adversarial/differential
sweep that found the second one.

1. retrieve() pruned on depth and silently dropped real collisions.

   It descended only into the octant the query item classified into. But
   every consumer of retrieve() decides overlap in the XY plane — the SAT
   detector, pointer picking, the 2D raycast, adapter.queryAABB. Two
   bodies at different z that overlap in XY genuinely collide under 2D
   SAT and were never offered to each other as candidates; whether a pair
   got tested came down to which side of an octant boundary each fell on.
   Measured on a randomized 300-body scene: 12 of 20 genuinely
   overlapping pairs never surfaced.

   getIndex is split into getIndex + _quadrantXY, and retrieve() now uses
   the latter: classify on x/y only, walk both depth halves of that
   quadrant. x/y pruning still applies at every level and in both halves.
   Going through getIndex and walking `index ^ 4` also restores
   correctness but costs ~4x the nodes visited, because the sibling
   rejects the item on its depth out-of-bounds guard and falls back to an
   unpruned 8-way walk.

2. Items sitting exactly ON a midpoint were misfiled to the parent.

   -1 means "straddles a midpoint, keep at this level". A point-z item
   cannot straddle the depth midpoint, and an item whose far edge merely
   touches a vertical one lies wholly inside the near child. The root box
   is origin-centred, so its midpoints are (0, 0, 0) — the default pos of
   every renderable and the shared gameplay z the 2.5D recipe prescribes.
   Measured: 200 bodies on a z=0 plane all stayed at the root and
   retrieve() returned 200 of 200; the same 200 spread across z left 10.

   Classification is now exact on all three axes; the midpoint belongs to
   the far/right/bottom child. Genuine straddlers and out-of-bounds items
   still stay at the parent, both under regression test.

octree.spec.js:247 asserted the old depth-midpoint behaviour as correct,
which is why this survived; it is flipped with the reasoning recorded.

Adds tests/octree_adversarial.spec.js: midpoint ties on all three axes,
gameplay-plane partitioning, structural invariants under random churn,
and randomized differential testing of queryAABB / querySphere /
retrieve() against a brute-force scan. queryAABB and querySphere passed
unchanged — the 3D queries were already correct and still prune on depth.

Invisible to 2D games, which use a QuadTree and never build an Octree.

Full suite 234 files / 5851 pass, eslint 0 errors (no new warnings),
biome clean, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Copilot AI lite review requested due to automatic review settings August 9, 2026 00:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 18868a7 into master Aug 9, 2026
8 of 9 checks passed
@obiot
obiot deleted the octree-broadphase-fixes branch August 9, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Octree broadphase: depth pruning drops real collisions, and midpoint items are misfiled to the root

2 participants