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

excaliburjs / Excalibur / 15354777440

30 May 2025 08:03PM UTC coverage: 87.858% (-1.5%) from 89.344%
15354777440

Pull #3385

github

web-flow
Merge a00f57733 into e6ec66358
Pull Request #3385: updated Meet action to add tolerance

5002 of 6948 branches covered (71.99%)

3 of 5 new or added lines in 2 files covered. (60.0%)

872 existing lines in 83 files now uncovered.

13661 of 15549 relevant lines covered (87.86%)

25187.01 hits per line

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

94.19
/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;
349✔
27
  owner: Entity;
28
  body: BodyComponent;
29
  collisionType: CollisionType;
30
  hasZeroBounds = false;
349✔
31
  /**
32
   * left bounds x hash coordinate
33
   */
34
  leftX: number;
35
  /**
36
   * right bounds x hash coordinate
37
   */
38
  rightX: number;
39
  /**
40
   * bottom bounds y hash coordinate
41
   */
42
  bottomY: number;
43
  /**
44
   * top bounds y hash coordinate
45
   */
46
  topY: number;
47
  /**
48
   * References to the hash cell the collider is a current member of
49
   */
50
  cells: HashGridCell<Collider, HashColliderProxy>[] = [];
349✔
51
  /**
52
   * Grid size in pixels
53
   */
54
  readonly gridSize: number;
55
  constructor(
56
    public collider: Collider,
349✔
57
    gridSize: number
58
  ) {
59
    super(collider, gridSize);
349✔
60
    this.gridSize = gridSize;
349✔
61
    const bounds = collider.bounds;
349✔
62
    this.hasZeroBounds = bounds.hasZeroDimensions();
349✔
63
    this.leftX = Math.floor(bounds.left / this.gridSize);
349✔
64
    this.rightX = Math.floor(bounds.right / this.gridSize);
349✔
65
    this.bottomY = Math.floor(bounds.bottom / this.gridSize);
349✔
66
    this.topY = Math.floor(bounds.top / this.gridSize);
349✔
67
    this.owner = collider.owner;
349✔
68
    this.body = this.owner?.get(BodyComponent);
349!
69
    this.collisionType = this.body.collisionType ?? CollisionType.PreventCollision;
349!
70
  }
71

72
  /**
73
   * Updates the hashed bounds coordinates
74
   */
75
  update(): void {
76
    super.update();
86✔
77
    this.body = this.owner?.get(BodyComponent);
86!
78
    this.collisionType = this.body.collisionType ?? CollisionType.PreventCollision;
86!
79
    this.hasZeroBounds = this.collider.localBounds.hasZeroDimensions();
86✔
80
  }
81
}
82

83
/**
84
 * This collision processor uses a sparsely populated grid of uniform cells to bucket potential
85
 * colliders together for the purpose of detecting collision pairs and collisions.
86
 */
87
export class SparseHashGridCollisionProcessor implements CollisionProcessor {
88
  readonly gridSize: number;
89
  readonly hashGrid: SparseHashGrid<Collider, HashColliderProxy>;
90

91
  private _pairs = new Set<string>();
1,902✔
92
  private _nonPairs = new Set<string>();
1,902✔
93

94
  public _pairPool = new Pool<Pair>(
1,902✔
95
    () => new Pair({ id: createId('collider', 0) } as Collider, { id: createId('collider', 0) } as Collider),
39✔
96
    (instance) => {
97
      instance.colliderA = null;
108✔
98
      instance.colliderB = null;
108✔
99
      return instance;
108✔
100
    },
101
    200
102
  );
103

104
  constructor(options: { size: number }) {
105
    this.gridSize = options.size;
1,902✔
106
    this.hashGrid = new SparseHashGrid<Collider, HashColliderProxy>({
1,902✔
107
      size: this.gridSize,
108
      proxyFactory: (collider, size) => new HashColliderProxy(collider, size)
349✔
109
    });
110
    this._pairPool.disableWarnings = true;
1,902✔
111

112
    // TODO dynamic grid size potentially larger than the largest collider
113
    // TODO Re-hash the objects if the median proves to be different
114
  }
115

116
  getColliders(): readonly Collider[] {
117
    return Array.from(this.hashGrid.objectToProxy.keys());
531✔
118
  }
119

120
  query(point: Vector): Collider[];
121
  query(bound: BoundingBox): Collider[];
122
  query(boundsOrPoint: Vector | BoundingBox): Collider[] {
123
    // FIXME workaround TS: https://github.com/microsoft/TypeScript/issues/14107
124
    return this.hashGrid.query(boundsOrPoint as any);
46✔
125
  }
126

127
  rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] {
128
    // DDA raycast algo
129
    const results: RayCastHit[] = [];
20✔
130
    const maxDistance = options?.maxDistance ?? Infinity;
20✔
131
    const collisionGroup = options?.collisionGroup;
20✔
132
    const collisionMask = !collisionGroup ? options?.collisionMask ?? CollisionGroup.All.category : collisionGroup.category;
20✔
133
    const searchAllColliders = options?.searchAllColliders ?? false;
20✔
134

135
    const unitRay = ray.dir.normalize();
20✔
136

137
    const dydx = unitRay.y / unitRay.x;
20✔
138
    const dxdy = unitRay.x / unitRay.y;
20✔
139

140
    const unitStepX = Math.sqrt(1 + dydx * dydx) * this.gridSize;
20✔
141
    const unitStepY = Math.sqrt(1 + dxdy * dxdy) * this.gridSize;
20✔
142

143
    const startXCoord = ray.pos.x / this.gridSize;
20✔
144
    const startYCoord = ray.pos.y / this.gridSize;
20✔
145

146
    const stepDir = vec(1, 1);
20✔
147

148
    let currentXCoord = ~~startXCoord;
20✔
149
    let currentYCoord = ~~startYCoord;
20✔
150
    let currentRayLengthX = 0;
20✔
151
    let currentRayLengthY = 0;
20✔
152

153
    if (unitRay.x < 0) {
20!
154
      stepDir.x = -1;
×
UNCOV
155
      currentRayLengthX = (startXCoord - currentXCoord) * unitStepX;
×
156
    } else {
157
      stepDir.x = 1;
20✔
158
      currentRayLengthX = (currentXCoord + 1 - startXCoord) * unitStepX;
20✔
159
    }
160

161
    if (unitRay.y < 0) {
20!
162
      stepDir.y = -1;
×
UNCOV
163
      currentRayLengthY = (startYCoord - currentYCoord) * unitStepY;
×
164
    } else {
165
      stepDir.y = 1;
20✔
166
      currentRayLengthY = (currentYCoord + 1 - startYCoord) * unitStepY;
20✔
167
    }
168

169
    const collidersVisited = new Set<number>();
20✔
170

171
    let done = false;
20✔
172
    let maxIterations = 9999;
20✔
173
    while (!done && maxIterations > 0) {
20✔
174
      maxIterations--; // safety exit
87✔
175
      // exit if exhausted max hash grid coordinate, bounds of the sparse grid
176
      if (!this.hashGrid.bounds.contains(vec(currentXCoord * this.gridSize, currentYCoord * this.gridSize))) {
87✔
177
        break;
15✔
178
      }
179
      // Test colliders at cell
180
      const key = HashGridCell.calculateHashKey(currentXCoord, currentYCoord);
72✔
181
      const cell = this.hashGrid.sparseHashGrid.get(key);
72✔
182
      if (cell) {
72✔
183
        const cellHits: RayCastHit[] = [];
64✔
184
        for (let colliderIndex = 0; colliderIndex < cell.proxies.length; colliderIndex++) {
64✔
185
          const collider = cell.proxies[colliderIndex];
81✔
186
          if (!collidersVisited.has(collider.collider.id.value)) {
81✔
187
            collidersVisited.add(collider.collider.id.value);
47✔
188

189
            if (options?.ignoreCollisionGroupAll && collider.body.group === CollisionGroup.All) {
47✔
190
              continue;
4✔
191
            }
192

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

195
            // Early exit if not the right group
196
            if (collider.body.group && !canCollide) {
43✔
197
              continue;
2✔
198
            }
199

200
            const hit = collider.collider.rayCast(ray, maxDistance);
41✔
201

202
            // Collect up all the colliders that hit inside a cell
203
            // they can be in any order so we need to sort them next
204
            if (hit) {
41✔
205
              cellHits.push(hit);
38✔
206
            }
207
          }
208
        }
209
        cellHits.sort((hit1, hit2) => hit1.distance - hit2.distance);
64✔
210
        for (let i = 0; i < cellHits.length; i++) {
64✔
211
          const hit = cellHits[i];
37✔
212
          if (options?.filter) {
37✔
213
            if (options.filter(hit)) {
15✔
214
              results.push(hit);
7✔
215
              if (!searchAllColliders) {
7✔
216
                done = true;
2✔
217
                break;
2✔
218
              }
219
            }
220
          } else {
221
            results.push(hit);
22✔
222
            if (!searchAllColliders) {
22✔
223
              done = true;
3✔
224
              break;
3✔
225
            }
226
          }
227
        }
228
      }
229

230
      if (currentRayLengthX < currentRayLengthY) {
72!
231
        currentXCoord += stepDir.x;
72✔
232
        currentRayLengthX += unitStepX;
72✔
233
      } else {
234
        currentYCoord += stepDir.y;
×
UNCOV
235
        currentRayLengthY += unitStepY;
×
236
      }
237
    }
238

239
    // Sort by distance
240
    results.sort((hit1, hit2) => hit1.distance - hit2.distance);
20✔
241
    if (!searchAllColliders && results.length) {
20✔
242
      return [results[0]];
5✔
243
    }
244
    return results;
15✔
245
  }
246

247
  /**
248
   * Adds the collider to the internal data structure for collision tracking
249
   * @param target
250
   */
251
  track(target: Collider): void {
252
    let colliders = [target];
367✔
253
    if (target instanceof CompositeCollider) {
367✔
254
      const compColliders = target.getColliders();
25✔
255
      for (const c of compColliders) {
25✔
256
        c.owner = target.owner;
7✔
257
      }
258
      colliders = compColliders;
25✔
259
    }
260

261
    for (const target of colliders) {
367✔
262
      this.hashGrid.track(target);
349✔
263
    }
264
  }
265

266
  /**
267
   * Removes a collider from the internal data structure for tracking collisions
268
   * @param target
269
   */
270
  untrack(target: Collider): void {
271
    let colliders = [target];
33✔
272
    if (target instanceof CompositeCollider) {
33✔
273
      colliders = target.getColliders();
12✔
274
    }
275

276
    for (const target of colliders) {
33✔
277
      this.hashGrid.untrack(target);
21✔
278
    }
279
  }
280

281
  private _canCollide(colliderA: HashColliderProxy, colliderB: HashColliderProxy) {
282
    // Prevent self collision
283
    if (colliderA.collider.id === colliderB.collider.id) {
1,923!
UNCOV
284
      return false;
×
285
    }
286

287
    // Colliders with the same owner do not collide (composite colliders)
288
    if (colliderA.owner && colliderB.owner && colliderA.owner.id === colliderB.owner.id) {
1,923✔
289
      return false;
4✔
290
    }
291

292
    // if the pair has a member with zero dimension don't collide
293
    if (colliderA.hasZeroBounds || colliderB.hasZeroBounds) {
1,919!
UNCOV
294
      return false;
×
295
    }
296

297
    // If both are in the same collision group short circuit
298
    if (!colliderA.body.group.canCollide(colliderB.body.group)) {
1,919!
UNCOV
299
      return false;
×
300
    }
301

302
    // if both are fixed short circuit
303
    if (colliderA.collisionType === CollisionType.Fixed && colliderB.collisionType === CollisionType.Fixed) {
1,919✔
304
      return false;
1,616✔
305
    }
306

307
    // if the either is prevent collision short circuit
308
    if (colliderA.collisionType === CollisionType.PreventCollision || colliderB.collisionType === CollisionType.PreventCollision) {
303✔
309
      return false;
2✔
310
    }
311

312
    // if either is dead short circuit
313
    if (!colliderA.owner.isActive || !colliderB.owner.isActive) {
301✔
314
      return false;
1✔
315
    }
316

317
    return true;
300✔
318
  }
319

320
  /**
321
   * Runs the broadphase sweep over tracked colliders and returns possible collision pairs
322
   * @param targets
323
   * @param elapsed
324
   */
325
  broadphase(targets: Collider[], elapsed: number): Pair[] {
326
    const pairs: Pair[] = [];
1,800✔
327
    this._pairs.clear();
1,800✔
328
    this._nonPairs.clear();
1,800✔
329

330
    let proxyId = 0;
1,800✔
331
    for (const proxy of this.hashGrid.objectToProxy.values()) {
1,800✔
332
      proxy.id = proxyId++; // track proxies we've already processed
1,257✔
333
      if (!proxy.owner.isActive || proxy.collisionType === CollisionType.PreventCollision) {
1,257✔
334
        continue;
21✔
335
      }
336
      // for every cell proxy collider is member of
337
      for (let cellIndex = 0; cellIndex < proxy.cells.length; cellIndex++) {
1,236✔
338
        const cell = proxy.cells[cellIndex];
9,819✔
339
        // TODO Can we skip any cells or make this iteration faster?
340
        // maybe a linked list here
341
        for (let otherIndex = 0; otherIndex < cell.proxies.length; otherIndex++) {
9,819✔
342
          const other = cell.proxies[otherIndex];
19,008✔
343
          if (other.id === proxy.id) {
19,008✔
344
            // skip duplicates
345
            continue;
9,819✔
346
          }
347
          const id = Pair.calculatePairHash(proxy.collider.id, other.collider.id);
9,189✔
348
          if (this._nonPairs.has(id)) {
9,189✔
349
            continue; // Is there a way we can re-use the non-pair cache
7,119✔
350
          }
351
          if (!this._pairs.has(id) && this._canCollide(proxy, other) && proxy.object.bounds.overlaps(other.object.bounds)) {
2,070✔
352
            const pair = this._pairPool.get();
147✔
353
            pair.colliderA = proxy.collider;
147✔
354
            pair.colliderB = other.collider;
147✔
355
            pair.id = id;
147✔
356
            this._pairs.add(id);
147✔
357
            pairs.push(pair);
147✔
358
          } else {
359
            this._nonPairs.add(id);
1,923✔
360
          }
361
        }
362
      }
363
    }
364
    return pairs;
1,800✔
365
  }
366

367
  /**
368
   * Runs a fine grain pass on collision pairs and does geometry intersection tests producing any contacts
369
   * @param pairs
370
   * @param stats
371
   */
372
  narrowphase(pairs: Pair[], stats?: FrameStats): CollisionContact[] {
373
    const contacts: CollisionContact[] = [];
112✔
374
    for (let i = 0; i < pairs.length; i++) {
112✔
375
      const newContacts = pairs[i].collide();
144✔
376
      for (let j = 0; j < newContacts.length; j++) {
144✔
377
        const c = newContacts[j];
144✔
378
        contacts.push(c);
144✔
379
        if (stats) {
144!
380
          stats.physics.contacts.set(c.id, c);
144✔
381
        }
382
      }
383
    }
384
    this._pairPool.done();
112✔
385
    if (stats) {
112!
386
      stats.physics.collisions += contacts.length;
112✔
387
    }
388
    return contacts; // TODO maybe we can re-use contacts as likely pairs next frame
112✔
389
  }
390

391
  /**
392
   * Perform data structure maintenance, returns number of colliders updated
393
   */
394
  update(targets: Collider[], elapsed: number): number {
395
    return this.hashGrid.update(targets);
1,791✔
396
  }
397

398
  /**
399
   * Draws the internal data structure
400
   * @param ex
401
   * @param elapsed
402
   */
403
  debug(ex: ExcaliburGraphicsContext, elapsed: number): void {
UNCOV
404
    this.hashGrid.debug(ex, elapsed);
×
405
  }
406
}
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