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

excaliburjs / Excalibur / 14804036802

02 May 2025 09:58PM UTC coverage: 5.927% (-83.4%) from 89.28%
14804036802

Pull #3404

github

web-flow
Merge 5c103d7f8 into 0f2ccaeb2
Pull Request #3404: feat: added Graph module to Math

234 of 8383 branches covered (2.79%)

229 of 246 new or added lines in 1 file covered. (93.09%)

13145 existing lines in 208 files now uncovered.

934 of 15759 relevant lines covered (5.93%)

4.72 hits per line

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

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

21
/**
22
 * Proxy type to stash collision info
23
 */
24
export class HashColliderProxy extends HashGridProxy<Collider> {
UNCOV
25
  id: number = -1;
×
26
  owner: Entity;
27
  body: BodyComponent;
28
  collisionType: CollisionType;
UNCOV
29
  hasZeroBounds = false;
×
30
  /**
31
   * left bounds x hash coordinate
32
   */
33
  leftX: number;
34
  /**
35
   * right bounds x hash coordinate
36
   */
37
  rightX: number;
38
  /**
39
   * bottom bounds y hash coordinate
40
   */
41
  bottomY: number;
42
  /**
43
   * top bounds y hash coordinate
44
   */
45
  topY: number;
46
  /**
47
   * References to the hash cell the collider is a current member of
48
   */
UNCOV
49
  cells: HashGridCell<Collider, HashColliderProxy>[] = [];
×
50
  /**
51
   * Grid size in pixels
52
   */
53
  readonly gridSize: number;
54
  constructor(
UNCOV
55
    public collider: Collider,
×
56
    gridSize: number
57
  ) {
UNCOV
58
    super(collider, gridSize);
×
UNCOV
59
    this.gridSize = gridSize;
×
UNCOV
60
    const bounds = collider.bounds;
×
UNCOV
61
    this.hasZeroBounds = bounds.hasZeroDimensions();
×
UNCOV
62
    this.leftX = Math.floor(bounds.left / this.gridSize);
×
UNCOV
63
    this.rightX = Math.floor(bounds.right / this.gridSize);
×
UNCOV
64
    this.bottomY = Math.floor(bounds.bottom / this.gridSize);
×
UNCOV
65
    this.topY = Math.floor(bounds.top / this.gridSize);
×
UNCOV
66
    this.owner = collider.owner;
×
UNCOV
67
    this.body = this.owner?.get(BodyComponent);
×
UNCOV
68
    this.collisionType = this.body.collisionType ?? CollisionType.PreventCollision;
×
69
  }
70

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

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

UNCOV
90
  private _pairs = new Set<string>();
×
UNCOV
91
  private _nonPairs = new Set<string>();
×
92

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

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

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

115
  getColliders(): readonly Collider[] {
UNCOV
116
    return Array.from(this.hashGrid.objectToProxy.keys());
×
117
  }
118

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

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

UNCOV
134
    const unitRay = ray.dir.normalize();
×
135

UNCOV
136
    const dydx = unitRay.y / unitRay.x;
×
UNCOV
137
    const dxdy = unitRay.x / unitRay.y;
×
138

UNCOV
139
    const unitStepX = Math.sqrt(1 + dydx * dydx) * this.gridSize;
×
UNCOV
140
    const unitStepY = Math.sqrt(1 + dxdy * dxdy) * this.gridSize;
×
141

UNCOV
142
    const startXCoord = ray.pos.x / this.gridSize;
×
UNCOV
143
    const startYCoord = ray.pos.y / this.gridSize;
×
144

UNCOV
145
    const stepDir = vec(1, 1);
×
146

UNCOV
147
    let currentXCoord = ~~startXCoord;
×
UNCOV
148
    let currentYCoord = ~~startYCoord;
×
UNCOV
149
    let currentRayLengthX = 0;
×
UNCOV
150
    let currentRayLengthY = 0;
×
151

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

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

UNCOV
168
    const collidersVisited = new Set<number>();
×
169

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

UNCOV
188
            if (options?.ignoreCollisionGroupAll && collider.body.group === CollisionGroup.All) {
×
UNCOV
189
              continue;
×
190
            }
191

UNCOV
192
            const canCollide = (collisionMask & collider.body.group.category) !== 0;
×
193

194
            // Early exit if not the right group
UNCOV
195
            if (collider.body.group && !canCollide) {
×
UNCOV
196
              continue;
×
197
            }
198

UNCOV
199
            const hit = collider.collider.rayCast(ray, maxDistance);
×
200

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

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

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

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

UNCOV
260
    for (const target of colliders) {
×
UNCOV
261
      this.hashGrid.track(target);
×
262
    }
263
  }
264

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

UNCOV
275
    for (const target of colliders) {
×
UNCOV
276
      this.hashGrid.untrack(target);
×
277
    }
278
  }
279

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

286
    // Colliders with the same owner do not collide (composite colliders)
UNCOV
287
    if (colliderA.owner && colliderB.owner && colliderA.owner.id === colliderB.owner.id) {
×
UNCOV
288
      return false;
×
289
    }
290

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

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

301
    // if both are fixed short circuit
UNCOV
302
    if (colliderA.collisionType === CollisionType.Fixed && colliderB.collisionType === CollisionType.Fixed) {
×
UNCOV
303
      return false;
×
304
    }
305

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

311
    // if either is dead short circuit
UNCOV
312
    if (!colliderA.owner.isActive || !colliderB.owner.isActive) {
×
UNCOV
313
      return false;
×
314
    }
315

UNCOV
316
    return true;
×
317
  }
318

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

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

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

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

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