• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 19938015860

04 Dec 2025 05:29PM UTC coverage: 88.653%. Remained the same
19938015860

Pull #3598

github

web-flow
Merge 15b7b6381 into 1be15c84b
Pull Request #3598: Docs migrate playground

5297 of 7233 branches covered (73.23%)

14673 of 16551 relevant lines covered (88.65%)

24575.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.22
/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts
1
import type { FrameStats } from '../../Debug/DebugConfig';
2
import type { Entity } from '../../EntityComponentSystem';
3
import type { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
4
import { createId } from '../../Id';
5
import type { Ray } from '../../Math/ray';
6
import type { Vector } from '../../Math/vector';
7
import { vec } from '../../Math/vector';
8
import { Pool } from '../../Util/Pool';
9
import { BodyComponent } from '../BodyComponent';
10
import type { BoundingBox } from '../BoundingBox';
11
import type { Collider } from '../Colliders/Collider';
12
import { CompositeCollider } from '../Colliders/CompositeCollider';
13
import { CollisionType } from '../CollisionType';
14
import { CollisionGroup } from '../Group/CollisionGroup';
15
import type { CollisionContact } from './CollisionContact';
16
import type { CollisionProcessor } from './CollisionProcessor';
17
import { Pair } from './Pair';
18
import type { RayCastHit } from './RayCastHit';
19
import type { RayCastOptions } from './RayCastOptions';
20
import { HashGridCell, HashGridProxy, SparseHashGrid } from './SparseHashGrid';
21

22
/**
23
 * Proxy type to stash collision info
24
 */
25
export class HashColliderProxy extends HashGridProxy<Collider> {
26
  id: number = -1;
355✔
27
  owner: Entity;
28
  body?: BodyComponent;
29
  collisionType: CollisionType;
30
  hasZeroBounds = false;
355✔
31

32
  /**
33
   * References to the hash cell the collider is a current member of
34
   */
35
  cells: HashGridCell<Collider, HashColliderProxy>[] = [];
355✔
36
  /**
37
   * Grid size in pixels
38
   */
39
  declare readonly gridSize: number;
40
  constructor(
41
    public collider: Collider,
355✔
42
    gridSize: number
43
  ) {
44
    super(collider, gridSize);
355✔
45
    this.gridSize = gridSize;
355✔
46
    const bounds = collider.bounds;
355✔
47
    this.hasZeroBounds = bounds.hasZeroDimensions();
355✔
48
    this.leftX = Math.floor(bounds.left / this.gridSize);
355✔
49
    this.rightX = Math.floor(bounds.right / this.gridSize);
355✔
50
    this.bottomY = Math.floor(bounds.bottom / this.gridSize);
355✔
51
    this.topY = Math.floor(bounds.top / this.gridSize);
355✔
52
    this.owner = collider.owner;
355✔
53
    this.body = this.owner?.get(BodyComponent);
355!
54
    this.collisionType = this.body?.collisionType ?? CollisionType.PreventCollision;
355!
55
  }
56

57
  /**
58
   * Updates the hashed bounds coordinates
59
   */
60
  update(): void {
61
    super.update();
86✔
62
    this.body = this.owner?.get(BodyComponent);
86!
63
    this.collisionType = this.body?.collisionType ?? CollisionType.PreventCollision;
86!
64
    this.hasZeroBounds = this.collider.localBounds.hasZeroDimensions();
86✔
65
  }
66
}
67

68
/**
69
 * This collision processor uses a sparsely populated grid of uniform cells to bucket potential
70
 * colliders together for the purpose of detecting collision pairs and collisions.
71
 */
72
export class SparseHashGridCollisionProcessor implements CollisionProcessor {
73
  readonly gridSize: number;
74
  readonly hashGrid: SparseHashGrid<Collider, HashColliderProxy>;
75

76
  private _pairs = new Set<string>();
1,930✔
77
  private _nonPairs = new Set<string>();
1,930✔
78

79
  public _pairPool = new Pool<Pair>(
1,930✔
80
    () => new Pair({ id: createId('collider', 0) } as Collider, { id: createId('collider', 0) } as Collider),
39✔
81
    (instance) => {
82
      instance.colliderA = null;
108✔
83
      instance.colliderB = null;
108✔
84
      return instance;
108✔
85
    },
86
    200
87
  );
88

89
  constructor(options: { size: number }) {
90
    this.gridSize = options.size;
1,930✔
91
    this.hashGrid = new SparseHashGrid<Collider, HashColliderProxy>({
1,930✔
92
      size: this.gridSize,
93
      proxyFactory: (collider, size) => new HashColliderProxy(collider, size)
355✔
94
    });
95
    this._pairPool.disableWarnings = true;
1,930✔
96

97
    // TODO dynamic grid size potentially larger than the largest collider
98
    // TODO Re-hash the objects if the median proves to be different
99
  }
100

101
  getColliders(): readonly Collider[] {
102
    return Array.from(this.hashGrid.objectToProxy.keys());
535✔
103
  }
104

105
  query(point: Vector): Collider[];
106
  query(bound: BoundingBox): Collider[];
107
  query(boundsOrPoint: Vector | BoundingBox): Collider[] {
108
    // FIXME workaround TS: https://github.com/microsoft/TypeScript/issues/14107
109
    return this.hashGrid.query(boundsOrPoint as any);
51✔
110
  }
111

112
  rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] {
113
    // DDA raycast algo
114
    const results: RayCastHit[] = [];
20✔
115
    const maxDistance = options?.maxDistance ?? Infinity;
20✔
116
    const collisionGroup = options?.collisionGroup;
20✔
117
    const collisionMask = !collisionGroup ? (options?.collisionMask ?? CollisionGroup.All.category) : collisionGroup.category;
20✔
118
    const searchAllColliders = options?.searchAllColliders ?? false;
20✔
119

120
    const unitRay = ray.dir.normalize();
20✔
121

122
    const dydx = unitRay.y / unitRay.x;
20✔
123
    const dxdy = unitRay.x / unitRay.y;
20✔
124

125
    const unitStepX = Math.sqrt(1 + dydx * dydx) * this.gridSize;
20✔
126
    const unitStepY = Math.sqrt(1 + dxdy * dxdy) * this.gridSize;
20✔
127

128
    const startXCoord = ray.pos.x / this.gridSize;
20✔
129
    const startYCoord = ray.pos.y / this.gridSize;
20✔
130

131
    const stepDir = vec(1, 1);
20✔
132

133
    let currentXCoord = ~~startXCoord;
20✔
134
    let currentYCoord = ~~startYCoord;
20✔
135
    let currentRayLengthX = 0;
20✔
136
    let currentRayLengthY = 0;
20✔
137

138
    if (unitRay.x < 0) {
20!
139
      stepDir.x = -1;
×
140
      currentRayLengthX = (startXCoord - currentXCoord) * unitStepX;
×
141
    } else {
142
      stepDir.x = 1;
20✔
143
      currentRayLengthX = (currentXCoord + 1 - startXCoord) * unitStepX;
20✔
144
    }
145

146
    if (unitRay.y < 0) {
20!
147
      stepDir.y = -1;
×
148
      currentRayLengthY = (startYCoord - currentYCoord) * unitStepY;
×
149
    } else {
150
      stepDir.y = 1;
20✔
151
      currentRayLengthY = (currentYCoord + 1 - startYCoord) * unitStepY;
20✔
152
    }
153

154
    const collidersVisited = new Set<number>();
20✔
155

156
    let done = false;
20✔
157
    let maxIterations = 9999;
20✔
158
    while (!done && maxIterations > 0) {
20✔
159
      maxIterations--; // safety exit
87✔
160
      // exit if exhausted max hash grid coordinate, bounds of the sparse grid
161
      if (!this.hashGrid.bounds.contains(vec(currentXCoord * this.gridSize, currentYCoord * this.gridSize))) {
87✔
162
        break;
15✔
163
      }
164
      // Test colliders at cell
165
      const key = HashGridCell.calculateHashKey(currentXCoord, currentYCoord);
72✔
166
      const cell = this.hashGrid.sparseHashGrid.get(key);
72✔
167
      if (cell) {
72✔
168
        const cellHits: RayCastHit[] = [];
64✔
169
        for (let colliderIndex = 0; colliderIndex < cell.proxies.length; colliderIndex++) {
64✔
170
          const collider = cell.proxies[colliderIndex];
81✔
171
          if (!collidersVisited.has(collider.collider.id.value)) {
81✔
172
            collidersVisited.add(collider.collider.id.value);
47✔
173
            if (collider.body) {
47!
174
              if (options?.ignoreCollisionGroupAll && collider.body.group === CollisionGroup.All) {
47✔
175
                continue;
4✔
176
              }
177

178
              const canCollide = (collisionMask & collider.body.group.category) !== 0;
43✔
179

180
              // Early exit if not the right group
181
              if (collider.body.group && !canCollide) {
43✔
182
                continue;
2✔
183
              }
184
            }
185
            const hit = collider.collider.rayCast(ray, maxDistance);
41✔
186

187
            // Collect up all the colliders that hit inside a cell
188
            // they can be in any order so we need to sort them next
189
            if (hit) {
41✔
190
              cellHits.push(hit);
38✔
191
            }
192
          }
193
        }
194
        cellHits.sort((hit1, hit2) => hit1.distance - hit2.distance);
64✔
195
        for (let i = 0; i < cellHits.length; i++) {
64✔
196
          const hit = cellHits[i];
37✔
197
          if (options?.filter) {
37✔
198
            if (options.filter(hit)) {
15✔
199
              results.push(hit);
7✔
200
              if (!searchAllColliders) {
7✔
201
                done = true;
2✔
202
                break;
2✔
203
              }
204
            }
205
          } else {
206
            results.push(hit);
22✔
207
            if (!searchAllColliders) {
22✔
208
              done = true;
3✔
209
              break;
3✔
210
            }
211
          }
212
        }
213
      }
214

215
      if (currentRayLengthX < currentRayLengthY) {
72!
216
        currentXCoord += stepDir.x;
72✔
217
        currentRayLengthX += unitStepX;
72✔
218
      } else {
219
        currentYCoord += stepDir.y;
×
220
        currentRayLengthY += unitStepY;
×
221
      }
222
    }
223

224
    // Sort by distance
225
    results.sort((hit1, hit2) => hit1.distance - hit2.distance);
20✔
226
    if (!searchAllColliders && results.length) {
20✔
227
      return [results[0]];
5✔
228
    }
229
    return results;
15✔
230
  }
231

232
  /**
233
   * Adds the collider to the internal data structure for collision tracking
234
   * @param target
235
   */
236
  track(target: Collider): void {
237
    let colliders = [target];
373✔
238
    if (target instanceof CompositeCollider) {
373✔
239
      const compColliders = target.getColliders();
25✔
240
      for (const c of compColliders) {
25✔
241
        c.owner = target.owner;
7✔
242
      }
243
      colliders = compColliders;
25✔
244
    }
245

246
    for (const target of colliders) {
373✔
247
      this.hashGrid.track(target);
355✔
248
    }
249
  }
250

251
  /**
252
   * Removes a collider from the internal data structure for tracking collisions
253
   * @param target
254
   */
255
  untrack(target: Collider): void {
256
    let colliders = [target];
33✔
257
    if (target instanceof CompositeCollider) {
33✔
258
      colliders = target.getColliders();
12✔
259
    }
260

261
    for (const target of colliders) {
33✔
262
      this.hashGrid.untrack(target);
21✔
263
    }
264
  }
265

266
  private _canCollide(colliderA: HashColliderProxy, colliderB: HashColliderProxy) {
267
    // Prevent self collision
268
    if (colliderA.collider.id === colliderB.collider.id) {
1,923!
269
      return false;
×
270
    }
271

272
    // Colliders with the same owner do not collide (composite colliders)
273
    if (colliderA.owner && colliderB.owner && colliderA.owner.id === colliderB.owner.id) {
1,923✔
274
      return false;
4✔
275
    }
276

277
    // if the pair has a member with zero dimension don't collide
278
    if (colliderA.hasZeroBounds || colliderB.hasZeroBounds) {
1,919!
279
      return false;
×
280
    }
281

282
    // If both are in the same collision group short circuit
283
    if (colliderA.body && colliderB.body && !colliderA.body.group.canCollide(colliderB.body.group)) {
1,919!
284
      return false;
×
285
    }
286

287
    // if both are fixed short circuit
288
    if (colliderA.collisionType === CollisionType.Fixed && colliderB.collisionType === CollisionType.Fixed) {
1,919✔
289
      return false;
1,616✔
290
    }
291

292
    // if the either is prevent collision short circuit
293
    if (colliderA.collisionType === CollisionType.PreventCollision || colliderB.collisionType === CollisionType.PreventCollision) {
303✔
294
      return false;
2✔
295
    }
296

297
    // if either is dead short circuit
298
    if (!colliderA.owner.isActive || !colliderB.owner.isActive) {
301✔
299
      return false;
1✔
300
    }
301

302
    return true;
300✔
303
  }
304

305
  /**
306
   * Runs the broadphase sweep over tracked colliders and returns possible collision pairs
307
   * @param targets
308
   * @param elapsed
309
   */
310
  broadphase(targets: Collider[], elapsed: number): Pair[] {
311
    const pairs: Pair[] = [];
1,809✔
312
    this._pairs.clear();
1,809✔
313
    this._nonPairs.clear();
1,809✔
314

315
    let proxyId = 0;
1,809✔
316
    for (const proxy of this.hashGrid.objectToProxy.values()) {
1,809✔
317
      proxy.id = proxyId++; // track proxies we've already processed
1,262✔
318
      if (!proxy.owner.isActive || proxy.collisionType === CollisionType.PreventCollision) {
1,262✔
319
        continue;
21✔
320
      }
321
      // for every cell proxy collider is member of
322
      for (let cellIndex = 0; cellIndex < proxy.cells.length; cellIndex++) {
1,241✔
323
        const cell = proxy.cells[cellIndex];
9,839✔
324
        // TODO Can we skip any cells or make this iteration faster?
325
        // maybe a linked list here
326
        for (let otherIndex = 0; otherIndex < cell.proxies.length; otherIndex++) {
9,839✔
327
          const other = cell.proxies[otherIndex];
19,028✔
328
          if (other.id === proxy.id) {
19,028✔
329
            // skip duplicates
330
            continue;
9,839✔
331
          }
332
          const id = Pair.calculatePairHash(proxy.collider.id, other.collider.id);
9,189✔
333
          if (this._nonPairs.has(id)) {
9,189✔
334
            continue; // Is there a way we can re-use the non-pair cache
7,119✔
335
          }
336
          if (!this._pairs.has(id) && this._canCollide(proxy, other) && proxy.object.bounds.overlaps(other.object.bounds)) {
2,070✔
337
            const pair = this._pairPool.get();
147✔
338
            pair.colliderA = proxy.collider;
147✔
339
            pair.colliderB = other.collider;
147✔
340
            pair.id = id;
147✔
341
            this._pairs.add(id);
147✔
342
            pairs.push(pair);
147✔
343
          } else {
344
            this._nonPairs.add(id);
1,923✔
345
          }
346
        }
347
      }
348
    }
349
    return pairs;
1,809✔
350
  }
351

352
  /**
353
   * Runs a fine grain pass on collision pairs and does geometry intersection tests producing any contacts
354
   * @param pairs
355
   * @param stats
356
   */
357
  narrowphase(pairs: Pair[], stats?: FrameStats): CollisionContact[] {
358
    const contacts: CollisionContact[] = [];
112✔
359
    for (let i = 0; i < pairs.length; i++) {
112✔
360
      const newContacts = pairs[i].collide();
144✔
361
      for (let j = 0; j < newContacts.length; j++) {
144✔
362
        const c = newContacts[j];
144✔
363
        contacts.push(c);
144✔
364
        if (stats) {
144!
365
          stats.physics.contacts.set(c.id, c);
144✔
366
        }
367
      }
368
    }
369
    this._pairPool.done();
112✔
370
    if (stats) {
112!
371
      stats.physics.collisions += contacts.length;
112✔
372
    }
373
    return contacts; // TODO maybe we can re-use contacts as likely pairs next frame
112✔
374
  }
375

376
  /**
377
   * Perform data structure maintenance, returns number of colliders updated
378
   */
379
  update(targets: Collider[], elapsed: number): number {
380
    return this.hashGrid.update(targets);
1,800✔
381
  }
382

383
  /**
384
   * Draws the internal data structure
385
   * @param ex
386
   * @param elapsed
387
   */
388
  debug(ex: ExcaliburGraphicsContext, elapsed: number): void {
389
    this.hashGrid.debug(ex, elapsed);
×
390
  }
391
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc