diff --git a/.gitattributes b/.gitattributes index 1ff0c4230..1ca226fff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,13 @@ ############################################################################### * text=auto +*.ts text +*.js text +*.yml text +*.html text +*.json text +.prettierrc text + ############################################################################### # Set default behavior for command prompt diff. # diff --git a/.prettierrc b/.prettierrc index 262742023..10b248878 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "semi": true, "singleQuote": true, "tabWidth": 2, - "trailingComma": "none" + "trailingComma": "none", + "endOfLine": "auto" } diff --git a/CHANGELOG.md b/CHANGELOG.md index dba7d0ee0..bc0cd9d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fixed issue where negative transforms would cause collision issues because polygon winding would change. - Fixed issue where removing and re-adding an actor would cause subsequent children added not to function properly with regards to their parent/child transforms - Fixed issue where `ex.GraphicsSystem` would crash if a parent entity did not have a `ex.TransformComponent` +- Fixed a bug in the new physics config merging, and re-arranged to better match the existing pattern ### Updates +- Perf improvements to collision narrowphase and solver steps + * Working in the local polygon space as much as possible speeds things up + * Add another pair filtering condition on the `SparseHashGridCollisionProcessor` which reduces pairs passed to narrowphase + * Switching to c-style loops where possible + * Caching get component calls + * Removing allocations where it makes sense +- Perf Side.fromDirection(direction: Vector): Side - thanks @ikudrickiy! - 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 diff --git a/sandbox/tests/many-colliders/index.ts b/sandbox/tests/many-colliders/index.ts index a2b4823cb..74c2c2f25 100644 --- a/sandbox/tests/many-colliders/index.ts +++ b/sandbox/tests/many-colliders/index.ts @@ -1,8 +1,17 @@ var game = new ex.Engine({ width: 800, - height: 600 + height: 600, + physics: { + solver: ex.SolverStrategy.Realistic, + spatialPartition: ex.SpatialPartitionStrategy.SparseHashGrid, + realistic: { + positionIterations: 10 + }, + sparseHashGrid: { + size: 30 + } + } }); -ex.Physics.useRealisticPhysics(); var random = new ex.Random(1337); for (let i = 0; i < 500; i++) { diff --git a/sandbox/tests/physics/physics.ts b/sandbox/tests/physics/physics.ts index 332f0d7c5..e1379901f 100644 --- a/sandbox/tests/physics/physics.ts +++ b/sandbox/tests/physics/physics.ts @@ -3,7 +3,15 @@ var game = new ex.Engine({ width: 600, height: 400, - fixedUpdateFps: 60 + fixedUpdateFps: 60, + physics: { + solver: ex.SolverStrategy.Realistic, + spatialPartition: ex.SpatialPartitionStrategy.SparseHashGrid, + bodies: { + canSleepByDefault: true + }, + gravity: ex.vec(0, 100) + } }); game.backgroundColor = ex.Color.Black; @@ -15,10 +23,6 @@ game.debug.collider.showBounds = true; game.debug.motion.showAll = true; game.debug.body.showMotion = true; -ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic; -ex.Physics.bodiesCanSleepByDefault = true; -ex.Physics.gravity = ex.vec(0, 100); - var globalRotation = 0; function spawnBlock(x: number, y: number) { var width = ex.randomInRange(20, 100); diff --git a/src/engine/Collision/BodyComponent.ts b/src/engine/Collision/BodyComponent.ts index d70713663..91b0b96c7 100644 --- a/src/engine/Collision/BodyComponent.ts +++ b/src/engine/Collision/BodyComponent.ts @@ -1,4 +1,4 @@ -import { Vector } from '../Math/vector'; +import { vec, Vector } from '../Math/vector'; import { CollisionType } from './CollisionType'; import { Clonable } from '../Interfaces/Clonable'; import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; @@ -12,6 +12,7 @@ import { Transform } from '../Math/transform'; import { EventEmitter } from '../EventEmitter'; import { DefaultPhysicsConfig, PhysicsConfig } from './PhysicsConfig'; import { DeepRequired } from '../Util/Required'; +import { Entity } from '../EntityComponentSystem'; export interface BodyComponentOptions { type?: CollisionType; @@ -255,12 +256,12 @@ export class BodyComponent extends Component implements Clonable return this.globalPos; } - public get transform(): TransformComponent { - return this.owner?.get(TransformComponent); - } + public transform: TransformComponent; + public motion: MotionComponent; - public get motion(): MotionComponent { - return this.owner?.get(MotionComponent); + override onAdd(owner: Entity): void { + this.transform = this.owner?.get(TransformComponent); + this.motion = this.owner?.get(MotionComponent); } public get pos(): Vector { @@ -405,6 +406,8 @@ export class BodyComponent extends Component implements Clonable this.motion.angularVelocity = value; } + private _impulseScratch = vec(0, 0); + private _distanceFromCenterScratch = vec(0, 0); /** * Apply a specific impulse to the body * @param point @@ -415,18 +418,18 @@ export class BodyComponent extends Component implements Clonable return; // only active objects participate in the simulation } - const finalImpulse = impulse.scale(this.inverseMass); - if (this.limitDegreeOfFreedom.includes(DegreeOfFreedom.X)) { + const finalImpulse = impulse.scale(this.inverseMass, this._impulseScratch); + if (this.limitDegreeOfFreedom.indexOf(DegreeOfFreedom.X) > -1) { finalImpulse.x = 0; } - if (this.limitDegreeOfFreedom.includes(DegreeOfFreedom.Y)) { + if (this.limitDegreeOfFreedom.indexOf(DegreeOfFreedom.Y) > -1) { finalImpulse.y = 0; } this.vel.addEqual(finalImpulse); if (!this.limitDegreeOfFreedom.includes(DegreeOfFreedom.Rotation)) { - const distanceFromCenter = point.sub(this.globalPos); + const distanceFromCenter = point.sub(this.globalPos, this._distanceFromCenterScratch); this.angularVelocity += this.inverseInertia * distanceFromCenter.cross(impulse); } } diff --git a/src/engine/Collision/BoundingBox.ts b/src/engine/Collision/BoundingBox.ts index c148f3a3a..d29591956 100644 --- a/src/engine/Collision/BoundingBox.ts +++ b/src/engine/Collision/BoundingBox.ts @@ -274,45 +274,45 @@ export class BoundingBox { */ public rayCast(ray: Ray, farClipDistance = Infinity): boolean { // algorithm from https://tavianator.com/fast-branchless-raybounding-box-intersections/ - let tmin = -Infinity; - let tmax = +Infinity; + let tMin = -Infinity; + let tMax = +Infinity; - const xinv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; - const yinv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; + const xInv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; + const yInv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; - const tx1 = (this.left - ray.pos.x) * xinv; - const tx2 = (this.right - ray.pos.x) * xinv; - tmin = Math.min(tx1, tx2); - tmax = Math.max(tx1, tx2); + const tx1 = (this.left - ray.pos.x) * xInv; + const tx2 = (this.right - ray.pos.x) * xInv; + tMin = Math.min(tx1, tx2); + tMax = Math.max(tx1, tx2); - const ty1 = (this.top - ray.pos.y) * yinv; - const ty2 = (this.bottom - ray.pos.y) * yinv; - tmin = Math.max(tmin, Math.min(ty1, ty2)); - tmax = Math.min(tmax, Math.max(ty1, ty2)); + const ty1 = (this.top - ray.pos.y) * yInv; + const ty2 = (this.bottom - ray.pos.y) * yInv; + tMin = Math.max(tMin, Math.min(ty1, ty2)); + tMax = Math.min(tMax, Math.max(ty1, ty2)); - return tmax >= Math.max(0, tmin) && tmin < farClipDistance; + return tMax >= Math.max(0, tMin) && tMin < farClipDistance; } public rayCastTime(ray: Ray, farClipDistance = Infinity): number { // algorithm from https://tavianator.com/fast-branchless-raybounding-box-intersections/ - let tmin = -Infinity; - let tmax = +Infinity; + let tMin = -Infinity; + let tMax = +Infinity; - const xinv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; - const yinv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; + const xInv = ray.dir.x === 0 ? Number.MAX_VALUE : 1 / ray.dir.x; + const yInv = ray.dir.y === 0 ? Number.MAX_VALUE : 1 / ray.dir.y; - const tx1 = (this.left - ray.pos.x) * xinv; - const tx2 = (this.right - ray.pos.x) * xinv; - tmin = Math.min(tx1, tx2); - tmax = Math.max(tx1, tx2); + const tx1 = (this.left - ray.pos.x) * xInv; + const tx2 = (this.right - ray.pos.x) * xInv; + tMin = Math.min(tx1, tx2); + tMax = Math.max(tx1, tx2); - const ty1 = (this.top - ray.pos.y) * yinv; - const ty2 = (this.bottom - ray.pos.y) * yinv; - tmin = Math.max(tmin, Math.min(ty1, ty2)); - tmax = Math.min(tmax, Math.max(ty1, ty2)); + const ty1 = (this.top - ray.pos.y) * yInv; + const ty2 = (this.bottom - ray.pos.y) * yInv; + tMin = Math.max(tMin, Math.min(ty1, ty2)); + tMax = Math.min(tMax, Math.max(ty1, ty2)); - if (tmax >= Math.max(0, tmin) && tmin < farClipDistance) { - return tmin; + if (tMax >= Math.max(0, tMin) && tMin < farClipDistance) { + return tMin; } return -1; } diff --git a/src/engine/Collision/Colliders/CollisionJumpTable.ts b/src/engine/Collision/Colliders/CollisionJumpTable.ts index 84d57b80e..d692c80d5 100644 --- a/src/engine/Collision/Colliders/CollisionJumpTable.ts +++ b/src/engine/Collision/Colliders/CollisionJumpTable.ts @@ -7,6 +7,10 @@ import { LineSegment } from '../../Math/line-segment'; import { Vector } from '../../Math/vector'; import { TransformComponent } from '../../EntityComponentSystem'; import { Pair } from '../Detection/Pair'; +import { AffineMatrix } from '../../Math/affine-matrix'; +const ScratchZero = Vector.Zero; // TODO constant vector +const ScratchNormal = Vector.Zero; // TODO constant vector +const ScratchMatrix = AffineMatrix.identity(); export const CollisionJumpTable = { CollideCircleCircle(circleA: CircleCollider, circleB: CircleCollider): CollisionContact[] { @@ -47,8 +51,8 @@ export const CollisionJumpTable = { } // make sure that the minAxis is pointing away from circle - const samedir = minAxis.dot(polygon.center.sub(circle.center)); - minAxis = samedir < 0 ? minAxis.negate() : minAxis; + const sameDir = minAxis.dot(polygon.center.sub(circle.center)); + minAxis = sameDir < 0 ? minAxis.negate() : minAxis; const point = circle.getFurthestPoint(minAxis); const xf = circle.owner?.get(TransformComponent) ?? new TransformComponent(); @@ -217,6 +221,7 @@ export const CollisionJumpTable = { // https://gamedev.stackexchange.com/questions/111390/multiple-contacts-for-sat-collision-detection // do a SAT test to find a min axis if it exists const separationA = SeparatingAxis.findPolygonPolygonSeparation(polyA, polyB); + // If there is no overlap from boxA's perspective we can end early if (separationA.separation > 0) { return []; @@ -233,26 +238,45 @@ export const CollisionJumpTable = { // The incident side is the most opposite from the axes of collision on the other collider const other = separation.collider === polyA ? polyB : polyA; - const incident = other.findSide(separation.axis.negate()) as LineSegment; + const main = separation.collider === polyA ? polyA : polyB; + + const toIncidentFrame = other.transform.inverse.multiply(main.transform.matrix, ScratchMatrix); + const toIncidentFrameRotation = toIncidentFrame.getRotation(); + const referenceEdgeNormal = main.normals[separation.sideId].rotate(toIncidentFrameRotation, ScratchZero, ScratchNormal); + let minEdge = Number.MAX_VALUE; + let incidentEdgeIndex = 0; + for (let i = 0; i < other.normals.length; i++) { + const value = referenceEdgeNormal.dot(other.normals[i]); + if (value < minEdge) { + minEdge = value; + incidentEdgeIndex = i; + } + } // Clip incident side by the perpendicular lines at each end of the reference side // https://en.wikipedia.org/wiki/Sutherland%E2%80%93Hodgman_algorithm - const reference = separation.side; - const refDir = reference.dir().normalize(); + const referenceSide = separation.localSide.transform(toIncidentFrame); + const referenceDirection = separation.localAxis.perpendicular().negate().rotate(toIncidentFrameRotation); - // Find our contact points by clipping the incident by the collision side - const clipRight = incident.clip(refDir.negate(), -refDir.dot(reference.begin)); + const incidentSide = new LineSegment(other.points[incidentEdgeIndex], other.points[(incidentEdgeIndex + 1) % other.points.length]); + const clipRight = incidentSide.clip(referenceDirection.negate(), -referenceDirection.dot(referenceSide.begin), false); let clipLeft: LineSegment | null = null; if (clipRight) { - clipLeft = clipRight.clip(refDir, refDir.dot(reference.end)); + clipLeft = clipRight.clip(referenceDirection, referenceDirection.dot(referenceSide.end), false); } - // If there is no left there is no collision if (clipLeft) { - // We only want clip points below the reference edge, discard the others - const points = clipLeft.getPoints().filter((p) => { - return reference.below(p); - }); + const localPoints: Vector[] = []; + const points: Vector[] = []; + const clipPoints = clipLeft.getPoints(); + + for (let i = 0; i < clipPoints.length; i++) { + const p = clipPoints[i]; + if (referenceSide.below(p)) { + localPoints.push(p); + points.push(other.transform.apply(p)); + } + } let normal = separation.axis; let tangent = normal.perpendicular(); @@ -261,16 +285,6 @@ export const CollisionJumpTable = { normal = normal.negate(); tangent = normal.perpendicular(); } - // Points are clipped from incident which is the other collider - // Store those as locals - let localPoints: Vector[] = []; - if (separation.collider === polyA) { - const xf = polyB.owner?.get(TransformComponent) ?? new TransformComponent(); - localPoints = points.map((p) => xf.applyInverse(p)); - } else { - const xf = polyA.owner?.get(TransformComponent) ?? new TransformComponent(); - localPoints = points.map((p) => xf.applyInverse(p)); - } return [new CollisionContact(polyA, polyB, normal.scale(-separation.separation), normal, tangent, points, localPoints, separation)]; } return []; @@ -278,9 +292,9 @@ export const CollisionJumpTable = { FindContactSeparation(contact: CollisionContact, localPoint: Vector) { const shapeA = contact.colliderA; - const txA = contact.colliderA.owner?.get(TransformComponent) ?? new TransformComponent(); + const txA = contact.bodyA?.transform ?? new TransformComponent(); const shapeB = contact.colliderB; - const txB = contact.colliderB.owner?.get(TransformComponent) ?? new TransformComponent(); + const txB = contact.bodyB?.transform ?? new TransformComponent(); // both are circles if (shapeA instanceof CircleCollider && shapeB instanceof CircleCollider) { diff --git a/src/engine/Collision/Colliders/CompositeCollider.ts b/src/engine/Collision/Colliders/CompositeCollider.ts index fc33f3cf8..aac5f703f 100644 --- a/src/engine/Collision/Colliders/CompositeCollider.ts +++ b/src/engine/Collision/Colliders/CompositeCollider.ts @@ -18,17 +18,9 @@ import { DefaultPhysicsConfig } from '../PhysicsConfig'; export class CompositeCollider extends Collider { private _transform: Transform; private _collisionProcessor = new DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); private _dynamicAABBTree = new DynamicTree({ - type: 'dynamic-tree', boundsPadding: 5, velocityMultiplier: 2 }); diff --git a/src/engine/Collision/Colliders/PolygonCollider.ts b/src/engine/Collision/Colliders/PolygonCollider.ts index 0c2a6b6db..ba01b6e95 100644 --- a/src/engine/Collision/Colliders/PolygonCollider.ts +++ b/src/engine/Collision/Colliders/PolygonCollider.ts @@ -6,12 +6,11 @@ import { CircleCollider } from './CircleCollider'; import { CollisionContact } from '../Detection/CollisionContact'; import { Projection } from '../../Math/projection'; import { LineSegment } from '../../Math/line-segment'; -import { Vector } from '../../Math/vector'; -import { AffineMatrix } from '../../Math/affine-matrix'; +import { Vector, vec } from '../../Math/vector'; import { Ray } from '../../Math/ray'; import { ClosestLineJumpTable } from './ClosestLineJumpTable'; import { Collider } from './Collider'; -import { BodyComponent, Debug, ExcaliburGraphicsContext, Logger } from '../..'; +import { BodyComponent, Debug, ExcaliburGraphicsContext, Logger, sign } from '../..'; import { CompositeCollider } from './CompositeCollider'; import { Shape } from './Shape'; import { Transform } from '../../Math/transform'; @@ -51,6 +50,11 @@ export class PolygonCollider extends Collider { } private _points: Vector[]; + private _normals: Vector[]; + + public get normals(): readonly Vector[] { + return this._normals; + } /** * Points in the polygon in order around the perimeter in local coordinates. These are relative from the body transform position. @@ -59,9 +63,18 @@ export class PolygonCollider extends Collider { public set points(points: Vector[]) { this._points = points; this._checkAndUpdateWinding(this._points); + this._calculateNormals(); this.flagDirty(); } + private _calculateNormals() { + const normals: Vector[] = []; + for (let i = 0; i < this._points.length; i++) { + normals.push(this._points[(i + 1) % this._points.length].sub(this._points[i]).normal()); + } + this._normals = normals; + } + /** * Points in the polygon in order around the perimeter in local coordinates. These are relative from the body transform position. * Excalibur stores these in counter-clockwise order @@ -70,7 +83,10 @@ export class PolygonCollider extends Collider { return this._points; } - private _transform: Transform; + private _transform: Transform = new Transform(); + public get transform() { + return this._transform; + } private _transformedPoints: Vector[] = []; private _sides: LineSegment[] = []; @@ -79,7 +95,8 @@ export class PolygonCollider extends Collider { constructor(options: PolygonColliderOptions) { super(); this.offset = options.offset ?? Vector.Zero; - this._globalMatrix.translate(this.offset.x, this.offset.y); + this._transform.pos.x += this.offset.x; + this._transform.pos.y += this.offset.y; this.points = options.points ?? []; if (!this.isConvex()) { @@ -101,8 +118,7 @@ export class PolygonCollider extends Collider { points.reverse(); } } - - private _isCounterClockwiseWinding(points: Vector[]): boolean { + public _isCounterClockwiseWinding(points: Vector[]): boolean { // https://stackoverflow.com/a/1165943 let sum = 0; for (let i = 0; i < points.length; i++) { @@ -346,10 +362,7 @@ export class PolygonCollider extends Collider { * Returns the world position of the collider, which is the current body transform plus any defined offset */ public get worldPos(): Vector { - if (this._transform) { - return this._transform.pos.add(this.offset); - } - return this.offset; + return this._transform.pos; } /** @@ -359,8 +372,6 @@ export class PolygonCollider extends Collider { return this.bounds.center; } - private _globalMatrix: AffineMatrix = AffineMatrix.identity(); - private _transformedPointsDirty = true; /** * Calculates the underlying transformation from the body relative space to world space @@ -370,12 +381,7 @@ export class PolygonCollider extends Collider { const len = points.length; this._transformedPoints.length = 0; // clear out old transform for (let i = 0; i < len; i++) { - this._transformedPoints[i] = this._globalMatrix.multiply(points[i].clone()); - } - // it is possible for the transform to change the winding, scale (-1, 1) for example - const scale = this._globalMatrix.getScale(); - if (scale.x < 0 || scale.y < 0) { - this._checkAndUpdateWinding(this._transformedPoints); + this._transformedPoints[i] = this._transform.apply(points[i].clone()); } } @@ -489,13 +495,22 @@ export class PolygonCollider extends Collider { */ public update(transform: Transform): void { if (transform) { - this._transform = transform; + // This change means an update must be performed in order for geometry to update + transform.cloneWithParent(this._transform); this._transformedPointsDirty = true; this._sidesDirty = true; - // This change means an update must be performed in order for geometry to update - const globalMat = transform.matrix ?? this._globalMatrix; - globalMat.clone(this._globalMatrix); - this._globalMatrix.translate(this.offset.x, this.offset.y); + if (this.offset.x !== 0 || this.offset.y !== 0) { + this._transform.pos.x += this.offset.x; + this._transform.pos.y += this.offset.y; + } + + if (this._transform.isMirrored()) { + // negative transforms really mess with things in collision local space + // flatten out the negatives by applying to geometry + this.points = this.points.map((p) => vec(p.x * sign(this._transform.scale.x), p.y * sign(this._transform.scale.y))); + this._transform.scale.x = Math.abs(this._transform.scale.x); + this._transform.scale.y = Math.abs(this._transform.scale.y); + } } } @@ -615,7 +630,7 @@ export class PolygonCollider extends Collider { * Get the axis aligned bounding box for the polygon collider in world coordinates */ public get bounds(): BoundingBox { - return this.localBounds.transform(this._globalMatrix); + return this.localBounds.transform(this._transform.matrix); } private _localBoundsDirty = true; diff --git a/src/engine/Collision/Colliders/SeparatingAxis.ts b/src/engine/Collision/Colliders/SeparatingAxis.ts index 01a32ab7b..81b674db5 100644 --- a/src/engine/Collision/Colliders/SeparatingAxis.ts +++ b/src/engine/Collision/Colliders/SeparatingAxis.ts @@ -1,13 +1,15 @@ import { LineSegment } from '../../Math/line-segment'; -import { Vector } from '../../Math/vector'; +import { Vector, vec } from '../../Math/vector'; import { Collider } from './Collider'; import { CircleCollider } from './CircleCollider'; import { PolygonCollider } from './PolygonCollider'; +import { AffineMatrix } from '../../Math/affine-matrix'; +import { Pool } from '../../Util/Pool'; /** * Specific information about a contact and it's separation */ -export interface SeparationInfo { +export class SeparationInfo { /** * Collider A */ @@ -21,18 +23,23 @@ export interface SeparationInfo { /** * Axis of separation from the collider's perspective */ - axis: Vector; + axis: Vector = vec(0, 0); + + /** + * Local axis of separation from the collider's perspective + */ + localAxis?: Vector = vec(0, 0); /** * Side of separation (reference) from the collider's perspective */ - side?: LineSegment; + side?: LineSegment = new LineSegment(vec(0, 0), vec(0, 0)); /** * Local side of separation (reference) from the collider's perspective */ - localSide?: LineSegment; + localSide?: LineSegment = new LineSegment(vec(0, 0), vec(0, 0)); /** * Index of the separation side (reference) from the collider's perspective @@ -42,51 +49,83 @@ export interface SeparationInfo { /** * Point on collider B (incident point) */ - point: Vector; + point: Vector = vec(0, 0); /** * Local point on collider B (incident point) */ - localPoint?: Vector; + localPoint?: Vector = vec(0, 0); } export class SeparatingAxis { + static SeparationPool = new Pool( + () => new SeparationInfo(), + (i) => i, // no recycle + 500 + ); + private static _ZERO = vec(0, 0); + private static _SCRATCH_POINT = vec(0, 0); + private static _SCRATCH_SUB_POINT = vec(0, 0); + private static _SCRATCH_NORMAL = vec(0, 0); + private static _SCRATCH_MATRIX = AffineMatrix.identity(); static findPolygonPolygonSeparation(polyA: PolygonCollider, polyB: PolygonCollider): SeparationInfo { + // Multi contact from SAT + // https://gamedev.stackexchange.com/questions/111390/multiple-contacts-for-sat-collision-detection + // do a SAT test to find a min axis if it exists + let bestSeparation = -Number.MAX_VALUE; - let bestSide: LineSegment | null = null; - let bestAxis: Vector | null = null; let bestSideIndex: number = -1; - let bestOtherPoint: Vector | null = null; - const sides = polyA.getSides(); - const localSides = polyA.getLocalSides(); - for (let i = 0; i < sides.length; i++) { - const side = sides[i]; - const axis = side.normal(); - const vertB = polyB.getFurthestPoint(axis.negate()); - // Separation on side i's axis - // We are looking for the largest separation between poly A's sides - const vertSeparation = side.distanceToPoint(vertB, true); - if (vertSeparation > bestSeparation) { - bestSeparation = vertSeparation; - bestSide = side; - bestAxis = axis; - bestSideIndex = i; - bestOtherPoint = vertB; + let localPoint: Vector; + // Work inside polyB reference frame + // inv polyB converts to local space from polyA world space + const toPolyBSpace = polyB.transform.inverse.multiply(polyA.transform.matrix, SeparatingAxis._SCRATCH_MATRIX); + const toPolyBSpaceRotation = toPolyBSpace.getRotation(); + + const normalsA = polyA.normals; + const pointsA = polyA.points; + const pointsB = polyB.points; + for (let pointsAIndex = 0; pointsAIndex < pointsA.length; pointsAIndex++) { + const normal = normalsA[pointsAIndex].rotate(toPolyBSpaceRotation, SeparatingAxis._ZERO, SeparatingAxis._SCRATCH_NORMAL); + const point = toPolyBSpace.multiply(pointsA[pointsAIndex], SeparatingAxis._SCRATCH_POINT); + + // For every point in polyB + // We want to see how much overlap there is on the axis provided by the normal + // We want to find the minimum overlap among all points + let smallestPointDistance = Number.MAX_VALUE; + let smallestLocalPoint: Vector; + for (let pointsBIndex = 0; pointsBIndex < pointsB.length; pointsBIndex++) { + const distance = normal.dot(pointsB[pointsBIndex].sub(point, SeparatingAxis._SCRATCH_SUB_POINT)); + if (distance < smallestPointDistance) { + smallestPointDistance = distance; + smallestLocalPoint = pointsB[pointsBIndex]; + } + } + + // We take the maximum overlap as the separation between the + // A negative separation means there were no gaps between the two shapes + if (smallestPointDistance > bestSeparation) { + bestSeparation = smallestPointDistance; + bestSideIndex = pointsAIndex; + localPoint = smallestLocalPoint; } } - return { - collider: polyA, - separation: bestAxis ? bestSeparation : 99, - axis: bestAxis as Vector, - side: bestSide, - localSide: localSides[bestSideIndex], - sideId: bestSideIndex, - point: bestOtherPoint as Vector, - localPoint: bestAxis ? polyB.getFurthestLocalPoint(bestAxis!.negate()) : null - }; + // TODO can we avoid applying world space transforms? + const bestSide2 = (bestSideIndex + 1) % pointsA.length; + const separationInfo = SeparatingAxis.SeparationPool.get(); + separationInfo.collider = polyA; + separationInfo.separation = bestSeparation; + normalsA[bestSideIndex].clone(separationInfo.localAxis); + normalsA[bestSideIndex].rotate(polyA.transform.rotation, SeparatingAxis._ZERO, separationInfo.axis); + polyA.transform.matrix.multiply(pointsA[bestSideIndex], separationInfo.side.begin); + polyA.transform.matrix.multiply(pointsA[bestSide2], separationInfo.side.end); + polyB.transform.matrix.multiply(localPoint, separationInfo.point); + separationInfo.sideId = bestSideIndex; + localPoint.clone(separationInfo.localPoint); + pointsA[bestSideIndex].clone(separationInfo.localSide.begin); + pointsA[bestSide2].clone(separationInfo.localSide.end); + return separationInfo; } - static findCirclePolygonSeparation(circle: CircleCollider, polygon: PolygonCollider): Vector | null { const axes = polygon.axes; const pc = polygon.center; @@ -118,3 +157,5 @@ export class SeparatingAxis { return minAxis.normalize().scale(minOverlap); } } + +SeparatingAxis.SeparationPool.disableWarnings = true; diff --git a/src/engine/Collision/CollisionSystem.ts b/src/engine/Collision/CollisionSystem.ts index c49e49939..4e50135d2 100644 --- a/src/engine/Collision/CollisionSystem.ts +++ b/src/engine/Collision/CollisionSystem.ts @@ -17,6 +17,7 @@ import { Scene } from '../Scene'; import { Side } from '../Collision/Side'; import { PhysicsWorld } from './PhysicsWorld'; import { CollisionProcessor } from './Detection/CollisionProcessor'; +import { SeparatingAxis } from './Colliders/SeparatingAxis'; export class CollisionSystem extends System { public systemType = SystemType.Update; public priority = SystemPriority.Higher; @@ -147,6 +148,10 @@ export class CollisionSystem extends System { } } + postupdate(): void { + SeparatingAxis.SeparationPool.done(); + } + getSolver(): CollisionSolver { if (this._configDirty) { this._configDirty = false; diff --git a/src/engine/Collision/Detection/CollisionContact.ts b/src/engine/Collision/Detection/CollisionContact.ts index 68abb707c..cfa7a8b8f 100644 --- a/src/engine/Collision/Detection/CollisionContact.ts +++ b/src/engine/Collision/Detection/CollisionContact.ts @@ -57,6 +57,9 @@ export class CollisionContact { */ info: SeparationInfo; + bodyA: BodyComponent | null = null; + bodyB: BodyComponent | null = null; + constructor( colliderA: Collider, colliderB: Collider, @@ -82,14 +85,20 @@ export class CollisionContact { const colliderBId = colliderB.composite?.compositeStrategy === 'separate' ? colliderB.id : colliderB.composite?.id ?? colliderB.id; this.id += '|' + Pair.calculatePairHash(colliderAId, colliderBId); } + if (this.colliderA.owner) { + this.bodyA = this.colliderA.owner.get(BodyComponent); + } + if (this.colliderB.owner) { + this.bodyB = this.colliderB.owner.get(BodyComponent); + } } /** * Match contact awake state, except if body's are Fixed */ public matchAwake(): void { - const bodyA = this.colliderA.owner.get(BodyComponent); - const bodyB = this.colliderB.owner.get(BodyComponent); + const bodyA = this.bodyA; + const bodyB = this.bodyB; if (bodyA && bodyB) { if (bodyA.sleeping !== bodyB.sleeping) { if (bodyA.sleeping && bodyA.collisionType !== CollisionType.Fixed && bodyB.sleepMotion >= bodyA.wakeThreshold) { diff --git a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts index e5000c078..28d9e0b48 100644 --- a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts +++ b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts @@ -32,9 +32,7 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor { private _colliders: Collider[] = []; constructor(private _config: DeepRequired) { - if (_config.spatialPartition.type === 'dynamic-tree') { - this._dynamicCollisionTree = new DynamicTree(_config.spatialPartition); - } + this._dynamicCollisionTree = new DynamicTree(_config.dynamicTree); } public getColliders(): readonly Collider[] { diff --git a/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts b/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts index eda3df280..ca1e63ed5 100644 --- a/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts +++ b/src/engine/Collision/Detection/SparseHashGridCollisionProcessor.ts @@ -106,6 +106,7 @@ export class SparseHashGridCollisionProcessor implements CollisionProcessor { size: this.gridSize, proxyFactory: (collider, size) => new HashColliderProxy(collider, size) }); + this._pairPool.disableWarnings = true; // TODO dynamic grid size potentially larger than the largest collider // TODO Re-hash the objects if the median proves to be different @@ -333,7 +334,7 @@ export class SparseHashGridCollisionProcessor implements CollisionProcessor { 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)) { + if (!this._pairs.has(id) && this._canCollide(proxy, other) && proxy.object.bounds.overlaps(other.object.bounds)) { const pair = this._pairPool.get(); pair.colliderA = proxy.collider; pair.colliderB = other.collider; @@ -355,12 +356,13 @@ export class SparseHashGridCollisionProcessor implements CollisionProcessor { * @param stats */ narrowphase(pairs: Pair[], stats?: FrameStats): CollisionContact[] { - let contacts: CollisionContact[] = []; + const 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) { + for (let j = 0; j < newContacts.length; j++) { + const c = newContacts[j]; + contacts.push(c); + if (stats) { stats.physics.contacts.set(c.id, c); } } @@ -374,8 +376,6 @@ export class SparseHashGridCollisionProcessor implements CollisionProcessor { /** * Perform data structure maintenance, returns number of colliders updated - * - * */ update(targets: Collider[], delta: number): number { return this.hashGrid.update(targets); diff --git a/src/engine/Collision/Detection/SpatialPartitionStrategy.ts b/src/engine/Collision/Detection/SpatialPartitionStrategy.ts new file mode 100644 index 000000000..e48ff5359 --- /dev/null +++ b/src/engine/Collision/Detection/SpatialPartitionStrategy.ts @@ -0,0 +1,13 @@ +/** + * Possible collision resolution strategies + * + * The default is [[SolverStrategy.Arcade]] which performs simple axis aligned arcade style physics. This is useful for things + * like platformers or top down games. + * + * More advanced rigid body physics are enabled by setting [[SolverStrategy.Realistic]] which allows for complicated + * simulated physical interactions. + */ +export enum SpatialPartitionStrategy { + DynamicTree = 'dynamic-tree', + SparseHashGrid = 'sparse-hash-grid' +} diff --git a/src/engine/Collision/Index.ts b/src/engine/Collision/Index.ts index c095329d9..9f1d85a6b 100644 --- a/src/engine/Collision/Index.ts +++ b/src/engine/Collision/Index.ts @@ -27,6 +27,7 @@ export * from './Detection/DynamicTree'; export * from './Detection/DynamicTreeCollisionProcessor'; export * from './Detection/SparseHashGridCollisionProcessor'; export * from './Detection/SparseHashGrid'; +export * from './Detection/SpatialPartitionStrategy'; export * from './Detection/QuadTree'; export * from './Solver/ArcadeSolver'; diff --git a/src/engine/Collision/PhysicsConfig.ts b/src/engine/Collision/PhysicsConfig.ts index 29bdf2726..85873147d 100644 --- a/src/engine/Collision/PhysicsConfig.ts +++ b/src/engine/Collision/PhysicsConfig.ts @@ -3,9 +3,9 @@ import { DeepRequired } from '../Util/Required'; import { SolverStrategy } from './SolverStrategy'; import { Physics } from './Physics'; import { ContactSolveBias } from './Solver/ContactBias'; +import { SpatialPartitionStrategy } from './Detection/SpatialPartitionStrategy'; export interface DynamicTreeConfig { - type: 'dynamic-tree'; /** * Pad collider BoundingBox by a constant amount for purposes of potential pairs * @@ -22,7 +22,6 @@ export interface DynamicTreeConfig { } export interface SparseHashGridConfig { - type: 'sparse-hash-grid'; /** * Size of the grid cells, default is 100x100 pixels. * @@ -147,7 +146,9 @@ export interface PhysicsConfig { /** * Configure the spatial data structure for locating pairs and raycasts */ - spatialPartition?: DynamicTreeConfig | SparseHashGridConfig; + spatialPartition?: SpatialPartitionStrategy; + sparseHashGrid?: SparseHashGridConfig; + dynamicTree?: DynamicTreeConfig; /** * Configure the [[ArcadeSolver]] @@ -228,15 +229,14 @@ export const DefaultPhysicsConfig: DeepRequired = { sleepBias: 0.9, defaultMass: 10 }, - spatialPartition: { - type: 'sparse-hash-grid', + spatialPartition: SpatialPartitionStrategy.SparseHashGrid, + sparseHashGrid: { size: 100 }, - // { - // type: 'dynamic-tree', - // boundsPadding: 5, - // velocityMultiplier: 2 - // }, + dynamicTree: { + boundsPadding: 5, + velocityMultiplier: 2 + }, arcade: { contactSolveBias: ContactSolveBias.None }, @@ -272,12 +272,13 @@ export function DeprecatedStaticToConfig(): DeepRequired { sleepBias: Physics.sleepBias, defaultMass: Physics.defaultMass }, - spatialPartition: { - type: 'sparse-hash-grid', + spatialPartition: SpatialPartitionStrategy.SparseHashGrid, + sparseHashGrid: { size: 100 - // type: 'dynamic-tree', - // boundsPadding: Physics.boundsPadding, - // velocityMultiplier: Physics.dynamicTreeVelocityMultiplier + }, + dynamicTree: { + 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 b89ad1ac3..243b01736 100644 --- a/src/engine/Collision/PhysicsWorld.ts +++ b/src/engine/Collision/PhysicsWorld.ts @@ -14,6 +14,7 @@ import { BodyComponent } from './BodyComponent'; import { PhysicsConfig } from './PhysicsConfig'; import { watchDeep } from '../Util/Watch'; import { Vector } from '../Math/vector'; +import { SpatialPartitionStrategy } from './Detection/SpatialPartitionStrategy'; export class PhysicsWorld { $configUpdate = new Observable>(); @@ -39,8 +40,8 @@ export class PhysicsWorld { this._configDirty = false; // preserve tracked colliders if config updates const colliders = this._collisionProcessor.getColliders(); - if (this._config.spatialPartition.type === 'sparse-hash-grid') { - this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.spatialPartition); + if (this._config.spatialPartition === SpatialPartitionStrategy.SparseHashGrid) { + this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.sparseHashGrid); } else { this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config); } @@ -56,8 +57,8 @@ export class PhysicsWorld { this._configDirty = true; BodyComponent.updateDefaultPhysicsConfig(config.bodies); }); - if (this._config.spatialPartition.type === 'sparse-hash-grid') { - this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.spatialPartition); + if (this._config.spatialPartition === SpatialPartitionStrategy.SparseHashGrid) { + this._collisionProcessor = new SparseHashGridCollisionProcessor(this._config.sparseHashGrid); } else { this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config); } diff --git a/src/engine/Collision/Side.ts b/src/engine/Collision/Side.ts index 99c7d1752..e27547d67 100644 --- a/src/engine/Collision/Side.ts +++ b/src/engine/Collision/Side.ts @@ -33,20 +33,21 @@ export module Side { } /** - * Given a vector, return the Side most in that direction (via dot product) + * Given a vector, return the Side most in that direction */ export function fromDirection(direction: Vector): Side { - const directions = [Vector.Left, Vector.Right, Vector.Up, Vector.Down]; - const directionEnum = [Side.Left, Side.Right, Side.Top, Side.Bottom]; - - let max = -Number.MAX_VALUE; - let maxIndex = -1; - for (let i = 0; i < directions.length; i++) { - if (directions[i].dot(direction) > max) { - max = directions[i].dot(direction); - maxIndex = i; + if (Math.abs(direction.x) >= Math.abs(direction.y)) { + if (direction.x <= 0) { + return Side.Left; } + + return Side.Right; } - return directionEnum[maxIndex]; + + if (direction.y <= 0) { + return Side.Top; + } + + return Side.Bottom; } } diff --git a/src/engine/Collision/Solver/ContactConstraintPoint.ts b/src/engine/Collision/Solver/ContactConstraintPoint.ts index c111e383c..198b90939 100644 --- a/src/engine/Collision/Solver/ContactConstraintPoint.ts +++ b/src/engine/Collision/Solver/ContactConstraintPoint.ts @@ -1,5 +1,4 @@ import { Vector } from '../../Math/vector'; -import { BodyComponent } from '../BodyComponent'; import { CollisionContact } from '../Detection/CollisionContact'; /** @@ -18,8 +17,8 @@ export class ContactConstraintPoint { * Updates the contact information */ update() { - const bodyA = this.contact.colliderA.owner?.get(BodyComponent); - const bodyB = this.contact.colliderB.owner?.get(BodyComponent); + const bodyA = this.contact.bodyA; + const bodyB = this.contact.bodyB; if (bodyA && bodyB) { const normal = this.contact.normal; @@ -54,8 +53,8 @@ export class ContactConstraintPoint { * Returns the relative velocity between bodyA and bodyB */ public getRelativeVelocity() { - const bodyA = this.contact.colliderA.owner?.get(BodyComponent); - const bodyB = this.contact.colliderB.owner?.get(BodyComponent); + const bodyA = this.contact.bodyA; + const bodyB = this.contact.bodyB; if (bodyA && bodyB) { // Relative velocity in linear terms // Angular to linear velocity formula -> omega = velocity/radius so omega x radius = velocity diff --git a/src/engine/Collision/Solver/RealisticSolver.ts b/src/engine/Collision/Solver/RealisticSolver.ts index b12b7091c..8cc157e50 100644 --- a/src/engine/Collision/Solver/RealisticSolver.ts +++ b/src/engine/Collision/Solver/RealisticSolver.ts @@ -5,7 +5,7 @@ import { CollisionType } from '../CollisionType'; import { ContactConstraintPoint } from './ContactConstraintPoint'; import { Side } from '../Side'; import { CollisionSolver } from './Solver'; -import { BodyComponent, DegreeOfFreedom } from '../BodyComponent'; +import { DegreeOfFreedom } from '../BodyComponent'; import { CollisionJumpTable } from '../Colliders/CollisionJumpTable'; import { DeepRequired } from '../../Util/Required'; import { PhysicsConfig } from '../PhysicsConfig'; @@ -42,7 +42,8 @@ export class RealisticSolver implements CollisionSolver { 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(); @@ -73,7 +74,8 @@ export class RealisticSolver implements CollisionSolver { // Keep track of contacts that done const finishedContactIds = Array.from(this.idToContactConstraint.keys()); - for (const contact of contacts) { + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; // Remove all current contacts that are not done const index = finishedContactIds.indexOf(contact.id); if (index > -1) { @@ -82,10 +84,11 @@ export class RealisticSolver implements CollisionSolver { const contactPoints = this.idToContactConstraint.get(contact.id) ?? []; let pointIndex = 0; - const bodyA = contact.colliderA.owner.get(BodyComponent); - const bodyB = contact.colliderB.owner.get(BodyComponent); + const bodyA = contact.bodyA; + const bodyB = contact.bodyB; if (bodyA && bodyB) { - for (const point of contact.points) { + for (let j = 0; j < contact.points.length; j++) { + const point = contact.points[j]; const normal = contact.normal; const tangent = contact.tangent; @@ -149,7 +152,8 @@ export class RealisticSolver implements CollisionSolver { if (this.config.warmStart) { this.warmStart(contacts); } else { - for (const contact of contacts) { + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; const contactPoints = this.getContactConstraints(contact.id); for (const point of contactPoints) { point.normalImpulse = 0; @@ -160,9 +164,10 @@ export class RealisticSolver implements CollisionSolver { } postSolve(contacts: CollisionContact[]) { - for (const contact of contacts) { - const bodyA = contact.colliderA.owner.get(BodyComponent); - const bodyB = contact.colliderB.owner.get(BodyComponent); + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; + const bodyA = contact.bodyA; + const bodyB = contact.bodyB; if (bodyA && bodyB) { // Skip post solve for active+passive collisions @@ -197,7 +202,8 @@ export class RealisticSolver implements CollisionSolver { // Store contacts this.lastFrameContacts.clear(); - for (const c of contacts) { + for (let i = 0; i < contacts.length; i++) { + const c = contacts[i]; this.lastFrameContacts.set(c.id, c); } } @@ -207,9 +213,10 @@ export class RealisticSolver implements CollisionSolver { * @param contacts */ warmStart(contacts: CollisionContact[]) { - for (const contact of contacts) { - const bodyA = contact.colliderA.owner?.get(BodyComponent); - const bodyB = contact.colliderB.owner?.get(BodyComponent); + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; + const bodyA = contact.bodyA; + const bodyB = contact.bodyB; if (bodyA && bodyB) { const contactPoints = this.idToContactConstraint.get(contact.id) ?? []; for (const point of contactPoints) { @@ -235,9 +242,10 @@ export class RealisticSolver implements CollisionSolver { */ solvePosition(contacts: CollisionContact[]) { for (let i = 0; i < this.config.positionIterations; i++) { - for (const contact of contacts) { - const bodyA = contact.colliderA.owner?.get(BodyComponent); - const bodyB = contact.colliderB.owner?.get(BodyComponent); + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; + const bodyA = contact.bodyA; + const bodyB = contact.bodyB; if (bodyA && bodyB) { // Skip solving active+passive @@ -299,9 +307,10 @@ export class RealisticSolver implements CollisionSolver { solveVelocity(contacts: CollisionContact[]) { for (let i = 0; i < this.config.velocityIterations; i++) { - for (const contact of contacts) { - const bodyA = contact.colliderA.owner?.get(BodyComponent); - const bodyB = contact.colliderB.owner?.get(BodyComponent); + for (let i = 0; i < contacts.length; i++) { + const contact = contacts[i]; + const bodyA = contact.bodyA; + const bodyB = contact.bodyB; if (bodyA && bodyB) { // Skip solving active+passive diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 98ea3f3b8..a6f2b7eb2 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -53,6 +53,7 @@ import { InputHost } from './Input/InputHost'; import { DefaultPhysicsConfig, DeprecatedStaticToConfig, PhysicsConfig } from './Collision/PhysicsConfig'; import { DeepRequired } from './Util/Required'; import { Context, createContext, useContext } from './Context'; +import { mergeDeep } from './Util/Util'; export type EngineEvents = { fallbackgraphicscontext: ExcaliburGraphicsContext2DCanvas; @@ -952,9 +953,9 @@ O|===|* >________________>\n\ } else { this.physics = { ...DefaultPhysicsConfig, - ...DeprecatedStaticToConfig(), - ...(options.physics as DeepRequired) + ...DeprecatedStaticToConfig() }; + mergeDeep(this.physics, options.physics); } this.debug = new DebugConfig(this); diff --git a/src/engine/Math/affine-matrix.ts b/src/engine/Math/affine-matrix.ts index e2687752b..4c9d61eab 100644 --- a/src/engine/Math/affine-matrix.ts +++ b/src/engine/Math/affine-matrix.ts @@ -173,7 +173,7 @@ export class AffineMatrix { // We don't actually use the 3rd or 4th dimension const det = this.determinant(); - const inverseDet = 1 / det; // TODO zero check + const inverseDet = 1 / det; // TODO zero check, or throw custom error for degenerate matrix const a = this.data[0]; const b = this.data[2]; const c = this.data[1]; @@ -332,14 +332,22 @@ export class AffineMatrix { public getScaleX(): number { // absolute scale of the matrix (we lose sign so need to add it back) - const xscale = vec(this.data[0], this.data[2]).distance(); - return this._scaleSignX * xscale; + const xScaleSq = this.data[0] * this.data[0] + this.data[2] * this.data[2]; + if (xScaleSq === 1.0) { + // usually there isn't scale so we can avoid a sqrt + return this._scaleSignX; + } + return this._scaleSignX * Math.sqrt(xScaleSq); } public getScaleY(): number { // absolute scale of the matrix (we lose sign so need to add it back) - const yscale = vec(this.data[1], this.data[3]).distance(); - return this._scaleSignY * yscale; + const yScaleSq = this.data[1] * this.data[1] + this.data[3] * this.data[3]; + if (yScaleSq === 1.0) { + // usually there isn't scale so we can avoid a sqrt + return this._scaleSignY; + } + return this._scaleSignY * Math.sqrt(yScaleSq); } /** diff --git a/src/engine/Math/line-segment.ts b/src/engine/Math/line-segment.ts index cee0c8288..5590fbaaa 100644 --- a/src/engine/Math/line-segment.ts +++ b/src/engine/Math/line-segment.ts @@ -1,3 +1,4 @@ +import { AffineMatrix } from './affine-matrix'; import { Vector } from './vector'; /** @@ -10,10 +11,24 @@ export class LineSegment { * @param end The ending point of the line segment */ constructor( - public readonly begin: Vector, - public readonly end: Vector + public begin: Vector, + public end: Vector ) {} + clone(dest?: LineSegment): LineSegment { + const result = dest || new LineSegment(this.begin.clone(), this.end.clone()); + result.begin = this.begin.clone(result.begin); + result.end = this.end.clone(result.end); + return result; + } + + transform(matrix: AffineMatrix, dest?: LineSegment): LineSegment { + const result = dest || new LineSegment(Vector.Zero, Vector.Zero); + result.begin = matrix.multiply(this.begin, result.begin); + result.end = matrix.multiply(this.end, result.end); + return result; + } + /** * Gets the raw slope (m) of the line. Will return (+/-)Infinity for vertical lines. */ @@ -74,18 +89,14 @@ export class LineSegment { return end.sub(begin); } - private _length: number; /** * Returns the length of the line segment in pixels */ public getLength(): number { - if (this._length) { - return this._length; - } const begin = this.begin; const end = this.end; const distance = begin.distance(end); - return (this._length = distance); + return distance; } /** @@ -116,9 +127,11 @@ export class LineSegment { * @param sideVector Vector that traces the line * @param length Length to clip along side */ - public clip(sideVector: Vector, length: number): LineSegment { + public clip(sideVector: Vector, length: number, normalize = true): LineSegment { let dir = sideVector; - dir = dir.normalize(); + if (normalize) { + dir = dir.normalize(); + } const near = dir.dot(this.begin) - length; const far = dir.dot(this.end) - length; diff --git a/src/engine/Math/transform.ts b/src/engine/Math/transform.ts index e08ec4ce4..01a9756ad 100644 --- a/src/engine/Math/transform.ts +++ b/src/engine/Math/transform.ts @@ -1,5 +1,5 @@ import { AffineMatrix } from './affine-matrix'; -import { canonicalizeAngle } from './util'; +import { canonicalizeAngle, sign } from './util'; import { vec, Vector } from './vector'; import { VectorView } from './vector-view'; import { WatchVector } from './watch-vector'; @@ -183,7 +183,13 @@ export class Transform { private _matrix = AffineMatrix.identity(); private _inverse = AffineMatrix.identity(); - public get matrix() { + /** + * Calculates and returns the matrix representation of this transform + * + * Avoid mutating the matrix to update the transform, it is not the source of truth. + * Update the transform pos, rotation, scale. + */ + public get matrix(): AffineMatrix { if (this._isDirty) { if (this.parent === null) { this._calculateMatrix().clone(this._matrix); @@ -195,7 +201,10 @@ export class Transform { return this._matrix; } - public get inverse() { + /** + * Calculates and returns the inverse matrix representation of this transform + */ + public get inverse(): AffineMatrix { if (this._isInverseDirty) { this.matrix.inverse(this._inverse); this._isInverseDirty = false; @@ -240,12 +249,22 @@ export class Transform { this.flagDirty(); } + /** + * Returns true if the transform has a negative x scale or y scale, but not both + */ + public isMirrored(): boolean { + const signBitX = sign(this.scale.x) >>> 31; + const signBitY = sign(this.scale.y) >>> 31; + const mirrored = signBitX ^ signBitY; + return !!mirrored; + } + /** * Clones the current transform * **Warning does not clone the parent** * @param dest */ - public clone(dest?: Transform) { + public clone(dest?: Transform): Transform { const target = dest ?? new Transform(); this._pos.clone(target._pos); target._z = this._z; @@ -254,4 +273,22 @@ export class Transform { target.flagDirty(); return target; } + + /** + * Clones but keeps the same parent reference + */ + public cloneWithParent(dest?: Transform): Transform { + const target = dest ?? new Transform(); + this._pos.clone(target._pos); + target._z = this._z; + target._rotation = this._rotation; + this._scale.clone(target._scale); + target.parent = this.parent; + target.flagDirty(); + return target; + } + + public toString(): string { + return this.matrix.toString(); + } } diff --git a/src/engine/Math/vector.ts b/src/engine/Math/vector.ts index 72a624d61..957e534f4 100644 --- a/src/engine/Math/vector.ts +++ b/src/engine/Math/vector.ts @@ -265,8 +265,13 @@ export class Vector implements Clonable { * Subtracts a vector from another, if you subtract vector `B.sub(A)` the resulting vector points from A -> B * @param v The vector to subtract */ - public sub(v: Vector): Vector { - return new Vector(this.x - v.x, this.y - v.y); + public sub(v: Vector, dest?: Vector): Vector { + const result = dest || new Vector(0, 0); + const x = this.x - v.x; + const y = this.y - v.y; + result.x = x; + result.y = y; + return result; } /** @@ -359,7 +364,8 @@ export class Vector implements Clonable { /** * Rotates the current vector around a point by a certain angle in radians. */ - public rotate(angle: number, anchor?: Vector): Vector { + public rotate(angle: number, anchor?: Vector, dest?: Vector): Vector { + const result = dest || new Vector(0, 0); if (!anchor) { anchor = new Vector(0, 0); } @@ -367,7 +373,9 @@ export class Vector implements Clonable { const cosAngle = Math.cos(angle); const x = cosAngle * (this.x - anchor.x) - sinAngle * (this.y - anchor.y) + anchor.x; const y = sinAngle * (this.x - anchor.x) + cosAngle * (this.y - anchor.y) + anchor.y; - return new Vector(x, y); + result.x = x; + result.y = y; + return result; } /** diff --git a/src/engine/Util/Util.ts b/src/engine/Util/Util.ts index 6c322adf2..99adc8b09 100644 --- a/src/engine/Util/Util.ts +++ b/src/engine/Util/Util.ts @@ -90,3 +90,39 @@ export function omit(object: } return newObj; } + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: any): item is object { + return item && typeof item === 'object' && !Array.isArray(item); +} + +/** + * Deep merge two objects. + * @param target + * @param ...sources + */ +export function mergeDeep(target: T, ...sources: T[]) { + if (!sources.length) { + return target; + } + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + mergeDeep(target[key] as any, source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return mergeDeep(target, ...sources); +} diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts index 171544980..d1587e0fd 100644 --- a/src/spec/ActorSpec.ts +++ b/src/spec/ActorSpec.ts @@ -18,7 +18,14 @@ describe('A game actor', () => { }); beforeEach(async () => { - engine = TestUtils.engine({ width: 100, height: 100 }); + engine = TestUtils.engine({ + width: 100, + height: 100, + physics: { + solver: ex.SolverStrategy.Arcade, + gravity: ex.vec(0, 0) + } + }); actor = new ex.Actor({ name: 'Default' }); actor.body.collisionType = ex.CollisionType.Active; scene = new ex.Scene(); @@ -38,14 +45,12 @@ describe('A game actor', () => { clock.step(1); collisionSystem.initialize(scene.world, scene); scene.world.systemManager.get(ex.PointerSystem).initialize(scene.world, scene); - - ex.Physics.useArcadePhysics(); - ex.Physics.acc.setTo(0, 0); }); afterEach(() => { engine.stop(); engine.dispose(); + actor = null; engine = null; }); @@ -919,7 +924,7 @@ describe('A game actor', () => { it('can recursively check containment', () => { const parent = new ex.Actor({ x: 0, y: 0, width: 100, height: 100 }); const child = new ex.Actor({ x: 100, y: 100, width: 100, height: 100 }); - const child2 = new ex.Actor({ x: 100, y: 100, width: 100, height: 100 }); + const grandChild = new ex.Actor({ x: 100, y: 100, width: 100, height: 100 }); parent.addChild(child); expect(parent.contains(150, 150)).toBeFalsy(); @@ -927,7 +932,7 @@ describe('A game actor', () => { expect(parent.contains(150, 150, true)).toBeTruthy(); expect(parent.contains(200, 200, true)).toBeFalsy(); - child.addChild(child2); + child.addChild(grandChild); expect(parent.contains(250, 250, true)).toBeTruthy(); }); diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index 2ee096df8..a086de359 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -562,7 +562,7 @@ describe('Collision Shape', () => { const contact = polyA.collide(polyB)[0]; // there should be a collision - expect(contact).not.toBe(null); + expect(contact).withContext('there should be a collision').not.toBeFalsy(); // normal and mtv should point away from bodyA expect(directionOfBodyB.dot(contact.mtv)).toBeGreaterThan(0); @@ -574,7 +574,7 @@ describe('Collision Shape', () => { expect(contact.normal.y).toBeCloseTo(0, 0.01); }); - it('can collide when the transform changes the winding', () => { + it('can collide when the transform changes the winding (mirrored)', () => { const polyA = new ex.PolygonCollider({ offset: ex.Vector.Zero.clone(), // specified relative to the position @@ -586,10 +586,9 @@ describe('Collision Shape', () => { points: [new ex.Vector(-10, -10), new ex.Vector(10, -10), new ex.Vector(10, 10), new ex.Vector(-10, 10)] }); - const transform = new ex.Transform(); - transform.scale = ex.vec(-1, 1); - - polyA.update(transform); + const mirrorTransform = new ex.Transform(); + mirrorTransform.scale = ex.vec(-1, 1); + polyA.update(mirrorTransform); const directionOfBodyB = polyB.center.sub(polyA.center); diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts index 6024a912d..1ad1a641f 100644 --- a/src/spec/CollisionSpec.ts +++ b/src/spec/CollisionSpec.ts @@ -9,7 +9,13 @@ describe('A Collision', () => { let clock: ex.TestClock = null; beforeEach(async () => { - engine = TestUtils.engine({ width: 600, height: 400 }); + engine = TestUtils.engine({ + width: 600, + height: 400, + physics: { + solver: ex.SolverStrategy.Arcade + } + }); clock = engine.clock = engine.clock.toTestClock(); actor1 = new ex.Actor({ x: 0, y: 0, width: 10, height: 10 }); @@ -51,14 +57,7 @@ describe('A Collision', () => { it('order of actors collision should not matter when an Active and Active Collision', () => { const collisionTree = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); actor1.body.collisionType = ex.CollisionType.Active; @@ -77,14 +76,7 @@ describe('A Collision', () => { it('order of actors collision should not matter when an Active and Passive Collision', () => { const collisionTree = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); actor1.body.collisionType = ex.CollisionType.Active; diff --git a/src/spec/CompositeColliderSpec.ts b/src/spec/CompositeColliderSpec.ts index 50cd697c0..726f71aa6 100644 --- a/src/spec/CompositeColliderSpec.ts +++ b/src/spec/CompositeColliderSpec.ts @@ -281,14 +281,7 @@ describe('A CompositeCollider', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); dynamicTreeProcessor.track(compCollider); @@ -301,14 +294,7 @@ describe('A CompositeCollider', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); dynamicTreeProcessor.track(compCollider); diff --git a/src/spec/DynamicTreeBroadphaseSpec.ts b/src/spec/DynamicTreeBroadphaseSpec.ts index 8bd70ee25..f2b8f2b85 100644 --- a/src/spec/DynamicTreeBroadphaseSpec.ts +++ b/src/spec/DynamicTreeBroadphaseSpec.ts @@ -36,14 +36,7 @@ describe('A DynamicTree Broadphase', () => { it('can find collision pairs for actors that are potentially colliding', () => { const dt = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); dt.track(actorA.collider.get()); dt.track(actorB.collider.get()); @@ -61,14 +54,7 @@ describe('A DynamicTree Broadphase', () => { const compCollider = new ex.CompositeCollider([circle, box]); const actor = new ex.Actor({ collider: compCollider }); const dt = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); dt.track(compCollider); @@ -83,14 +69,7 @@ describe('A DynamicTree Broadphase', () => { 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, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); dt.track(compCollider); @@ -100,14 +79,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); @@ -126,14 +98,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); @@ -160,14 +125,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); @@ -192,14 +150,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const collisionGroup1 = ex.CollisionGroupManager.create('somegroup1'); const collisionGroup2 = ex.CollisionGroupManager.create('somegroup2'); @@ -227,14 +178,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); @@ -256,14 +200,7 @@ describe('A DynamicTree Broadphase', () => { it('can rayCast with ignoreCollisionGroupAll, returns 1 hit', () => { const sut = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); @@ -288,14 +225,7 @@ describe('A DynamicTree Broadphase', () => { it('can rayCast with filter, returns 1 hit', () => { const sut = new ex.DynamicTreeCollisionProcessor({ - ...DefaultPhysicsConfig, - ...{ - spatialPartition: { - type: 'dynamic-tree', - boundsPadding: 5, - velocityMultiplier: 2 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); @@ -321,14 +251,7 @@ describe('A DynamicTree Broadphase', () => { 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 - } - } + ...DefaultPhysicsConfig }); const actor1 = new ex.Actor({ x: 100, y: 0, width: 50, height: 50 }); sut.track(actor1.collider.get()); diff --git a/src/spec/SparseHashGridCollisionProcessorSpec.ts b/src/spec/SparseHashGridCollisionProcessorSpec.ts index 0dcda3a8d..5e4e09cd1 100644 --- a/src/spec/SparseHashGridCollisionProcessorSpec.ts +++ b/src/spec/SparseHashGridCollisionProcessorSpec.ts @@ -11,7 +11,7 @@ describe('A Sparse Hash Grid Broadphase', () => { actorA.body.collisionType = ex.CollisionType.Active; actorA.collider.update(); - actorB = new ex.Actor({ x: 20, y: 0, width: 20, height: 20 }); + actorB = new ex.Actor({ x: 10, y: 0, width: 20, height: 20 }); actorB.collider.useCircleCollider(10); actorB.body.collisionType = ex.CollisionType.Active; actorB.collider.update(); @@ -77,7 +77,7 @@ describe('A Sparse Hash Grid Broadphase', () => { const colliders = dt.query(actorA.collider.bounds); - expect(colliders.length).toBe(1); + expect(colliders.length).toBe(2); }); it('can rayCast with default options, only 1 hit is returned, searches all groups', () => {