Skip to content

Commit

Permalink
perf: Improve narrowphase and realistic solver performance (#3114)
Browse files Browse the repository at this point in the history
Closes #3006 thanks @ikudrickiy!

This PR implements a lot of low hanging fruit optimizations to the narrowphase and realistic solver. 

Notably:
* 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 component calls
* Removing allocations where it makes sense
* Optimize Side.fromDirection(direction: Vector): Side - thanks @ikudrickiy! 

Also this fixes a bug in the new physics config merging, and re-arranged to better match the existing pattern
  • Loading branch information
eonarheim authored Jun 30, 2024
1 parent 3a2f408 commit fcec226
Show file tree
Hide file tree
Showing 34 changed files with 505 additions and 366 deletions.
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none"
"trailingComma": "none",
"endOfLine": "auto"
}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions sandbox/tests/many-colliders/index.ts
Original file line number Diff line number Diff line change
@@ -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++) {
Expand Down
14 changes: 9 additions & 5 deletions sandbox/tests/physics/physics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
23 changes: 13 additions & 10 deletions src/engine/Collision/BodyComponent.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -255,12 +256,12 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
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<any>): void {
this.transform = this.owner?.get(TransformComponent);
this.motion = this.owner?.get(MotionComponent);
}

public get pos(): Vector {
Expand Down Expand Up @@ -405,6 +406,8 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
this.motion.angularVelocity = value;
}

private _impulseScratch = vec(0, 0);
private _distanceFromCenterScratch = vec(0, 0);
/**
* Apply a specific impulse to the body
* @param point
Expand All @@ -415,18 +418,18 @@ export class BodyComponent extends Component implements Clonable<BodyComponent>
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);
}
}
Expand Down
54 changes: 27 additions & 27 deletions src/engine/Collision/BoundingBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
64 changes: 39 additions & 25 deletions src/engine/Collision/Colliders/CollisionJumpTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 [];
Expand All @@ -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();
Expand All @@ -261,26 +285,16 @@ 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 [];
},

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) {
Expand Down
10 changes: 1 addition & 9 deletions src/engine/Collision/Colliders/CompositeCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
Loading

0 comments on commit fcec226

Please sign in to comment.