Skip to content

perf(collision): add spatial hash grid for broad-phase collision culling#8289

Open
killerdevildog wants to merge 4 commits into4ian:masterfrom
killerdevildog:optimize/spatial-hash-collision
Open

perf(collision): add spatial hash grid for broad-phase collision culling#8289
killerdevildog wants to merge 4 commits into4ian:masterfrom
killerdevildog:optimize/spatial-hash-collision

Conversation

@killerdevildog
Copy link

Replace O(N×M) brute-force pair enumeration in hitBoxesCollisionTest with a spatial hash grid that only tests nearby object pairs, dramatically reducing collision detection cost for scenes with many objects.

Problem

The existing hitBoxesCollisionTest delegates to twoListsTest, which tests every object in list 1 against every object in list 2 — O(N×M) complexity. At N=1000 objects this takes ~43ms per collision check, exceeding the entire 16.7ms frame budget at 60fps.

Solution

  • New file: GDJS/Runtime/spatial-hash-grid.ts — A generic SpatialHashGrid<T> class that maps items by their AABB into fixed-size grid cells. Uses hash-based cell keys (prime multiplication + XOR) and pools internal arrays to minimize GC pressure.

  • Modified: GDJS/Runtime/events-tools/objecttools.tshitBoxesCollisionTest now builds a spatial hash grid when total object count ≥ 32, inserting list-2 objects by AABB and querying only nearby candidates for each list-1 object. Falls back to the original twoListsTest for small lists where grid overhead is not worthwhile.

Key design decisions:

  • Adaptive cell size: 2× average object dimension (minimum 32px) — keeps ~1–4 objects per cell
  • Threshold of 32 objects: Below this, brute-force is faster than grid construction overhead
  • Grid is reused across frames (module-level singleton) — only cleared and potentially resized, never reallocated
  • Same pick/trim semantics as original twoListsTest — behavioral parity preserved

Benchmark Results

N objects Brute-force (mean) Spatial Hash (mean) Speedup
50 0.097ms 0.021ms 4.7×
100 0.414ms 0.048ms 8.6×
250 2.696ms 0.224ms 12.0×
500 11.05ms 0.800ms 13.8×
1000 43.29ms 3.417ms 12.7×

At N=1000, collision detection drops from ~43ms (2.6× over frame budget) to ~3.4ms (well within budget).

Scaling also improves: brute-force N=1000 is 442× slower than N=50; spatial hash N=1000 is only 167× slower than N=50.

Benchmark Code

The benchmark suite used to produce these results is available as benchmarks.patch in the repo root. Apply and run with:
```bash
git apply benchmarks.patch
cd benchmarks && npm install && npx vitest bench --run
```

Testing

  • Baseline verified by stashing optimization and re-running benchmarks (numbers matched saved baseline within normal variance)
  • Optimization verified across multiple runs with consistent 5–14× speedup
  • Small-list fallback ensures no regression for scenes with few objects

benchmarks.patch

@killerdevildog killerdevildog requested a review from 4ian as a code owner February 17, 2026 01:16
@killerdevildog killerdevildog force-pushed the optimize/spatial-hash-collision branch from e24bb00 to 7a48dbb Compare February 17, 2026 02:40
Replace O(N×M) brute-force pair enumeration in hitBoxesCollisionTest with
a spatial hash grid that only tests nearby object pairs.

Changes:
- Add GDJS/Runtime/spatial-hash-grid.ts: Generic SpatialHashGrid<T> class
  that maps items by AABB into fixed-size grid cells. Supports insert and
  region query operations. Uses hash-based cell keys (prime multiplication
  + XOR) to avoid Map overhead of string keys. Pools internal arrays to
  reduce GC pressure during repeated clear/rebuild cycles.

- Modify GDJS/Runtime/events-tools/objecttools.ts: hitBoxesCollisionTest
  now uses SpatialHashGrid when total object count >= 32. For each frame:
  1. Computes adaptive cell size (2× average object dimension, min 32px)
  2. Inserts all list-2 objects by their AABB
  3. For each list-1 object, queries grid for nearby candidates only
  4. Runs existing RuntimeObject.collisionTest (bounding circle + SAT)
     only on candidate pairs instead of all pairs
  Falls back to original twoListsTest for small lists (< 32 objects)
  where grid overhead would exceed brute-force cost.

Benchmark results (N objects, all-pairs collision check):
  N=50:   0.097ms → 0.021ms  (4.7× faster)
  N=100:  0.414ms → 0.048ms  (8.6× faster)
  N=250:  2.696ms → 0.224ms  (12.0× faster)
  N=500:  11.05ms → 0.800ms  (13.8× faster)
  N=1000: 43.29ms → 3.417ms  (12.7× faster)

At N=1000 the brute-force path consumed ~43ms (exceeding the 16.7ms
frame budget at 60fps). The spatial hash reduces this to ~3.4ms.
@killerdevildog killerdevildog force-pushed the optimize/spatial-hash-collision branch from 7a48dbb to 49bc40d Compare February 17, 2026 02:47
@4ian
Copy link
Owner

4ian commented Feb 17, 2026

Would it be possible to have a benchmark that runs hitBoxesCollisionTest before/after the improvement (or, more easily and more flexible, by allowing to change _SPATIAL_HASH_MIN_OBJECTS: set it to "Infinity" to try brute force, set it to a smaller number to try the spatial hashing).

The reason for benchmarking hitBoxesCollisionTest directly is to check what is the impact of re-populating the grid at every frame. We may discover the _SPATIAL_HASH_MIN_OBJECTS might need a different value.

Let us know once you have a version that benchmark hitBoxesCollisionTest directly (like what the game engine could do by calling it every frame, and possibly even multiple times a frame) so we can compare bruteforce (_SPATIAL_HASH_MIN_OBJECTS = Infinity), the existing threshold (32) and a different threshold (100?) for N=50,100,250,5000,1000 objects.

@killerdevildog
Copy link
Author

@4ian I'll work on it.

@killerdevildog
Copy link
Author

Benchmark Findings: Spatial Hash Collision Optimization

Comprehensive benchmarking of gdjs.evtTools.object.hitBoxesCollisionTest() on both master (brute-force) and optimize/spatial-hash-collision branches confirms the spatial hash optimization delivers significant performance improvements for medium to large object counts, with speedups ranging from 1.89× at N=100 to 10.87× at N=1000, while avoiding overhead for small lists through the threshold=32 default that skips spatial hashing when total object count is below 32 (effectively tied at N=50, slightly slower at N=2/10 as expected). Testing was performed using Vitest v4.0.18 across N=2,10,50,100,250,500,1000 objects per list with three threshold modes (Infinity for pure brute-force, 32 for current default, 100 for alternative), and results show the grid rebuild cost is fully amortized by query savings despite rebuilding the spatial hash grid on every call, with the optimization scaling exactly as expected from O(N×M) to O(N+M) complexity reduction. The default threshold=32 is well-calibrated to avoid overhead for tiny lists while capturing performance gains at N≥100, with no regression on the brute-force path when the threshold is not met, making this optimization production-ready for games with 100+ active collision objects. See collision.baseline.md for master branch numbers, collision.after_optimization.md for full optimization results, and collision.bench.ts for implementation details.

benchmark-implementation.patch

…head

Remove for..in key-counting fast-path that added ~15% overhead at N=2.
Instead, flatten once and branch: for small N (<32 total objects) inline
the brute-force pair iteration using already-flattened arrays; for large
N use spatial hash grid. This eliminates both the key-counting cost and
the double-flatten that occurred when calling twoListsTest after
hitBoxesCollisionTest had already flattened.
@killerdevildog
Copy link
Author

benchmarks.patch

Spatial Hash Collision Optimization — Design Notes

Why Earlier Commits Were Slower at Small N

Attempt 1: Flatten + delegate to twoListsTest

The first implementation flattened both ObjectsLists via .values() inside hitBoxesCollisionTest, counted the total objects, and then — for small lists below the spatial hash threshold — called twoListsTest to run the brute-force pairs. The problem: twoListsTest internally calls .values() again to flatten the same lists into its own static arrays. This double-flatten added 18–27% overhead at N=2, turning a ~360ns call into ~440ns.

Attempt 2: for..in key-counting fast-path (threshold = 26)

To avoid the double-flatten, a fast-path was added: before any flattening, count the number of keys in each ObjectsLists.items using a for..in loop. If both lists had exactly 1 key and fewer than 26 objects each, skip straight to twoListsTest (which would do the only flatten). The threshold of 26 was determined via a dedicated tuning benchmark (fastpath-tuning.bench.ts) that tested values 0, 5, 10, 16, 20, 26, and 32 across N=2..50.

However, the for..in key-counting loop itself added ~40ns overhead per call — enough to make N=2 15% slower than master (0.85x). At N=10 it was 9% slower (0.91x). The overhead was invisible at larger N but unacceptable for the most common case.

Final Fix: Inline brute-force with single flatten

The solution was to eliminate both problems at once:

  1. Always flatten once — call .values() exactly once per list at the top of hitBoxesCollisionTest
  2. Count using the flattened arrays — sum .length of each sub-array (trivial integer additions, no for..in)
  3. For N < 32: inline the same brute-force pair iteration that twoListsTest uses, but operate on the already-flattened arrays — no second flatten, no key-counting
  4. For N ≥ 32: use the spatial hash grid (also operates on the already-flattened arrays)

This means the small-N path does exactly the same work as master's twoListsTest (flatten → reset picks → iterate pairs → trim), just without the indirection of a function call that re-flattens.


Benchmark Results

All benchmarks run on Linux / Node.js with Vitest v4.0.18 (tinybench), seeded RNG for deterministic layouts.

Master Baseline (brute-force twoListsTest)

N ops/s mean (ms) rme samples
2 2,763,839 0.0004 ±0.84% 1,381,920
10 1,280,669 0.0008 ±0.46% 640,335
50 123,929 0.0081 ±2.04% 61,965
100 33,281 0.0300 ±0.32% 16,641
250 5,449 0.1835 ±0.26% 2,725
500 1,369 0.7306 ±0.15% 685
1000 343 2.9160 ±0.31% 172

Optimization Branch (inlined brute-force + spatial hash, threshold = 32)

N ops/s mean (ms) rme samples
2 2,651,478 0.0004 ±0.85% 1,325,739
10 1,248,256 0.0008 ±0.50% 624,129
50 117,225 0.0085 ±2.01% 58,613
100 67,776 0.0148 ±1.53% 33,889
250 25,083 0.0399 ±1.06% 12,542
500 10,304 0.0971 ±1.36% 5,152
1000 3,724 0.2685 ±1.25% 1,863

Comparison

N master optimized speedup notes
2 2,763,839 2,651,478 0.96x within run-to-run noise (±0.85%)
10 1,280,669 1,248,256 0.97x within run-to-run noise (±0.50%)
50 123,929 117,225 0.95x effectively tied
100 33,281 67,776 2.04x spatial hash kicks in
250 5,449 25,083 4.60x
500 1,369 10,304 7.53x
1000 343 3,724 10.86x

@4ian
Copy link
Owner

4ian commented Feb 18, 2026

Very good, this benchmark looks much better and I can reproduce this locally.
We will want to bake these benchmarks as part of the GDJS/tests at some point. For now, it might not be useful.

Main request:

  • This is effectively now an improved twoListTest that benefits from spatial hashing after a threshold. Let's extract this into a function twoListsTestWithSpatialHashing that takes a predicate. So that the implementation of hitBoxesCollisionTest boils down to calling twoListsTestWithSpatialHashing and passing somehow gdjs.RuntimeObject.collisionTest.

The reason for this is so that this could, in another PR/later, be probably adapted to other functions doing similar work like distanceTest, movesTowardTest, isTurnedTowardObject (with some additional callback/parameter allowing to set how queryToArray should be used and ensure it's efficient - it's more work so can be done later). In practice, collisionTest is the most used condition, and distanceTest then, so it's good to start with collisionTest which will be the most impactful.

Here are a few AI generated review points that would be good to address:

  • Self-collision when objectsLists1 and objectsLists2 share the same list:
    The id check (obj1.id !== obj2.id) prevents an object from colliding with itself, but if the same object appears in both lists (which is valid in GDevelop — e.g., "check collision of Enemies with Enemies"), the code will correctly test pairs. However, each pair will be tested twice (A vs B and B vs A). The pick optimization short-circuits the second test only if both are already picked, but for the inverted path, this double-testing could cause subtle logic issues since atLeastOneObject is tracked per-obj1.
  • Add a comment about hash-collision duplicates. They are fine, collision test itself is correct (it does a real hitbox check), so this doesn't cause incorrect results.
  • queryToArray doesn't deduplicate. Documented, and handled by the pick flag — but if this class is ever reused outside the collision pipeline (the class is generic ), callers might be surprised. Consider documenting the deduplication responsibility more prominently in the class-level doc.
  • Array pool grows without bound:
    _pooledArrays accumulates arrays as cells are cleared, but they're never released. In a scene with a one-time spike of 10,000 cells, those 10,000 empty arrays persist forever. For a game this is probably negligible, but a trimPool(maxSize) method would be cheap insurance.
  • Use Math.floor consistently for all four cell coordinate calculations, or use a helper like (val >= 0 ? val : val - 1) | 0.
  • Minor: redundant |0 after Math.floor

Once this is cleaned, we can probably go ahead with this.

- Extract predicate-agnostic twoListsTestWithSpatialHashing so
  hitBoxesCollisionTest simply delegates with collisionTest predicate.
- SpatialHashGrid: add _toCell() helper for consistent negative-coord
  handling, removing redundant |0 after Math.floor.
- SpatialHashGrid: add trimPool(maxSize) to cap unbounded pool growth.
- SpatialHashGrid: document hash-collision safety and queryToArray
  duplicate-result responsibility at class and method level.
- Document self-collision pick-flag behaviour in JSDoc.
@killerdevildog
Copy link
Author

killerdevildog commented Feb 18, 2026

1. Extract twoListsTestWithSpatialHashing

Extracted the full spatial-hash + brute-force logic into a new predicate-agnostic function twoListsTestWithSpatialHashing(predicate, objectsLists1, objectsLists2, inverted, extraArg). hitBoxesCollisionTest now delegates to it in a single line, passing gdjs.RuntimeObject.collisionTest as the predicate. This allows future reuse by distanceTest, movesTowardTest, isTurnedTowardObject, etc.

2. Self-collision documentation

Added JSDoc on twoListsTestWithSpatialHashing explaining that when objectsLists1 and objectsLists2 share the same list (e.g. "Enemies collide with Enemies"), pairs are tested from both directions. The pick flag short-circuits the second test when !inverted; in the inverted path, atLeastOneObject is tracked per-obj1, matching existing twoListsTest semantics.

3. Hash-collision comment

Added JSDoc on _hashKey() documenting that hash collisions are harmless — they only cause extra candidates in queryToArray, but every candidate is validated by the full collision predicate, so correctness is unaffected.

4. queryToArray dedup responsibility

Added prominent documentation at both the class level and the queryToArray method level explaining that items spanning multiple cells may appear more than once in results, and callers are responsible for de-duplication. Notes that the collision pipeline handles this via pick flags.

5. trimPool(maxSize)

Added trimPool(maxSize: number) method to SpatialHashGrid to cap unbounded pool growth after one-time spikes (e.g. a scene with many temporary objects).

6. Consistent Math.floor / _toCell() helper

Replaced all 8 cell coordinate calculations (4 in insert, 4 in queryToArray) with a new _toCell(v) helper that correctly handles negative coordinates: c >= 0 ? c | 0 : Math.floor(c).

7. Removed redundant |0 after Math.floor

The old code had Math.floor(x) | 0 — the |0 was redundant since Math.floor already returns an integer. The new _toCell() helper uses |0 only for the positive fast path and Math.floor only for negative values, with no redundancy.

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.

2 participants