Octree broadphase: exact octant classification, depth-blind retrieve() - #1580
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in the
Octreebroadphase, found while validating theCamera3d+ Octree + SAT stack ahead of the 2.5D platformer example (#1476). Both are invisible to 2D games, which use aQuadTreeand never construct anOctree.1.
retrieve()pruned on depth and silently dropped real collisionsretrieve()descended only into the octant the query item classified into. But every consumer of it decides overlap in the XY plane:physics/builtin/detector.js:288input/pointerevent.ts:327physics/builtin/raycast.ts:213adapter.queryAABBphysics/builtin/builtin-adapter.ts:572Two bodies at different
zthat 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.
getIndexis split intogetIndex+_quadrantXY, andretrieve()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
getIndexand walkingindex ^ 4also 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:index ^ 4_quadrantXYBoth 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, orcollisionType/collisionMask— which is what the 2D path has always done.2. Items sitting exactly ON a midpoint were misfiled to the parent
-1fromgetIndexmeans "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 defaultposof every renderable, and the shared gameplay z the 2.5D recipe prescribes.Measured: 200 bodies on a
z = 0plane all stayed at the root andretrieve()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:247asserted 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:(0,0,0)position_subtreeCountvs real count, no duplicate results)queryAABB,querySphereandretrieve()— 100 queries each, seeded LCG for reproducibilityqueryAABBandquerySpherepassed unchanged: the genuinely-3D queries were already correct and still prune on depth. The differential sweep is what found defect 1.Gates
tsc --noEmitCloses #1581
🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi