Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [#2368] elastic collisions & degree of freedom #2369

Merged
merged 7 commits into from
Jun 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Breaking Changes

- The `ex.Physics.useRealisticPhysics()` physics solver has been updated to fix a bug in bounciness to be more physically accurate, this does change how physics behaves. Setting `ex.Body.bounciness = 0` will simulate the old behavior.
- `ex.TransformComponent.posChanged$` has been removed, it incurs a steep performance cost
- `ex.EventDispatcher` meta events 'subscribe' and 'unsubscribe' were unused and undocumented and have been removed

Expand Down Expand Up @@ -108,6 +109,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add target element id to `ex.Screen.goFullScreen('some-element-id')` to influence the fullscreen element in the fullscreen browser API.

### Fixed
- Fixed bug in `ex.Physics.useRealisticPhysics()` solver where `ex.Body.bounciness` was not being respected in the simulation
- Fixed bug in `ex.Physics.useRealisticPhysics()` solver where `ex.Body.limitDegreeOfFreedom` was not working all the time.
- Fixed bug in `Clock.schedule` where callbacks would not fire at the correct time, this was because it was scheduling using browser time and not the clock's internal time.
- Fixed issue in Chromium browsers where Excalibur crashes if more than 256 `Image.decode()` calls are happening in the same frame.
- Fixed issue where `ex.EdgeCollider` were not working properly in `ex.CompositeCollider` for `ex.TileMap`'s
Expand Down
14 changes: 10 additions & 4 deletions sandbox/tests/physics/physics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

var game = new ex.Engine({
width: 600,
height: 400
height: 400,
fixedUpdateFps: 60
});
game.backgroundColor = ex.Color.Black;

Expand Down Expand Up @@ -32,6 +33,8 @@ function spawnBlock(x: number, y: number) {
height: width + 100
});
block.rotation = globalRotation;
block.body.bounciness = 0;
// block.body.limitDegreeOfFreedom.push(ex.DegreeOfFreedom.Rotation);
// block.body.addBoxCollider(width + 200, width / 2);
// block.collider.useBoxCollider(width / 2, width + 100);
block.body.events.on('contactstart', (e) => {
Expand All @@ -56,10 +59,11 @@ function spawnCircle(x: number, y: number) {
var color = new ex.Color(255, ex.randomIntInRange(0, 255), ex.randomIntInRange(0, 255));
var circle = new ex.Actor({x: x, y: y, radius: width / 2, color: color});
// circle.rx = ex.Util.randomInRange(-0.5, 0.5);
circle.angularVelocity = 1;
circle.vel.setTo(0, 300);
// circle.angularVelocity = 1;
// circle.vel.setTo(0, 300);
// circle.collider.useCircleCollider(width / 2);
circle.body.collisionType = ex.CollisionType.Active;
circle.body.bounciness = 1.0;
circle.graphics.onPostDraw = (ctx: ex.ExcaliburGraphicsContext) => {
ctx.drawCircle(ex.vec(0, 0), width / 2, color);
// ex.Util.DrawUtil.circle(ctx, 0, 0, width / 2, color, color);
Expand Down Expand Up @@ -95,6 +99,7 @@ game.add(edge);

var ground = new ex.Actor({x: 300, y: 380, width: 600, height: 10, color: ex.Color.Azure.clone()});
ground.body.collisionType = ex.CollisionType.Fixed;
ground.body.bounciness = 0.0;
ground.collider.useBoxCollider(600, 10); // optional
game.add(ground);

Expand Down Expand Up @@ -123,7 +128,8 @@ game.input.keyboard.on('down', (evt: ex.Input.KeyEvent) => {
});

game.input.pointers.primary.on('down', (evt: ex.Input.PointerEvent) => {
spawnBlock(evt.worldPos.x, evt.worldPos.y);
// spawnBlock(evt.worldPos.x, evt.worldPos.y);
spawnCircle(evt.worldPos.x, evt.worldPos.y);
});

game.start();
Expand Down
2 changes: 1 addition & 1 deletion src/engine/Actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ export class Actor extends Entity implements Eventable, PointerEvents, CanInitia
if (collider) {
this.addComponent(new ColliderComponent(collider));
} else if (radius) {
this.addComponent(new ColliderComponent(Shape.Circle(radius, this.anchor)));
this.addComponent(new ColliderComponent(Shape.Circle(radius)));
} else {
if (width > 0 && height > 0) {
this.addComponent(new ColliderComponent(Shape.Box(width, height, this.anchor)));
Expand Down
2 changes: 2 additions & 0 deletions src/engine/Collision/BodyComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable<Body

/**
* Degrees of freedom to limit
*
* Note: this only limits responses in the realistic solver, if velocity/angularVelocity is set the actor will still respond
*/
public limitDegreeOfFreedom: DegreeOfFreedom[] = [];

Expand Down
21 changes: 9 additions & 12 deletions src/engine/Collision/Colliders/CircleCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Collider } from './Collider';
import { ClosestLineJumpTable } from './ClosestLineJumpTable';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
import { Transform } from '../../Math/transform';
import { AffineMatrix } from '../../Math/affine-matrix';

export interface CircleColliderOptions {
/**
Expand All @@ -35,13 +36,10 @@ export class CircleCollider extends Collider {
*/
public offset: Vector = Vector.Zero;

public get worldPos(): Vector {
const tx = this._transform;
const scale = tx?.globalScale ?? Vector.One;
const rotation = tx?.globalRotation ?? 0;
const pos = (tx?.globalPos ?? Vector.Zero);
private _globalMatrix: AffineMatrix = AffineMatrix.identity();

return (this.offset ?? Vector.Zero).scale(scale).rotate(rotation).add(pos);
public get worldPos(): Vector {
return this._globalMatrix.getPosition();
}

private _naturalRadius: number;
Expand Down Expand Up @@ -71,6 +69,7 @@ export class CircleCollider extends Collider {
super();
this.offset = options.offset || Vector.Zero;
this.radius = options.radius || 0;
this._globalMatrix.translate(this.offset.x, this.offset.y);
}

/**
Expand All @@ -87,12 +86,7 @@ export class CircleCollider extends Collider {
* Get the center of the collider in world coordinates
*/
public get center(): Vector {
const tx = this._transform;
const scale = tx?.globalScale ?? Vector.One;
const rotation = tx?.globalRotation ?? 0;
const pos = (tx?.globalPos ?? Vector.Zero);

return (this.offset ?? Vector.Zero).scale(scale).rotate(rotation).add(pos);
return this._globalMatrix.getPosition();
}

/**
Expand Down Expand Up @@ -241,6 +235,9 @@ export class CircleCollider extends Collider {
/* istanbul ignore next */
public update(transform: Transform): void {
this._transform = transform;
const globalMat = transform.matrix ?? this._globalMatrix;
globalMat.clone(this._globalMatrix);
this._globalMatrix.translate(this.offset.x, this.offset.y);
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/engine/Collision/Solver/ContactConstraintPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,9 @@ export class ContactConstraintPoint {
* Direction from center of mass of bodyB to contact point
*/
public bToContact: Vector = new Vector(0, 0);

/**
* Original contact velocity combined with bounciness
*/
public originalVelocityAndRestitution: number = 0;
}
57 changes: 44 additions & 13 deletions src/engine/Collision/Solver/RealisticSolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ContactConstraintPoint } from './ContactConstraintPoint';
import { Side } from '../Side';
import { Physics } from '../Physics';
import { CollisionSolver } from './Solver';
import { BodyComponent } from '../BodyComponent';
import { BodyComponent, DegreeOfFreedom } from '../BodyComponent';
import { CollisionJumpTable } from '../Colliders/CollisionJumpTable';

export class RealisticSolver implements CollisionSolver {
Expand Down Expand Up @@ -111,9 +111,16 @@ export class RealisticSolver implements CollisionSolver {
// Update contact point calculations
contactPoints[pointIndex].aToContact = aToContact;
contactPoints[pointIndex].bToContact = bToContact;
contactPoints[pointIndex].normalMass = normalMass;
contactPoints[pointIndex].tangentMass = tangentMass;

contactPoints[pointIndex].normalMass = 1.0 / normalMass;
contactPoints[pointIndex].tangentMass = 1.0 / tangentMass;

// Calculate relative velocity before solving to accurately do restitution
const restitution = bodyA.bounciness > bodyB.bounciness ? bodyA.bounciness : bodyB.bounciness;
const relativeVelocity = contact.normal.dot(contactPoints[pointIndex].getRelativeVelocity());
contactPoints[pointIndex].originalVelocityAndRestitution = 0;
if (relativeVelocity < -0.1) { // TODO what's a good threshold here?
contactPoints[pointIndex].originalVelocityAndRestitution = -restitution * relativeVelocity;
}
pointIndex++;
}
}
Expand Down Expand Up @@ -235,18 +242,39 @@ export class RealisticSolver implements CollisionSolver {
// Clamp to avoid over-correction
// Remember that we are shooting for 0 overlap in the end
const steeringForce = clamp(steeringConstant * (separation + slop), maxCorrection, 0);
const impulse = normal.scale(-steeringForce / point.normalMass);
const impulse = normal.scale(-steeringForce * point.normalMass);

// This is a pseudo impulse, meaning we aren't doing a real impulse calculation
// We adjust position and rotation instead of doing the velocity
if (bodyA.collisionType === CollisionType.Active) {
bodyA.pos = bodyA.pos.add(impulse.negate().scale(bodyA.inverseMass));
bodyA.rotation -= point.aToContact.cross(impulse) * bodyA.inverseInertia;
// TODO make applyPseudoImpulse function?
const impulseForce = impulse.negate().scale(bodyA.inverseMass);
if (bodyA.limitDegreeOfFreedom.includes(DegreeOfFreedom.X)) {
impulseForce.x = 0;
}
if (bodyA.limitDegreeOfFreedom.includes(DegreeOfFreedom.Y)) {
impulseForce.y = 0;
}

bodyA.pos = bodyA.pos.add(impulseForce);
if (!bodyA.limitDegreeOfFreedom.includes(DegreeOfFreedom.Rotation)) {
bodyA.rotation -= point.aToContact.cross(impulse) * bodyA.inverseInertia;
}
}

if (bodyB.collisionType === CollisionType.Active) {
bodyB.pos = bodyB.pos.add(impulse.scale(bodyB.inverseMass));
bodyB.rotation += point.bToContact.cross(impulse) * bodyB.inverseInertia;
const impulseForce = impulse.scale(bodyB.inverseMass);
if (bodyB.limitDegreeOfFreedom.includes(DegreeOfFreedom.X)) {
impulseForce.x = 0;
}
if (bodyB.limitDegreeOfFreedom.includes(DegreeOfFreedom.Y)) {
impulseForce.y = 0;
}

bodyB.pos = bodyB.pos.add(impulseForce);
if (!bodyB.limitDegreeOfFreedom.includes(DegreeOfFreedom.Rotation)) {
bodyB.rotation += point.bToContact.cross(impulse) * bodyB.inverseInertia;
}
}
}
}
Expand All @@ -266,17 +294,17 @@ export class RealisticSolver implements CollisionSolver {
continue;
}

const restitution = bodyA.bounciness * bodyB.bounciness;
const friction = Math.min(bodyA.friction, bodyB.friction);

const constraints = this.idToContactConstraint.get(contact.id) ?? [];

// Friction constraint
for (const point of constraints) {
const relativeVelocity = point.getRelativeVelocity();

// Negate velocity in tangent direction to simulate friction
const tangentVelocity = -relativeVelocity.dot(contact.tangent);
let impulseDelta = tangentVelocity / point.tangentMass;
let impulseDelta = tangentVelocity * point.tangentMass;

// Clamping based in Erin Catto's GDC 2006 talk
// Correct clamping https://github.com/erincatto/box2d-lite/blob/master/docs/GDC2006_Catto_Erin_PhysicsTutorial.pdf
Expand All @@ -292,14 +320,17 @@ export class RealisticSolver implements CollisionSolver {
bodyB.applyImpulse(point.point, impulse);
}

// Bounce constraint
for (const point of constraints) {
// Need to recalc relative velocity because the previous step could have changed vel
const relativeVelocity = point.getRelativeVelocity();

// Compute impulse in normal direction
const normalVelocity = relativeVelocity.dot(contact.normal);
// See https://en.wikipedia.org/wiki/Collision_response
let impulseDelta = (-(1 + restitution) * normalVelocity) / point.normalMass;

// Per Erin it is a mistake to apply the restitution inside the iteration
// From Erin Catto's Box2D we keep original contact velocity and adjust by small impulses
let impulseDelta = -point.normalMass * (normalVelocity - point.originalVelocityAndRestitution);

// Clamping based in Erin Catto's GDC 2014 talk
// Accumulated impulse stored in the contact is always positive (dV > 0)
Expand Down
1 change: 1 addition & 0 deletions src/spec/ActorSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe('A game actor', () => {
expect((actor.graphics.current[0].graphic as ex.Circle).radius).toBe(10);
expect((actor.graphics.current[0].graphic as ex.Circle).color).toEqual(ex.Color.Red);
expect(actor.collider.get()).toBeInstanceOf(ex.CircleCollider);
expect(actor.collider.get().offset).toBeVector(ex.vec(0, 0));
});

it('can be created with a width/height with default rectangle collider and graphic', () => {
Expand Down
Loading