diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a4c718e..dba7d0ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,16 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- You can now query for colliders on the physics world + ```typescript + const scene = ...; + const colliders = scene.physics.query(ex.BoundingBox.fromDimensions(...)); + ``` - `actor.oldGlobalPos` returns the globalPosition from the previous frame - create development builds of excalibur that bundlers can use in dev mode - show warning in development when Entity hasn't been added to a scene after a few seconds - New `RentalPool` type for sparse object pooling +- New `ex.SparseHashGridCollisionProcessor` which is a simpler (and faster) implementation for broadphase pair generation. This works by bucketing colliders into uniform sized square buckets and using that to generate pairs. ### Fixed @@ -33,11 +39,20 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Updates +- Perf improvements to PointerSystem by using new spatial hash grid data structure - Perf improvements: Hot path allocations * Reduce State/Transform stack hot path allocations in graphics context * Reduce Transform allocations * Reduce AffineMatrix allocations +- Perf improvements to `CircleCollider` bounds calculations +- Switch from iterators to c-style loops which bring more speed + * `Entity` component iteration + * `EntityManager` iteration + * `EventEmitter`s + * `GraphicsSystem` entity iteration + * `PointerSystem` entity iteration + ### Changed diff --git a/src/engine/Actions/ActionsSystem.ts b/src/engine/Actions/ActionsSystem.ts index 1ea39c0c9..248a8703f 100644 --- a/src/engine/Actions/ActionsSystem.ts +++ b/src/engine/Actions/ActionsSystem.ts @@ -22,8 +22,9 @@ export class ActionsSystem extends System { }); } update(delta: number): void { - for (const actions of this._actions) { - actions.update(delta); + for (let i = 0; i < this._actions.length; i++) { + const action = this._actions[i]; + action.update(delta); } } } diff --git a/src/engine/Collision/BoundingBox.ts b/src/engine/Collision/BoundingBox.ts index 62bb82562..c148f3a3a 100644 --- a/src/engine/Collision/BoundingBox.ts +++ b/src/engine/Collision/BoundingBox.ts @@ -47,8 +47,23 @@ export class BoundingBox { /** * Returns a new instance of [[BoundingBox]] that is a copy of the current instance */ - public clone(): BoundingBox { - return new BoundingBox(this.left, this.top, this.right, this.bottom); + public clone(dest?: BoundingBox): BoundingBox { + const result = dest || new BoundingBox(0, 0, 0, 0); + result.left = this.left; + result.right = this.right; + result.top = this.top; + result.bottom = this.bottom; + return result; + } + + /** + * Resets the bounds to a zero width/height box + */ + public reset(): void { + this.left = 0; + this.top = 0; + this.bottom = 0; + this.right = 0; } /** @@ -326,13 +341,16 @@ export class BoundingBox { * Combines this bounding box and another together returning a new bounding box * @param other The bounding box to combine */ - public combine(other: BoundingBox): BoundingBox { - const compositeBB = new BoundingBox( - Math.min(this.left, other.left), - Math.min(this.top, other.top), - Math.max(this.right, other.right), - Math.max(this.bottom, other.bottom) - ); + public combine(other: BoundingBox, dest?: BoundingBox): BoundingBox { + const compositeBB = dest || new BoundingBox(0, 0, 0, 0); + const left = Math.min(this.left, other.left); + const top = Math.min(this.top, other.top); + const right = Math.max(this.right, other.right); + const bottom = Math.max(this.bottom, other.bottom); + compositeBB.left = left; + compositeBB.top = top; + compositeBB.right = right; + compositeBB.bottom = bottom; return compositeBB; } diff --git a/src/engine/Collision/Colliders/CircleCollider.ts b/src/engine/Collision/Colliders/CircleCollider.ts index f6e298b69..d735e8e8c 100644 --- a/src/engine/Collision/Colliders/CircleCollider.ts +++ b/src/engine/Collision/Colliders/CircleCollider.ts @@ -45,14 +45,19 @@ export class CircleCollider extends Collider { } private _naturalRadius: number; + + private _radius: number | undefined; /** * Get the radius of the circle */ public get radius(): number { + if (this._radius) { + return this._radius; + } const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; // This is a trade off, the alternative is retooling circles to support ellipse collisions - return this._naturalRadius * Math.min(scale.x, scale.y); + return (this._radius = this._naturalRadius * Math.min(scale.x, scale.y)); } /** @@ -63,6 +68,8 @@ export class CircleCollider extends Collider { const scale = tx?.globalScale ?? Vector.One; // This is a trade off, the alternative is retooling circles to support ellipse collisions this._naturalRadius = val / Math.min(scale.x, scale.y); + this._localBoundsDirty = true; + this._radius = val; } private _transform: Transform; @@ -211,31 +218,20 @@ export class CircleCollider extends Collider { * Get the axis aligned bounding box for the circle collider in world coordinates */ public get bounds(): BoundingBox { - const tx = this._transform; - const scale = tx?.globalScale ?? Vector.One; - const rotation = tx?.globalRotation ?? 0; - const pos = tx?.globalPos ?? Vector.Zero; - return new BoundingBox( - this.offset.x - this._naturalRadius, - this.offset.y - this._naturalRadius, - this.offset.x + this._naturalRadius, - this.offset.y + this._naturalRadius - ) - .rotate(rotation) - .scale(scale) - .translate(pos); + return this.localBounds.transform(this._globalMatrix); } + private _localBoundsDirty = true; + private _localBounds: BoundingBox; /** * Get the axis aligned bounding box for the circle collider in local coordinates */ public get localBounds(): BoundingBox { - return new BoundingBox( - this.offset.x - this._naturalRadius, - this.offset.y - this._naturalRadius, - this.offset.x + this._naturalRadius, - this.offset.y + this._naturalRadius - ); + if (this._localBoundsDirty) { + this._localBounds = new BoundingBox(-this._naturalRadius, -this._naturalRadius, +this._naturalRadius, +this._naturalRadius); + this._localBoundsDirty = false; + } + return this._localBounds; } /** @@ -259,6 +255,7 @@ export class CircleCollider extends Collider { const globalMat = transform.matrix ?? this._globalMatrix; globalMat.clone(this._globalMatrix); this._globalMatrix.translate(this.offset.x, this.offset.y); + this._radius = undefined; } /** diff --git a/src/engine/Collision/Colliders/CompositeCollider.ts b/src/engine/Collision/Colliders/CompositeCollider.ts index 9dd911068..fc33f3cf8 100644 --- a/src/engine/Collision/Colliders/CompositeCollider.ts +++ b/src/engine/Collision/Colliders/CompositeCollider.ts @@ -17,8 +17,21 @@ import { DefaultPhysicsConfig } from '../PhysicsConfig'; export class CompositeCollider extends Collider { private _transform: Transform; - private _collisionProcessor = new DynamicTreeCollisionProcessor(DefaultPhysicsConfig); - private _dynamicAABBTree = new DynamicTree(DefaultPhysicsConfig.dynamicTree); + private _collisionProcessor = new DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + private _dynamicAABBTree = new DynamicTree({ + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + }); private _colliders: Collider[] = []; private _compositeStrategy?: 'separate' | 'together'; diff --git a/src/engine/Collision/CollisionSystem.ts b/src/engine/Collision/CollisionSystem.ts index afd06617a..c49e49939 100644 --- a/src/engine/Collision/CollisionSystem.ts +++ b/src/engine/Collision/CollisionSystem.ts @@ -15,8 +15,8 @@ import { Engine } from '../Engine'; import { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext'; import { Scene } from '../Scene'; import { Side } from '../Collision/Side'; -import { DynamicTreeCollisionProcessor } from './Detection/DynamicTreeCollisionProcessor'; import { PhysicsWorld } from './PhysicsWorld'; +import { CollisionProcessor } from './Detection/CollisionProcessor'; export class CollisionSystem extends System { public systemType = SystemType.Update; public priority = SystemPriority.Higher; @@ -28,7 +28,7 @@ export class CollisionSystem extends System { private _arcadeSolver: ArcadeSolver; private _lastFrameContacts = new Map(); private _currentFrameContacts = new Map(); - private get _processor(): DynamicTreeCollisionProcessor { + private get _processor(): CollisionProcessor { return this._physics.collisionProcessor; } @@ -73,13 +73,17 @@ export class CollisionSystem extends System { return; } + // TODO do we need to do this every frame? // Collect up all the colliders and update them let colliders: Collider[] = []; - for (const entity of this.query.entities) { + for (let entityIndex = 0; entityIndex < this.query.entities.length; entityIndex++) { + const entity = this.query.entities[entityIndex]; const colliderComp = entity.get(ColliderComponent); const collider = colliderComp?.get(); if (colliderComp && colliderComp.owner?.active && collider) { colliderComp.update(); + + // Flatten composite colliders if (collider instanceof CompositeCollider) { const compositeColliders = collider.getColliders(); if (!collider.compositeStrategy) { @@ -95,7 +99,7 @@ export class CollisionSystem extends System { // Update the spatial partitioning data structures // TODO if collider invalid it will break the processor // TODO rename "update" to something more specific - this._processor.update(colliders); + this._processor.update(colliders, elapsedMs); // Run broadphase on all colliders and locates potential collisions const pairs = this._processor.broadphase(colliders, elapsedMs); @@ -153,7 +157,7 @@ export class CollisionSystem extends System { } debug(ex: ExcaliburGraphicsContext) { - this._processor.debug(ex); + this._processor.debug(ex, 0); } public runContactStartEnd() { diff --git a/src/engine/Collision/Detection/CollisionProcessor.ts b/src/engine/Collision/Detection/CollisionProcessor.ts index 0d61d4f95..fbd8ef7e4 100644 --- a/src/engine/Collision/Detection/CollisionProcessor.ts +++ b/src/engine/Collision/Detection/CollisionProcessor.ts @@ -2,7 +2,12 @@ import { Pair } from './Pair'; import { Collider } from '../Colliders/Collider'; import { CollisionContact } from './CollisionContact'; -import { ExcaliburGraphicsContext } from '../..'; +import { RayCastOptions } from './RayCastOptions'; +import { Ray } from '../../Math/ray'; +import { RayCastHit } from './RayCastHit'; +import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { BoundingBox } from '../BoundingBox'; +import { Vector } from '../../Math/vector'; /** * Definition for collision processor @@ -10,6 +15,38 @@ import { ExcaliburGraphicsContext } from '../..'; * Collision processors are responsible for tracking colliders and identifying contacts between them */ export interface CollisionProcessor { + /** + * + */ + rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[]; + + /** + * Query the collision processor for colliders that contain the point + * @param point + */ + query(point: Vector): Collider[]; + + /** + * Query the collision processor for colliders that overlap with the bounds + * @param bounds + */ + query(bounds: BoundingBox): Collider[]; + + /** + * Get all tracked colliders + */ + getColliders(): readonly Collider[]; + + /** + * Track collider in collision processor + */ + track(target: Collider): void; + + /** + * Untrack collider in collision processor + */ + untrack(target: Collider): void; + /** * Detect potential collision pairs given a list of colliders */ diff --git a/src/engine/Collision/Detection/DynamicTree.ts b/src/engine/Collision/Detection/DynamicTree.ts index b4fc8596a..e0a1bde2c 100644 --- a/src/engine/Collision/Detection/DynamicTree.ts +++ b/src/engine/Collision/Detection/DynamicTree.ts @@ -5,7 +5,7 @@ import { Id } from '../../Id'; import { Entity } from '../../EntityComponentSystem/Entity'; import { BodyComponent } from '../BodyComponent'; import { Color, ExcaliburGraphicsContext } from '../..'; -import { PhysicsConfig } from '../PhysicsConfig'; +import { DynamicTreeConfig } from '../PhysicsConfig'; /** * Dynamic Tree Node used for tracking bounds within the tree @@ -43,11 +43,11 @@ export interface ColliderProxy { * Internally the bounding boxes are organized as a balanced binary tree of bounding boxes, where the leaf nodes are tracked bodies. * Every non-leaf node is a bounding box that contains child bounding boxes. */ -export class DynamicTree> { - public root: TreeNode; - public nodes: { [key: number]: TreeNode }; +export class DynamicTree> { + public root: TreeNode; + public nodes: { [key: number]: TreeNode }; constructor( - private _config: Required['dynamicTree']>, + private _config: Required, public worldBounds: BoundingBox = new BoundingBox(-Number.MAX_VALUE, -Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE) ) { this.root = null; @@ -57,7 +57,7 @@ export class DynamicTree> { /** * Inserts a node into the dynamic tree */ - private _insert(leaf: TreeNode): void { + private _insert(leaf: TreeNode): void { // If there are no nodes in the tree, make this the root leaf if (this.root === null) { this.root = leaf; @@ -169,7 +169,7 @@ export class DynamicTree> { /** * Removes a node from the dynamic tree */ - private _remove(leaf: TreeNode) { + private _remove(leaf: TreeNode) { if (leaf === this.root) { this.root = null; return; @@ -177,7 +177,7 @@ export class DynamicTree> { const parent = leaf.parent; const grandParent = parent.parent; - let sibling: TreeNode; + let sibling: TreeNode; if (parent.left === leaf) { sibling = parent.right; } else { @@ -209,8 +209,8 @@ export class DynamicTree> { /** * Tracks a body in the dynamic tree */ - public trackCollider(collider: T) { - const node = new TreeNode(); + public trackCollider(collider: TProxy) { + const node = new TreeNode(); node.data = collider; node.bounds = collider.bounds; node.bounds.left -= 2; @@ -224,7 +224,7 @@ export class DynamicTree> { /** * Updates the dynamic tree given the current bounds of each body being tracked */ - public updateCollider(collider: T) { + public updateCollider(collider: TProxy) { const node = this.nodes[collider.id.value]; if (!node) { return false; @@ -279,7 +279,7 @@ export class DynamicTree> { /** * Untracks a body from the dynamic tree */ - public untrackCollider(collider: T) { + public untrackCollider(collider: TProxy) { const node = this.nodes[collider.id.value]; if (!node) { return; @@ -292,7 +292,7 @@ export class DynamicTree> { /** * Balances the tree about a node */ - private _balance(node: TreeNode) { + private _balance(node: TreeNode) { if (node === null) { throw new Error('Cannot balance at null node'); } @@ -423,9 +423,9 @@ export class DynamicTree> { * that you are complete with your query and you do not want to continue. Returning false will continue searching * the tree until all possible colliders have been returned. */ - public query(collider: T, callback: (other: T) => boolean): void { + public query(collider: TProxy, callback: (other: TProxy) => boolean): void { const bounds = collider.bounds; - const helper = (currentNode: TreeNode): boolean => { + const helper = (currentNode: TreeNode): boolean => { if (currentNode && currentNode.bounds.overlaps(bounds)) { if (currentNode.isLeaf() && currentNode.data !== collider) { if (callback.call(collider, currentNode.data)) { @@ -448,8 +448,8 @@ export class DynamicTree> { * callback indicates that your are complete with your query and do not want to continue. Return false will continue searching * the tree until all possible bodies that would intersect with the ray have been returned. */ - public rayCastQuery(ray: Ray, max: number = Infinity, callback: (other: T) => boolean): void { - const helper = (currentNode: TreeNode): boolean => { + public rayCastQuery(ray: Ray, max: number = Infinity, callback: (other: TProxy) => boolean): void { + const helper = (currentNode: TreeNode): boolean => { if (currentNode && currentNode.bounds.rayCast(ray, max)) { if (currentNode.isLeaf()) { if (callback.call(ray, currentNode.data)) { @@ -466,8 +466,8 @@ export class DynamicTree> { helper(this.root); } - public getNodes(): TreeNode[] { - const helper = (currentNode: TreeNode): TreeNode[] => { + public getNodes(): TreeNode[] { + const helper = (currentNode: TreeNode): TreeNode[] => { if (currentNode) { return [currentNode].concat(helper(currentNode.left), helper(currentNode.right)); } else { @@ -479,7 +479,7 @@ export class DynamicTree> { public debug(ex: ExcaliburGraphicsContext) { // draw all the nodes in the Dynamic Tree - const helper = (currentNode: TreeNode) => { + const helper = (currentNode: TreeNode) => { if (currentNode) { if (currentNode.isLeaf()) { currentNode.bounds.draw(ex, Color.Green); diff --git a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts index 1576c9111..e5000c078 100644 --- a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts +++ b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts @@ -16,40 +16,9 @@ import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphi import { RayCastHit } from './RayCastHit'; import { DeepRequired } from '../../Util/Required'; import { PhysicsConfig } from '../PhysicsConfig'; - -export interface RayCastOptions { - /** - * Optionally specify the maximum distance in pixels to ray cast, default is Infinity - */ - maxDistance?: number; - /** - * Optionally specify a collision group to target in the ray cast, default is All. - */ - collisionGroup?: CollisionGroup; - /** - * Optionally specify a collision mask to target multiple collision categories - */ - collisionMask?: number; - /** - * Optionally specify to search for all colliders that intersect the ray cast, not just the first which is the default - */ - searchAllColliders?: boolean; - /** - * Optionally ignore things with CollisionGroup.All and only test against things with an explicit group - * - * Default false - */ - ignoreCollisionGroupAll?: boolean; - - /** - * Optionally provide a any filter function to filter on arbitrary qualities of a ray cast hit - * - * Filters run after any collision mask/collision group filtering, it is the last decision - * - * Returning true means you want to include the collider in your results, false means exclude it - */ - filter?: (hit: RayCastHit) => boolean; -} +import { RayCastOptions } from './RayCastOptions'; +import { BoundingBox } from '../BoundingBox'; +import { createId } from '../../Id'; /** * Responsible for performing the collision broadphase (locating potential collisions) and @@ -63,13 +32,47 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor { private _colliders: Collider[] = []; constructor(private _config: DeepRequired) { - this._dynamicCollisionTree = new DynamicTree(_config.dynamicTree); + if (_config.spatialPartition.type === 'dynamic-tree') { + this._dynamicCollisionTree = new DynamicTree(_config.spatialPartition); + } } public getColliders(): readonly Collider[] { return this._colliders; } + public query(point: Vector): Collider[]; + public query(bounds: BoundingBox): Collider[]; + public query(pointOrBounds: Vector | BoundingBox): Collider[] { + const results: Collider[] = []; + if (pointOrBounds instanceof BoundingBox) { + this._dynamicCollisionTree.query( + { + id: createId('collider', -1), + owner: null, + bounds: pointOrBounds + } as Collider, + (other) => { + results.push(other); + return false; + } + ); + } else { + this._dynamicCollisionTree.query( + { + id: createId('collider', -1), + owner: null, + bounds: new BoundingBox(pointOrBounds.x, pointOrBounds.y, pointOrBounds.x, pointOrBounds.y) + } as Collider, + (other) => { + results.push(other); + return false; + } + ); + } + return results; + } + public rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] { const results: RayCastHit[] = []; const maxDistance = options?.maxDistance ?? Infinity; diff --git a/src/engine/Collision/Detection/Pair.ts b/src/engine/Collision/Detection/Pair.ts index 55e1dbf17..f6e2a91b4 100644 --- a/src/engine/Collision/Detection/Pair.ts +++ b/src/engine/Collision/Detection/Pair.ts @@ -22,9 +22,6 @@ export class Pair { * @param colliderB */ public static canCollide(colliderA: Collider, colliderB: Collider) { - const bodyA = colliderA?.owner?.get(BodyComponent); - const bodyB = colliderB?.owner?.get(BodyComponent); - // Prevent self collision if (colliderA.id === colliderB.id) { return false; @@ -40,6 +37,9 @@ export class Pair { return false; } + const bodyA = colliderA?.owner?.get(BodyComponent); + const bodyB = colliderB?.owner?.get(BodyComponent); + // Body's needed for collision in the current state // TODO can we collide without a body? if (!bodyA || !bodyB) { diff --git a/src/engine/Collision/Detection/RayCastOptions.ts b/src/engine/Collision/Detection/RayCastOptions.ts new file mode 100644 index 000000000..3898d1412 --- /dev/null +++ b/src/engine/Collision/Detection/RayCastOptions.ts @@ -0,0 +1,36 @@ +import { CollisionGroup } from '../Group/CollisionGroup'; +import { RayCastHit } from './RayCastHit'; + +export interface RayCastOptions { + /** + * Optionally specify the maximum distance in pixels to ray cast, default is Infinity + */ + maxDistance?: number; + /** + * Optionally specify a collision group to target in the ray cast, default is All. + */ + collisionGroup?: CollisionGroup; + /** + * Optionally specify a collision mask to target multiple collision categories + */ + collisionMask?: number; + /** + * Optionally specify to search for all colliders that intersect the ray cast, not just the first which is the default + */ + searchAllColliders?: boolean; + /** + * Optionally ignore things with CollisionGroup.All and only test against things with an explicit group + * + * Default false + */ + ignoreCollisionGroupAll?: boolean; + + /** + * Optionally provide a any filter function to filter on arbitrary qualities of a ray cast hit + * + * Filters run after any collision mask/collision group filtering, it is the last decision + * + * Returning true means you want to include the collider in your results, false means exclude it + */ + filter?: (hit: RayCastHit) => boolean; +} diff --git a/src/engine/Collision/Detection/SparseHashGrid.ts b/src/engine/Collision/Detection/SparseHashGrid.ts new file mode 100644 index 000000000..2fd57752d --- /dev/null +++ b/src/engine/Collision/Detection/SparseHashGrid.ts @@ -0,0 +1,272 @@ +import { Color } from '../../Color'; +import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { Vector, vec } from '../../Math/vector'; +import { RentalPool } from '../../Util/RentalPool'; +import { BoundingBox } from '../BoundingBox'; + +export class HashGridProxy { + id: number = -1; + /** + * left bounds x hash coordinate + */ + leftX: number; + /** + * right bounds x hash coordinate + */ + rightX: number; + /** + * bottom bounds y hash coordinate + */ + bottomY: number; + /** + * top bounds y hash coordinate + */ + topY: number; + + bounds: BoundingBox; + + cells: HashGridCell[] = []; + hasZeroBounds = false; + /** + * Grid size in pixels + */ + readonly gridSize: number; + constructor( + public object: T, + gridSize: number + ) { + this.gridSize = gridSize; + this.bounds = object.bounds; + this.hasZeroBounds = this.bounds.hasZeroDimensions(); + this.leftX = Math.floor(this.bounds.left / this.gridSize); + this.rightX = Math.floor(this.bounds.right / this.gridSize); + this.bottomY = Math.floor(this.bounds.bottom / this.gridSize); + this.topY = Math.floor(this.bounds.top / this.gridSize); + } + + /** + * Has the hashed bounds changed + */ + hasChanged(): boolean { + const bounds = this.object.bounds; + const leftX = Math.floor(bounds.left / this.gridSize); + const rightX = Math.floor(bounds.right / this.gridSize); + const bottomY = Math.floor(bounds.bottom / this.gridSize); + const topY = Math.floor(bounds.top / this.gridSize); + if (this.leftX !== leftX || this.rightX !== rightX || this.bottomY !== bottomY || this.topY !== topY) { + return true; + } + return false; + } + + /** + * Clears all collider references + */ + clear(): void { + for (const cell of this.cells) { + const index = cell.proxies.indexOf(this); + if (index > -1) { + cell.proxies.splice(index, 1); + } + // TODO reclaim cell in pool if empty? + } + } + + /** + * Updates the hashed bounds coordinates + */ + update(): void { + this.bounds = this.object.bounds; + + this.leftX = Math.floor(this.bounds.left / this.gridSize); + this.rightX = Math.floor(this.bounds.right / this.gridSize); + this.bottomY = Math.floor(this.bounds.bottom / this.gridSize); + this.topY = Math.floor(this.bounds.top / this.gridSize); + this.hasZeroBounds = this.object.bounds.hasZeroDimensions(); + } +} + +export class HashGridCell = HashGridProxy> { + proxies: TProxy[] = []; + key: string; + x: number; + y: number; + + configure(x: number, y: number) { + this.x = x; + this.y = y; + this.key = HashGridCell.calculateHashKey(x, y); + } + + static calculateHashKey(x: number, y: number) { + return `${x}+${y}`; + } +} + +export class SparseHashGrid = HashGridProxy> { + readonly gridSize: number; + readonly sparseHashGrid: Map>; + readonly objectToProxy: Map; + + public bounds = new BoundingBox(); + + private _hashGridCellPool = new RentalPool>( + () => new HashGridCell(), + (instance) => { + instance.configure(0, 0); + instance.proxies.length = 0; + return instance; + }, + 1000 + ); + + private _buildProxy: (object: TObject) => TProxy; + + constructor(options: { size: number; proxyFactory?: (object: TObject, gridSize: number) => TProxy }) { + this.gridSize = options.size; + this.sparseHashGrid = new Map>(); + this.objectToProxy = new Map(); + if (options.proxyFactory) { + this._buildProxy = (object: TObject) => options.proxyFactory(object, this.gridSize); + } else { + this._buildProxy = (object: TObject) => new HashGridProxy(object, this.gridSize) as TProxy; + } + + // TODO dynamic grid size potentially larger than the largest collider + // TODO Re-hash the objects if the median proves to be different + } + + query(point: Vector): TObject[]; + query(bounds: BoundingBox): TObject[]; + query(boundsOrPoint: BoundingBox | Vector): TObject[] { + const results = new Set(); + if (boundsOrPoint instanceof BoundingBox) { + const bounds = boundsOrPoint; + const leftX = Math.floor(bounds.left / this.gridSize); + const rightX = Math.floor(bounds.right / this.gridSize); + const bottomY = Math.floor(bounds.bottom / this.gridSize); + const topY = Math.floor(bounds.top / this.gridSize); + for (let x = leftX; x <= rightX; x++) { + for (let y = topY; y <= bottomY; y++) { + const key = HashGridCell.calculateHashKey(x, y); + // Hash bounds into appropriate cell + const cell = this.sparseHashGrid.get(key); + if (cell) { + for (let i = 0; i < cell.proxies.length; i++) { + if (cell.proxies[i].bounds.intersect(bounds)) { + results.add(cell.proxies[i].object); + } + } + } + } + } + } else { + const point = boundsOrPoint; + const key = HashGridCell.calculateHashKey(Math.floor(point.x / this.gridSize), Math.floor(point.y / this.gridSize)); + // Hash points into appropriate cell + const cell = this.sparseHashGrid.get(key); + if (cell) { + for (let i = 0; i < cell.proxies.length; i++) { + if (cell.proxies[i].bounds.contains(point)) { + results.add(cell.proxies[i].object); + } + } + } + } + return Array.from(results); + } + + get(xCoord: number, yCoord: number): HashGridCell { + const key = HashGridCell.calculateHashKey(xCoord, yCoord); + const cell = this.sparseHashGrid.get(key); + return cell; + } + + private _insert(x: number, y: number, proxy: TProxy): void { + const key = HashGridCell.calculateHashKey(x, y); + // Hash collider into appropriate cell + let cell = this.sparseHashGrid.get(key); + if (!cell) { + cell = this._hashGridCellPool.rent(); + cell.configure(x, y); + this.sparseHashGrid.set(cell.key, cell); + } + cell.proxies.push(proxy); + proxy.cells.push(cell); // TODO dupes, doesn't seem to be a problem + this.bounds.combine(proxy.bounds, this.bounds); + } + + private _remove(x: number, y: number, proxy: TProxy): void { + const key = HashGridCell.calculateHashKey(x, y); + // Hash collider into appropriate cell + const cell = this.sparseHashGrid.get(key); + if (cell) { + const proxyIndex = cell.proxies.indexOf(proxy); + if (proxyIndex > -1) { + cell.proxies.splice(proxyIndex, 1); + } + const cellIndex = proxy.cells.indexOf(cell); + if (cellIndex > -1) { + proxy.cells.splice(cellIndex, 1); + } + if (cell.proxies.length === 0) { + this._hashGridCellPool.return(cell); + this.sparseHashGrid.delete(key); + } + } + } + + track(target: TObject): void { + const proxy = this._buildProxy(target); + this.objectToProxy.set(target, proxy); + for (let x = proxy.leftX; x <= proxy.rightX; x++) { + for (let y = proxy.topY; y <= proxy.bottomY; y++) { + this._insert(x, y, proxy); + } + } + } + + untrack(target: TObject): void { + const proxy = this.objectToProxy.get(target); + if (proxy) { + proxy.clear(); + this.objectToProxy.delete(target); + } + } + + update(targets: TObject[]): number { + let updated = 0; + this.bounds.reset(); + for (const target of targets) { + const proxy = this.objectToProxy.get(target); + if (!proxy) { + continue; + } + if (proxy.hasChanged()) { + // TODO slightly wasteful only remove from changed + for (let x = proxy.leftX; x <= proxy.rightX; x++) { + for (let y = proxy.topY; y <= proxy.bottomY; y++) { + this._remove(x, y, proxy); + } + } + proxy.update(); + // TODO slightly wasteful only add new + for (let x = proxy.leftX; x <= proxy.rightX; x++) { + for (let y = proxy.topY; y <= proxy.bottomY; y++) { + this._insert(x, y, proxy); + } + } + updated++; + } + } + return updated; + } + + debug(ex: ExcaliburGraphicsContext, delta: number): void { + const transparent = Color.Transparent; + const color = Color.White; + for (const cell of this.sparseHashGrid.values()) { + ex.drawRectangle(vec(cell.x * this.gridSize, cell.y * this.gridSize), this.gridSize, this.gridSize, transparent, color, 2); + } + } +} diff --git a/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts b/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts new file mode 100644 index 000000000..eda3df280 --- /dev/null +++ b/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts @@ -0,0 +1,392 @@ +import { FrameStats } from '../../Debug/DebugConfig'; +import { Entity } from '../../EntityComponentSystem'; +import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { createId } from '../../Id'; +import { Ray } from '../../Math/ray'; +import { Vector, vec } from '../../Math/vector'; +import { Pool } from '../../Util/Pool'; +import { BodyComponent } from '../BodyComponent'; +import { BoundingBox } from '../BoundingBox'; +import { Collider } from '../Colliders/Collider'; +import { CompositeCollider } from '../Colliders/CompositeCollider'; +import { CollisionType } from '../CollisionType'; +import { CollisionGroup } from '../Group/CollisionGroup'; +import { CollisionContact } from './CollisionContact'; +import { CollisionProcessor } from './CollisionProcessor'; +import { Pair } from './Pair'; +import { RayCastHit } from './RayCastHit'; +import { RayCastOptions } from './RayCastOptions'; +import { HashGridCell, HashGridProxy, SparseHashGrid } from './SparseHashGrid'; + +/** + * Proxy type to stash collision info + */ +export class HashColliderProxy extends HashGridProxy { + id: number = -1; + owner: Entity; + body: BodyComponent; + collisionType: CollisionType; + hasZeroBounds = false; + /** + * left bounds x hash coordinate + */ + leftX: number; + /** + * right bounds x hash coordinate + */ + rightX: number; + /** + * bottom bounds y hash coordinate + */ + bottomY: number; + /** + * top bounds y hash coordinate + */ + topY: number; + /** + * References to the hash cell the collider is a current member of + */ + cells: HashGridCell[] = []; + /** + * Grid size in pixels + */ + readonly gridSize: number; + constructor( + public collider: Collider, + gridSize: number + ) { + super(collider, gridSize); + this.gridSize = gridSize; + const bounds = collider.bounds; + this.hasZeroBounds = bounds.hasZeroDimensions(); + this.leftX = Math.floor(bounds.left / this.gridSize); + this.rightX = Math.floor(bounds.right / this.gridSize); + this.bottomY = Math.floor(bounds.bottom / this.gridSize); + this.topY = Math.floor(bounds.top / this.gridSize); + this.owner = collider.owner; + this.body = this.owner?.get(BodyComponent); + this.collisionType = this.body.collisionType ?? CollisionType.PreventCollision; + } + + /** + * Updates the hashed bounds coordinates + */ + update(): void { + super.update(); + this.body = this.owner?.get(BodyComponent); + this.collisionType = this.body.collisionType ?? CollisionType.PreventCollision; + this.hasZeroBounds = this.collider.localBounds.hasZeroDimensions(); + } +} + +/** + * This collision processor uses a sparsely populated grid of uniform cells to bucket potential + * colliders together for the purpose of detecting collision pairs and collisions. + */ +export class SparseHashGridCollisionProcessor implements CollisionProcessor { + readonly gridSize: number; + readonly hashGrid: SparseHashGrid; + + private _pairs = new Set(); + private _nonPairs = new Set(); + + public _pairPool = new Pool( + () => new Pair({ id: createId('collider', 0) } as Collider, { id: createId('collider', 0) } as Collider), + (instance) => { + instance.colliderA = null; + instance.colliderB = null; + return instance; + }, + 200 + ); + + constructor(options: { size: number }) { + this.gridSize = options.size; + this.hashGrid = new SparseHashGrid({ + size: this.gridSize, + proxyFactory: (collider, size) => new HashColliderProxy(collider, size) + }); + + // TODO dynamic grid size potentially larger than the largest collider + // TODO Re-hash the objects if the median proves to be different + } + + getColliders(): readonly Collider[] { + return Array.from(this.hashGrid.objectToProxy.keys()); + } + + query(point: Vector): Collider[]; + query(bound: BoundingBox): Collider[]; + query(boundsOrPoint: Vector | BoundingBox): Collider[] { + // FIXME workaround TS: https://github.com/microsoft/TypeScript/issues/14107 + return this.hashGrid.query(boundsOrPoint as any); + } + + rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] { + // DDA raycast algo + const results: RayCastHit[] = []; + const maxDistance = options?.maxDistance ?? Infinity; + const collisionGroup = options?.collisionGroup; + const collisionMask = !collisionGroup ? options?.collisionMask ?? CollisionGroup.All.category : collisionGroup.category; + const searchAllColliders = options?.searchAllColliders ?? false; + + const unitRay = ray.dir.normalize(); + + const dydx = unitRay.y / unitRay.x; + const dxdy = unitRay.x / unitRay.y; + + const unitStepX = Math.sqrt(1 + dydx * dydx) * this.gridSize; + const unitStepY = Math.sqrt(1 + dxdy * dxdy) * this.gridSize; + + const startXCoord = ray.pos.x / this.gridSize; + const startYCoord = ray.pos.y / this.gridSize; + + const stepDir = vec(1, 1); + + let currentXCoord = ~~startXCoord; + let currentYCoord = ~~startYCoord; + let currentRayLengthX = 0; + let currentRayLengthY = 0; + + if (unitRay.x < 0) { + stepDir.x = -1; + currentRayLengthX = (startXCoord - currentXCoord) * unitStepX; + } else { + stepDir.x = 1; + currentRayLengthX = (currentXCoord + 1 - startXCoord) * unitStepX; + } + + if (unitRay.y < 0) { + stepDir.y = -1; + currentRayLengthY = (startYCoord - currentYCoord) * unitStepY; + } else { + stepDir.y = 1; + currentRayLengthY = (currentYCoord + 1 - startYCoord) * unitStepY; + } + + const collidersVisited = new Set(); + + let done = false; + let maxIterations = 9999; + while (!done && maxIterations > 0) { + maxIterations--; // safety exit + // exit if exhausted max hash grid coordinate, bounds of the sparse grid + if (!this.hashGrid.bounds.contains(vec(currentXCoord * this.gridSize, currentYCoord * this.gridSize))) { + break; + } + // Test colliders at cell + const key = HashGridCell.calculateHashKey(currentXCoord, currentYCoord); + const cell = this.hashGrid.sparseHashGrid.get(key); + if (cell) { + for (let colliderIndex = 0; colliderIndex < cell.proxies.length; colliderIndex++) { + const collider = cell.proxies[colliderIndex]; + if (!collidersVisited.has(collider.collider.id.value)) { + collidersVisited.add(collider.collider.id.value); + + if (options?.ignoreCollisionGroupAll && collider.body.group === CollisionGroup.All) { + continue; + } + + const canCollide = (collisionMask & collider.body.group.category) !== 0; + + // Early exit if not the right group + if (collider.body.group && !canCollide) { + continue; + } + + const hit = collider.collider.rayCast(ray, maxDistance); + + if (hit) { + if (options?.filter) { + if (options.filter(hit)) { + results.push(hit); + if (!searchAllColliders) { + done = true; + break; + } + } + } else { + results.push(hit); + if (!searchAllColliders) { + done = true; + break; + } + } + } + } + } + } + + if (currentRayLengthX < currentRayLengthY) { + currentXCoord += stepDir.x; + currentRayLengthX += unitStepX; + } else { + currentYCoord += stepDir.y; + currentRayLengthY += unitStepY; + } + } + + return results; + } + + /** + * Adds the collider to the internal data structure for collision tracking + * @param target + */ + track(target: Collider): void { + let colliders = [target]; + if (target instanceof CompositeCollider) { + const compColliders = target.getColliders(); + for (const c of compColliders) { + c.owner = target.owner; + } + colliders = compColliders; + } + + for (const target of colliders) { + this.hashGrid.track(target); + } + } + + /** + * Removes a collider from the internal data structure for tracking collisions + * @param target + */ + untrack(target: Collider): void { + let colliders = [target]; + if (target instanceof CompositeCollider) { + colliders = target.getColliders(); + } + + for (const target of colliders) { + this.hashGrid.untrack(target); + } + } + + private _canCollide(colliderA: HashColliderProxy, colliderB: HashColliderProxy) { + // Prevent self collision + if (colliderA.collider.id === colliderB.collider.id) { + return false; + } + + // Colliders with the same owner do not collide (composite colliders) + if (colliderA.owner && colliderB.owner && colliderA.owner.id === colliderB.owner.id) { + return false; + } + + // if the pair has a member with zero dimension don't collide + if (colliderA.hasZeroBounds || colliderB.hasZeroBounds) { + return false; + } + + // If both are in the same collision group short circuit + if (!colliderA.body.group.canCollide(colliderB.body.group)) { + return false; + } + + // if both are fixed short circuit + if (colliderA.collisionType === CollisionType.Fixed && colliderB.collisionType === CollisionType.Fixed) { + return false; + } + + // if the either is prevent collision short circuit + if (colliderA.collisionType === CollisionType.PreventCollision || colliderB.collisionType === CollisionType.PreventCollision) { + return false; + } + + // if either is dead short circuit + if (!colliderA.owner.active || !colliderB.owner.active) { + return false; + } + + return true; + } + + /** + * Runs the broadphase sweep over tracked colliders and returns possible collision pairs + * @param targets + * @param delta + */ + broadphase(targets: Collider[], delta: number): Pair[] { + const pairs: Pair[] = []; + this._pairs.clear(); + this._nonPairs.clear(); + + let proxyId = 0; + for (const proxy of this.hashGrid.objectToProxy.values()) { + proxy.id = proxyId++; // track proxies we've already processed + if (!proxy.owner.active || proxy.collisionType === CollisionType.PreventCollision) { + continue; + } + // for every cell proxy collider is member of + for (let cellIndex = 0; cellIndex < proxy.cells.length; cellIndex++) { + const cell = proxy.cells[cellIndex]; + // TODO Can we skip any cells or make this iteration faster? + // maybe a linked list here + for (let otherIndex = 0; otherIndex < cell.proxies.length; otherIndex++) { + const other = cell.proxies[otherIndex]; + if (other.id === proxy.id) { + // skip duplicates + continue; + } + const id = Pair.calculatePairHash(proxy.collider.id, other.collider.id); + if (this._nonPairs.has(id)) { + continue; // Is there a way we can re-use the non-pair cache + } + if (!this._pairs.has(id) && this._canCollide(proxy, other)) { + const pair = this._pairPool.get(); + pair.colliderA = proxy.collider; + pair.colliderB = other.collider; + pair.id = id; + this._pairs.add(id); + pairs.push(pair); + } else { + this._nonPairs.add(id); + } + } + } + } + return pairs; + } + + /** + * Runs a fine grain pass on collision pairs and does geometry intersection tests producing any contacts + * @param pairs + * @param stats + */ + narrowphase(pairs: Pair[], stats?: FrameStats): CollisionContact[] { + let contacts: CollisionContact[] = []; + for (let i = 0; i < pairs.length; i++) { + const newContacts = pairs[i].collide(); + contacts = contacts.concat(newContacts); + if (stats && newContacts.length > 0) { + for (const c of newContacts) { + stats.physics.contacts.set(c.id, c); + } + } + } + this._pairPool.done(); + if (stats) { + stats.physics.collisions += contacts.length; + } + return contacts; // TODO maybe we can re-use contacts as likely pairs next frame + } + + /** + * Perform data structure maintenance, returns number of colliders updated + * + * + */ + update(targets: Collider[], delta: number): number { + return this.hashGrid.update(targets); + } + + /** + * Draws the internal data structure + * @param ex + * @param delta + */ + debug(ex: ExcaliburGraphicsContext, delta: number): void { + this.hashGrid.debug(ex, delta); + } +} diff --git a/src/engine/Collision/Index.ts b/src/engine/Collision/Index.ts index d6ca4286c..c095329d9 100644 --- a/src/engine/Collision/Index.ts +++ b/src/engine/Collision/Index.ts @@ -21,9 +21,12 @@ export * from './Group/CollisionGroupManager'; export * from './Detection/Pair'; export * from './Detection/CollisionContact'; export * from './Detection/RayCastHit'; +export * from './Detection/RayCastOptions'; export * from './Detection/CollisionProcessor'; export * from './Detection/DynamicTree'; export * from './Detection/DynamicTreeCollisionProcessor'; +export * from './Detection/SparseHashGridCollisionProcessor'; +export * from './Detection/SparseHashGrid'; export * from './Detection/QuadTree'; export * from './Solver/ArcadeSolver'; diff --git a/src/engine/Collision/MotionSystem.ts b/src/engine/Collision/MotionSystem.ts index f8847d0ad..7b0e6b42e 100644 --- a/src/engine/Collision/MotionSystem.ts +++ b/src/engine/Collision/MotionSystem.ts @@ -58,8 +58,8 @@ export class MotionSystem extends System { captureOldTransformWithChildren(entity: Entity) { entity.get(BodyComponent)?.captureOldTransform(); - for (const child of entity.children) { - this.captureOldTransformWithChildren(child); + for (let i = 0; i < entity.children.length; i++) { + this.captureOldTransformWithChildren(entity.children[i]); } } } diff --git a/src/engine/Collision/PhysicsConfig.ts b/src/engine/Collision/PhysicsConfig.ts index 0068195e7..29bdf2726 100644 --- a/src/engine/Collision/PhysicsConfig.ts +++ b/src/engine/Collision/PhysicsConfig.ts @@ -4,6 +4,33 @@ import { SolverStrategy } from './SolverStrategy'; import { Physics } from './Physics'; import { ContactSolveBias } from './Solver/ContactBias'; +export interface DynamicTreeConfig { + type: 'dynamic-tree'; + /** + * Pad collider BoundingBox by a constant amount for purposes of potential pairs + * + * Default 5 pixels + */ + boundsPadding?: number; + + /** + * Factor to add to the collider BoundingBox, bounding box (dimensions += vel * dynamicTreeVelocityMultiplier); + * + * Default 2 + */ + velocityMultiplier?: number; +} + +export interface SparseHashGridConfig { + type: 'sparse-hash-grid'; + /** + * Size of the grid cells, default is 100x100 pixels. + * + * A good size means that your average collider in your game would fit inside the cell size by size dimension. + */ + size: number; +} + export interface PhysicsConfig { /** * Excalibur physics simulation is enabled @@ -118,23 +145,9 @@ export interface PhysicsConfig { }; /** - * Configure the dynamic tree spatial data structure for locating pairs and raycasts + * Configure the spatial data structure for locating pairs and raycasts */ - dynamicTree?: { - /** - * Pad collider BoundingBox by a constant amount for purposes of potential pairs - * - * Default 5 pixels - */ - boundsPadding?: number; - - /** - * Factor to add to the collider BoundingBox, bounding box (dimensions += vel * dynamicTreeVelocityMultiplier); - * - * Default 2 - */ - velocityMultiplier?: number; - }; + spatialPartition?: DynamicTreeConfig | SparseHashGridConfig; /** * Configure the [[ArcadeSolver]] @@ -215,10 +228,15 @@ export const DefaultPhysicsConfig: DeepRequired = { sleepBias: 0.9, defaultMass: 10 }, - dynamicTree: { - boundsPadding: 5, - velocityMultiplier: 2 + spatialPartition: { + type: 'sparse-hash-grid', + size: 100 }, + // { + // type: 'dynamic-tree', + // boundsPadding: 5, + // velocityMultiplier: 2 + // }, arcade: { contactSolveBias: ContactSolveBias.None }, @@ -254,9 +272,12 @@ export function DeprecatedStaticToConfig(): DeepRequired { sleepBias: Physics.sleepBias, defaultMass: Physics.defaultMass }, - dynamicTree: { - boundsPadding: Physics.boundsPadding, - velocityMultiplier: Physics.dynamicTreeVelocityMultiplier + spatialPartition: { + type: 'sparse-hash-grid', + size: 100 + // type: 'dynamic-tree', + // boundsPadding: Physics.boundsPadding, + // velocityMultiplier: Physics.dynamicTreeVelocityMultiplier }, arcade: { contactSolveBias: ContactSolveBias.None diff --git a/src/engine/Collision/PhysicsWorld.ts b/src/engine/Collision/PhysicsWorld.ts index bfad30375..b89ad1ac3 100644 --- a/src/engine/Collision/PhysicsWorld.ts +++ b/src/engine/Collision/PhysicsWorld.ts @@ -1,10 +1,19 @@ import { Ray } from '../Math/ray'; import { DeepRequired } from '../Util/Required'; import { Observable } from '../Util/Observable'; -import { DynamicTreeCollisionProcessor, RayCastHit, RayCastOptions } from './Index'; +import { + BoundingBox, + Collider, + CollisionProcessor, + DynamicTreeCollisionProcessor, + RayCastHit, + RayCastOptions, + SparseHashGridCollisionProcessor +} from './Index'; import { BodyComponent } from './BodyComponent'; import { PhysicsConfig } from './PhysicsConfig'; import { watchDeep } from '../Util/Watch'; +import { Vector } from '../Math/vector'; export class PhysicsWorld { $configUpdate = new Observable>(); @@ -21,16 +30,20 @@ export class PhysicsWorld { this.$configUpdate.notifyAll(newConfig); } - private _collisionProcessor: DynamicTreeCollisionProcessor; + private _collisionProcessor: CollisionProcessor; /** * Spatial data structure for locating potential collision pairs and ray casts */ - public get collisionProcessor(): DynamicTreeCollisionProcessor { + public get collisionProcessor(): CollisionProcessor { if (this._configDirty) { this._configDirty = false; // preserve tracked colliders if config updates const colliders = this._collisionProcessor.getColliders(); - this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config); + if (this._config.spatialPartition.type === 'sparse-hash-grid') { + this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.spatialPartition); + } else { + this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config); + } for (const collider of colliders) { this._collisionProcessor.track(collider); } @@ -43,7 +56,11 @@ export class PhysicsWorld { this._configDirty = true; BodyComponent.updateDefaultPhysicsConfig(config.bodies); }); - this._collisionProcessor = new DynamicTreeCollisionProcessor(this.config); + if (this._config.spatialPartition.type === 'sparse-hash-grid') { + this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.spatialPartition); + } else { + this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config); + } } /** @@ -54,4 +71,15 @@ export class PhysicsWorld { public rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] { return this.collisionProcessor.rayCast(ray, options); } + + /** + * Query for colliders in the scene's physics world + * @param point + */ + public query(point: Vector): Collider[]; + public query(bounds: BoundingBox): Collider[]; + public query(pointOrBounds: Vector | BoundingBox): Collider[] { + // FIXME workaround TS: https://github.com/microsoft/TypeScript/issues/14107 + return this._collisionProcessor.query(pointOrBounds as any); + } } diff --git a/src/engine/Collision/Solver/ArcadeSolver.ts b/src/engine/Collision/Solver/ArcadeSolver.ts index 77a88c48f..ee6a5c3bd 100644 --- a/src/engine/Collision/Solver/ArcadeSolver.ts +++ b/src/engine/Collision/Solver/ArcadeSolver.ts @@ -71,7 +71,8 @@ export class ArcadeSolver implements CollisionSolver { public preSolve(contacts: CollisionContact[]) { const epsilon = 0.0001; - for (const contact of contacts) { + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; if (Math.abs(contact.mtv.x) < epsilon && Math.abs(contact.mtv.y) < epsilon) { // Cancel near 0 mtv collisions contact.cancel(); @@ -95,7 +96,8 @@ export class ArcadeSolver implements CollisionSolver { } public postSolve(contacts: CollisionContact[]) { - for (const contact of contacts) { + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; if (contact.isCanceled()) { continue; } diff --git a/src/engine/EntityComponentSystem/Entity.ts b/src/engine/EntityComponentSystem/Entity.ts index a2f386148..d5b8debb0 100644 --- a/src/engine/EntityComponentSystem/Entity.ts +++ b/src/engine/EntityComponentSystem/Entity.ts @@ -108,6 +108,7 @@ export class Entity implements OnIniti * Use addComponent/removeComponent otherwise the ECS will not be notified of changes. */ public readonly components = new Map(); + public componentValues: Component[] = []; private _componentsToRemove: ComponentCtor[] = []; private _instanceOfComponentCacheDirty = true; @@ -259,7 +260,8 @@ export class Entity implements OnIniti return this._instanceOfComponentCache.get(type) as MaybeKnownComponent; } - for (const instance of this.components.values()) { + for (let compIndex = 0; compIndex < this.componentValues.length; compIndex++) { + const instance = this.componentValues[compIndex]; if (instance instanceof type) { this._instanceOfComponentCache.set(type, instance); return instance as MaybeKnownComponent; @@ -429,6 +431,8 @@ export class Entity implements OnIniti component.owner = this; this.components.set(component.constructor, component); + this.componentValues.push(component); + this._instanceOfComponentCache.set(component.constructor, component); if (component.onAdd) { component.onAdd(this); } @@ -463,6 +467,10 @@ export class Entity implements OnIniti if (componentToRemove.onRemove) { componentToRemove.onRemove(this); } + const componentIndex = this.componentValues.indexOf(componentToRemove); + if (componentIndex > -1) { + this.componentValues.splice(componentIndex, 1); + } } this.components.delete(type); // remove after the notify to preserve typing this._instanceOfComponentCacheDirty = true; diff --git a/src/engine/EntityComponentSystem/EntityManager.ts b/src/engine/EntityComponentSystem/EntityManager.ts index f07e4315a..172193b16 100644 --- a/src/engine/EntityComponentSystem/EntityManager.ts +++ b/src/engine/EntityComponentSystem/EntityManager.ts @@ -19,7 +19,8 @@ export class EntityManager { * @param elapsed */ public updateEntities(scene: Scene, elapsed: number) { - for (const entity of this.entities) { + for (let entityIndex = 0; entityIndex < this.entities.length; entityIndex++) { + const entity = this.entities[entityIndex]; entity.update(scene.engine, elapsed); if (!entity.active) { this.removeEntity(entity); @@ -28,7 +29,8 @@ export class EntityManager { } public findEntitiesForRemoval() { - for (const entity of this.entities) { + for (let entityIndex = 0; entityIndex < this.entities.length; entityIndex++) { + const entity = this.entities[entityIndex]; if (!entity.active) { this.removeEntity(entity); } @@ -117,7 +119,8 @@ export class EntityManager { private _entitiesToRemove: Entity[] = []; public processEntityRemovals(): void { - for (const entity of this._entitiesToRemove) { + for (let entityIndex = 0; entityIndex < this._entitiesToRemove.length; entityIndex++) { + const entity = this._entitiesToRemove[entityIndex]; if (entity.active) { continue; } @@ -127,7 +130,8 @@ export class EntityManager { } public processComponentRemovals(): void { - for (const entity of this.entities) { + for (let entityIndex = 0; entityIndex < this.entities.length; entityIndex++) { + const entity = this.entities[entityIndex]; entity.processComponentRemoval(); } } diff --git a/src/engine/EventEmitter.ts b/src/engine/EventEmitter.ts index ddbba0cb9..bcb4c485d 100644 --- a/src/engine/EventEmitter.ts +++ b/src/engine/EventEmitter.ts @@ -65,15 +65,22 @@ export class EventEmitter { if (this._paused) { return; } - this._listeners[eventName]?.forEach((fn) => fn(event)); + const listeners = this._listeners[eventName]; + if (listeners) { + for (let i = 0; i < listeners.length; i++) { + listeners[i](event); + } + } const onces = this._listenersOnce[eventName]; this._listenersOnce[eventName] = []; if (onces) { - onces.forEach((fn) => fn(event)); + for (let i = 0; i < onces.length; i++) { + onces[i](event); + } + } + for (let i = 0; i < this._pipes.length; i++) { + this._pipes[i].emit(eventName, event); } - this._pipes.forEach((pipe) => { - pipe.emit(eventName, event); - }); } pipe(emitter: EventEmitter): Subscription { diff --git a/src/engine/Graphics/GraphicsSystem.ts b/src/engine/Graphics/GraphicsSystem.ts index aefd65723..59e8e2f42 100644 --- a/src/engine/Graphics/GraphicsSystem.ts +++ b/src/engine/Graphics/GraphicsSystem.ts @@ -82,7 +82,8 @@ export class GraphicsSystem extends System { if (this._camera) { this._camera.draw(this._graphicsContext); } - for (const transform of this._sortedTransforms) { + for (let transformIndex = 0; transformIndex < this._sortedTransforms.length; transformIndex++) { + const transform = this._sortedTransforms[transformIndex]; const entity = transform.owner as Entity; // If the entity is offscreen skip diff --git a/src/engine/Input/PointerSystem.ts b/src/engine/Input/PointerSystem.ts index ef725b576..d6599a928 100644 --- a/src/engine/Input/PointerSystem.ts +++ b/src/engine/Input/PointerSystem.ts @@ -1,4 +1,3 @@ -import { ColliderComponent } from '../Collision/ColliderComponent'; import { Engine } from '../Engine'; import { System, TransformComponent, SystemType, Entity, World, Query, SystemPriority } from '../EntityComponentSystem'; import { GraphicsComponent } from '../Graphics/GraphicsComponent'; @@ -7,6 +6,7 @@ import { PointerComponent } from './PointerComponent'; import { PointerEventReceiver } from './PointerEventReceiver'; import { PointerEvent } from './PointerEvent'; import { CoordPlane } from '../Math/coord-plane'; +import { SparseHashGrid } from '../Collision/Detection/SparseHashGrid'; /** * The PointerSystem is responsible for dispatching pointer events to entities @@ -22,13 +22,25 @@ export class PointerSystem extends System { private _engine: Engine; private _receivers: PointerEventReceiver[]; private _engineReceiver: PointerEventReceiver; + private _graphicsHashGrid = new SparseHashGrid({ size: 100 }); + private _graphics: GraphicsComponent[] = []; + private _entityToPointer = new Map(); + query: Query; constructor(public world: World) { super(); this.query = this.world.query([TransformComponent, PointerComponent]); + this.query.entityAdded$.subscribe((e) => { const tx = e.get(TransformComponent); + const pointer = e.get(PointerComponent); + this._entityToPointer.set(e, pointer); + const maybeGfx = e.get(GraphicsComponent); + if (maybeGfx) { + this._graphics.push(maybeGfx); + this._graphicsHashGrid.track(maybeGfx); + } this._sortedTransforms.push(tx); this._sortedEntities.push(tx.owner); tx.zIndexChanged$.subscribe(this._zIndexUpdate); @@ -37,6 +49,15 @@ export class PointerSystem extends System { this.query.entityRemoved$.subscribe((e) => { const tx = e.get(TransformComponent); + this._entityToPointer.delete(e); + const maybeGfx = e.get(GraphicsComponent); + if (maybeGfx) { + const index = this._graphics.indexOf(maybeGfx); + if (index > -1) { + this._graphics.splice(index, 1); + } + this._graphicsHashGrid.untrack(maybeGfx); + } tx.zIndexChanged$.unsubscribe(this._zIndexUpdate); const index = this._sortedTransforms.indexOf(tx); if (index > -1) { @@ -111,6 +132,9 @@ export class PointerSystem extends System { } public update(): void { + // Update graphics + this._graphicsHashGrid.update(this._graphics); + // Locate all the pointer/entity mappings this._processPointerToEntity(this._sortedEntities); @@ -127,20 +151,16 @@ export class PointerSystem extends System { private _processPointerToEntity(entities: Entity[]) { let transform: TransformComponent; - let collider: ColliderComponent; - let graphics: GraphicsComponent; let pointer: PointerComponent; const receiver = this._engineReceiver; - // TODO probably a spatial partition optimization here to quickly query bounds for pointer - // doesn't seem to cause issues tho for perf - // Pre-process find entities under pointers - for (const entity of entities) { + for (let entityIndex = 0; entityIndex < entities.length; entityIndex++) { + const entity = entities[entityIndex]; transform = entity.get(TransformComponent); - pointer = entity.get(PointerComponent) ?? new PointerComponent(); + pointer = entity.get(PointerComponent); // If pointer bounds defined - if (pointer.localBounds) { + if (pointer && pointer.localBounds) { const pointerBounds = pointer.localBounds.transform(transform.get().matrix); for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { if (pointerBounds.contains(transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos)) { @@ -148,29 +168,23 @@ export class PointerSystem extends System { } } } - - // Check collider contains pointer - collider = entity.get(ColliderComponent); - if (collider && (pointer.useColliderShape || this.overrideUseColliderShape)) { - collider.update(); - const geom = collider.get(); - if (geom) { - for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { - if (geom.contains(transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos)) { - this.addPointerToEntity(entity, pointerId); - } - } + } + for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { + const colliders = this._scene.physics.query(pos.worldPos); + for (let i = 0; i < colliders.length; i++) { + const collider = colliders[i]; + const maybePointer = this._entityToPointer.get(collider.owner); + if (maybePointer && (pointer.useColliderShape || this.overrideUseColliderShape)) { + this.addPointerToEntity(collider.owner, pointerId); } } - // Check graphics contains pointer - graphics = entity.get(GraphicsComponent); - if (graphics && (pointer.useGraphicsBounds || this.overrideUseGraphicsBounds)) { - const graphicBounds = graphics.localBounds.transform(transform.get().matrix); - for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { - if (graphicBounds.contains(transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos)) { - this.addPointerToEntity(entity, pointerId); - } + const graphics = this._graphicsHashGrid.query(pos.worldPos); + for (let i = 0; i < graphics.length; i++) { + const graphic = graphics[i]; + const maybePointer = this._entityToPointer.get(graphic.owner); + if ((maybePointer && pointer.useGraphicsBounds) || this.overrideUseGraphicsBounds) { + this.addPointerToEntity(graphic.owner, pointerId); } } } diff --git a/src/engine/Math/transform.ts b/src/engine/Math/transform.ts index bd4420d0f..e08ec4ce4 100644 --- a/src/engine/Math/transform.ts +++ b/src/engine/Math/transform.ts @@ -205,8 +205,13 @@ export class Transform { private _scratch = AffineMatrix.identity(); private _calculateMatrix(): AffineMatrix { - this._scratch.reset(); - this._scratch.translate(this.pos.x, this.pos.y).rotate(this.rotation).scale(this.scale.x, this.scale.y); + this._scratch.data[0] = Math.cos(this._rotation); + this._scratch.data[1] = Math.sin(this._rotation); + this._scratch.data[2] = -Math.sin(this._rotation); + this._scratch.data[3] = Math.cos(this.rotation); + this._scratch.data[4] = this._pos.x; + this._scratch.data[5] = this._pos.y; + this._scratch.scale(this._scale.x, this._scale.y); return this._scratch; } diff --git a/src/spec/BoundingBoxSpec.ts b/src/spec/BoundingBoxSpec.ts index 71e5f8491..be45684af 100644 --- a/src/spec/BoundingBoxSpec.ts +++ b/src/spec/BoundingBoxSpec.ts @@ -313,7 +313,7 @@ function runBoundingBoxTests(creationType: string, createBoundingBox: Function) expect(bb.rayCast(ray)).toBe(true); }); - it('ray cast in the correct direction but that are not long enough dont hit', () => { + it("ray cast in the correct direction but that are not long enough don't hit", () => { const bb = new ex.BoundingBox(0, 0, 10, 10); const ray = new ex.Ray(new ex.Vector(-10, 5), ex.Vector.Right); diff --git a/src/spec/CollisionContactSpec.ts b/src/spec/CollisionContactSpec.ts index 5e9d74871..4ee938efe 100644 --- a/src/spec/CollisionContactSpec.ts +++ b/src/spec/CollisionContactSpec.ts @@ -45,6 +45,7 @@ describe('A CollisionContact', () => { it('can resolve in the Box system', () => { actorB.pos = ex.vec(19, actorB.pos.y); + actorB.collider.update(); const cc = new ex.CollisionContact( colliderA, colliderB, diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index 5936f55d1..2ee096df8 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -105,14 +105,15 @@ describe('Collision Shape', () => { expect(sut.bounds.top).toBeCloseTo(expected.top); expect(sut.bounds.bottom).toBeCloseTo(expected.bottom); - expect(sut.localBounds).toEqual( - new ex.BoundingBox({ - left: 90, - top: -10, - bottom: 10, - right: 110 - }) - ); + // TODO should offset be factored into local bounds??? Feels like no + // expect(sut.localBounds).toEqual( + // new ex.BoundingBox({ + // left: 90, + // top: -10, + // bottom: 10, + // right: 110 + // }) + // ); }); it('calculates correct center when transformed', () => { @@ -127,7 +128,7 @@ describe('Collision Shape', () => { it('has bounds', () => { actor.pos = ex.vec(400, 400); - + actor.collider.update(); const bounds = circle.bounds; expect(bounds.left).toBe(390); expect(bounds.right).toBe(410); diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts index 661d018b6..6024a912d 100644 --- a/src/spec/CollisionSpec.ts +++ b/src/spec/CollisionSpec.ts @@ -50,7 +50,16 @@ describe('A Collision', () => { }); it('order of actors collision should not matter when an Active and Active Collision', () => { - const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const collisionTree = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); actor1.body.collisionType = ex.CollisionType.Active; actor2.body.collisionType = ex.CollisionType.Active; @@ -67,7 +76,16 @@ describe('A Collision', () => { }); it('order of actors collision should not matter when an Active and Passive Collision', () => { - const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const collisionTree = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); actor1.body.collisionType = ex.CollisionType.Active; actor2.body.collisionType = ex.CollisionType.Passive; @@ -84,7 +102,7 @@ describe('A Collision', () => { }); it('order of actors collision should not matter when an Active and PreventCollision', () => { - const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const collisionTree = new ex.SparseHashGridCollisionProcessor({ size: 10 }); actor1.body.collisionType = ex.CollisionType.Active; actor2.body.collisionType = ex.CollisionType.PreventCollision; @@ -101,7 +119,7 @@ describe('A Collision', () => { }); it('order of actors collision should not matter when an Active and Fixed', () => { - const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const collisionTree = new ex.SparseHashGridCollisionProcessor({ size: 10 }); actor1.body.collisionType = ex.CollisionType.Active; actor2.body.collisionType = ex.CollisionType.Fixed; @@ -118,7 +136,7 @@ describe('A Collision', () => { }); it('order of actors collision should not matter when an Fixed and Fixed', () => { - const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const collisionTree = new ex.SparseHashGridCollisionProcessor({ size: 10 }); actor1.body.collisionType = ex.CollisionType.Fixed; actor2.body.collisionType = ex.CollisionType.Fixed; diff --git a/src/spec/CompositeColliderSpec.ts b/src/spec/CompositeColliderSpec.ts index d64f40f2a..50cd697c0 100644 --- a/src/spec/CompositeColliderSpec.ts +++ b/src/spec/CompositeColliderSpec.ts @@ -280,7 +280,16 @@ describe('A CompositeCollider', () => { it('is separated into a series of colliders in the dynamic tree', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); - const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); dynamicTreeProcessor.track(compCollider); expect(dynamicTreeProcessor.getColliders().length).toBe(2); @@ -291,7 +300,16 @@ describe('A CompositeCollider', () => { it('removes all colliders in the dynamic tree', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); - const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); dynamicTreeProcessor.track(compCollider); expect(dynamicTreeProcessor.getColliders().length).toBe(2); diff --git a/src/spec/DynamicTreeBroadphaseSpec.ts b/src/spec/DynamicTreeBroadphaseSpec.ts index ba34f02ad..8bd70ee25 100644 --- a/src/spec/DynamicTreeBroadphaseSpec.ts +++ b/src/spec/DynamicTreeBroadphaseSpec.ts @@ -1,5 +1,6 @@ import * as ex from '@excalibur'; import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig'; +import { TestUtils } from './util/TestUtils'; describe('A DynamicTree Broadphase', () => { let actorA: ex.Actor; @@ -34,7 +35,16 @@ describe('A DynamicTree Broadphase', () => { }); it('can find collision pairs for actors that are potentially colliding', () => { - const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const dt = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); dt.track(actorA.collider.get()); dt.track(actorB.collider.get()); dt.track(actorC.collider.get()); @@ -50,7 +60,16 @@ describe('A DynamicTree Broadphase', () => { const box = ex.Shape.Box(200, 10); const compCollider = new ex.CompositeCollider([circle, box]); const actor = new ex.Actor({ collider: compCollider }); - const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const dt = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); dt.track(compCollider); const pairs = dt.broadphase([circle, box], 100); @@ -63,10 +82,273 @@ describe('A DynamicTree Broadphase', () => { const compCollider = new ex.CompositeCollider([circle, box]); const actor = new ex.Actor({ collider: compCollider, collisionType: ex.CollisionType.Active }); actor.body.vel = ex.vec(2000, 0); // extra fast to trigger the fast object detection - const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig); + const dt = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); dt.track(compCollider); const pairs = dt.broadphase([circle, box], 100); expect(pairs).toEqual([]); }); + + it('can rayCast with default options, only 1 hit is returned, searches all groups', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with searchAllColliders on, all hits is returned, searches all groups', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true + }); + + expect(hits.length).toBe(2); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + + expect(hits[1].body).toEqual(actor2.body); + expect(hits[1].collider).toEqual(actor2.collider.get()); + expect(hits[1].distance).toBe(175); + expect(hits[1].point).toEqual(ex.vec(175, 0)); + }); + + it('can rayCast with searchAllColliders on & collision group on, only specified group is returned', () => { + ex.CollisionGroupManager.reset(); + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); + const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50, collisionGroup: collisionGroup1 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50, collisionGroup: collisionGroup2 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + collisionGroup: collisionGroup1 + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with searchAllColliders on with actors that have collision groups are searched', () => { + ex.CollisionGroupManager.reset(); + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); + const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50, collisionGroup: collisionGroup1 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50, collisionGroup: collisionGroup2 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true + }); + + expect(hits.length).toBe(2); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + + expect(hits[1].body).toEqual(actor2.body); + expect(hits[1].collider).toEqual(actor2.collider.get()); + expect(hits[1].distance).toBe(175); + expect(hits[1].point).toEqual(ex.vec(175, 0)); + }); + + it('can rayCast with searchAllColliders on and max distance set, returns 1 hit', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + maxDistance: 100 + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with ignoreCollisionGroupAll, returns 1 hit', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + collisionMask: 0b1, + ignoreCollisionGroupAll: true + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); + + it('can rayCast with filter, returns 1 hit', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + filter: (hit) => { + return hit.body.group.name === 'test'; + } + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); + + it('can rayCast with filter and search all colliders false, returns 1 hit', () => { + const sut = new ex.DynamicTreeCollisionProcessor({ + ...DefaultPhysicsConfig, + ...{ + spatialPartition: { + type: 'dynamic-tree', + boundsPadding: 5, + velocityMultiplier: 2 + } + } + }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: false, + filter: (hit) => { + return hit.body.group.name === 'test'; + } + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); }); diff --git a/src/spec/SparseHashGridCollisionProcessorSpec.ts b/src/spec/SparseHashGridCollisionProcessorSpec.ts new file mode 100644 index 000000000..0dcda3a8d --- /dev/null +++ b/src/spec/SparseHashGridCollisionProcessorSpec.ts @@ -0,0 +1,264 @@ +import * as ex from '@excalibur'; + +describe('A Sparse Hash Grid Broadphase', () => { + let actorA: ex.Actor; + let actorB: ex.Actor; + let actorC: ex.Actor; + + beforeEach(() => { + actorA = new ex.Actor({ x: 0, y: 0, width: 20, height: 20 }); + actorA.collider.useCircleCollider(10); + actorA.body.collisionType = ex.CollisionType.Active; + actorA.collider.update(); + + actorB = new ex.Actor({ x: 20, y: 0, width: 20, height: 20 }); + actorB.collider.useCircleCollider(10); + actorB.body.collisionType = ex.CollisionType.Active; + actorB.collider.update(); + + actorC = new ex.Actor({ x: 1000, y: 0, width: 20, height: 20 }); + actorC.collider.useCircleCollider(10); + actorC.body.collisionType = ex.CollisionType.Active; + actorC.collider.update(); + }); + + it('exists', () => { + expect(ex.SparseHashGridCollisionProcessor).toBeDefined(); + }); + + it('can be constructed', () => { + const dt = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + + expect(dt).not.toBe(null); + }); + + it('can find collision pairs for actors that are potentially colliding', () => { + const dt = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + dt.track(actorC.collider.get()); + + // only should be 1 pair since C is very far away + const pairs = dt.broadphase([actorA.collider.get(), actorB.collider.get(), actorC.collider.get()], 100); + + expect(pairs.length).toBe(1); + }); + + it('should not find pairs for a composite collider', () => { + const circle = ex.Shape.Circle(50); + const box = ex.Shape.Box(200, 10); + const compCollider = new ex.CompositeCollider([circle, box]); + const actor = new ex.Actor({ collider: compCollider }); + const dt = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + dt.track(compCollider); + + const pairs = dt.broadphase([circle, box], 100); + expect(pairs).toEqual([]); + }); + + it('should not find pairs for a composite collider when moving fast', () => { + const circle = ex.Shape.Circle(50); + const box = ex.Shape.Box(200, 10); + const compCollider = new ex.CompositeCollider([circle, box]); + const actor = new ex.Actor({ collider: compCollider, collisionType: ex.CollisionType.Active }); + actor.body.vel = ex.vec(2000, 0); // extra fast to trigger the fast object detection + const dt = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + dt.track(compCollider); + + const pairs = dt.broadphase([circle, box], 100); + expect(pairs).toEqual([]); + }); + + it('should allow for bespoke collider queries', () => { + const dt = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + dt.track(actorC.collider.get()); + + const colliders = dt.query(actorA.collider.bounds); + + expect(colliders.length).toBe(1); + }); + + it('can rayCast with default options, only 1 hit is returned, searches all groups', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with searchAllColliders on, all hits is returned, searches all groups', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true + }); + + expect(hits.length).toBe(2); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + + expect(hits[1].body).toEqual(actor2.body); + expect(hits[1].collider).toEqual(actor2.collider.get()); + expect(hits[1].distance).toBe(175); + expect(hits[1].point).toEqual(ex.vec(175, 0)); + }); + + it('can rayCast with searchAllColliders on & collision group on, only specified group is returned', () => { + ex.CollisionGroupManager.reset(); + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); + const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50, collisionGroup: collisionGroup1 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50, collisionGroup: collisionGroup2 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + collisionGroup: collisionGroup1 + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with searchAllColliders on with actors that have collision groups are searched', () => { + ex.CollisionGroupManager.reset(); + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); + const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50, collisionGroup: collisionGroup1 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50, collisionGroup: collisionGroup2 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true + }); + + expect(hits.length).toBe(2); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + + expect(hits[1].body).toEqual(actor2.body); + expect(hits[1].collider).toEqual(actor2.collider.get()); + expect(hits[1].distance).toBe(175); + expect(hits[1].point).toEqual(ex.vec(175, 0)); + }); + + it('can rayCast with searchAllColliders on and max distance set, returns 1 hit', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + maxDistance: 100 + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor1.body); + expect(hits[0].collider).toEqual(actor1.collider.get()); + expect(hits[0].distance).toBe(75); + expect(hits[0].point).toEqual(ex.vec(75, 0)); + }); + + it('can rayCast with ignoreCollisionGroupAll, returns 1 hit', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + collisionMask: 0b1, + ignoreCollisionGroupAll: true + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); + + it('can rayCast with filter, returns 1 hit', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: true, + filter: (hit) => { + return hit.body.group.name === 'test'; + } + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); + + it('can rayCast with filter and search all colliders false, returns 1 hit', () => { + const sut = new ex.SparseHashGridCollisionProcessor({ size: 50 }); + const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); + sut.track(actor1.collider.get()); + const actor2 = new ex.Actor({ x: 200, y: 0, width: 50, height: 50 }); + sut.track(actor2.collider.get()); + const actor3 = new ex.Actor({ x: 300, y: 0, width: 50, height: 50, collisionGroup: new ex.CollisionGroup('test', 0b1, ~0b1) }); + sut.track(actor3.collider.get()); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits = sut.rayCast(ray, { + searchAllColliders: false, + filter: (hit) => { + return hit.body.group.name === 'test'; + } + }); + + expect(hits.length).toBe(1); + expect(hits[0].body).toEqual(actor3.body); + expect(hits[0].collider).toEqual(actor3.collider.get()); + expect(hits[0].distance).toBe(275); + expect(hits[0].point).toEqual(ex.vec(275, 0)); + }); +}); diff --git a/wallaby.js b/wallaby.js index 8aae9e11d..10f77ce9e 100644 --- a/wallaby.js +++ b/wallaby.js @@ -33,9 +33,6 @@ module.exports = function (wallaby) { postprocessor: wallaby.postprocessors.webpack({ mode: 'none', devtool: 'source-map', - optimization: { - providedExports: true - }, resolve: { extensions: ['.ts', '.js'], alias: {