From 8d45e35e6742b545c067c1aebbfcf313355a6fcf Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Mon, 27 Jun 2022 09:32:56 -0500 Subject: [PATCH] fix: [#2368] elastic collisions & degree of freedom (#2369) Closes #2368 This PR corrects the math around elastic collisions in the realistic collision solver, elastic collisions now preserve energy as expected. Additionally this PR also fixes an issue with the degrees of freedom limiter ![bouncy](https://user-images.githubusercontent.com/612071/175856506-17f3f6d5-d7af-4b1f-96d8-639205a04078.gif) --- CHANGELOG.md | 3 + sandbox/tests/physics/physics.ts | 14 +- src/engine/Actor.ts | 2 +- src/engine/Collision/BodyComponent.ts | 2 + .../Collision/Colliders/CircleCollider.ts | 21 +-- .../Solver/ContactConstraintPoint.ts | 5 + .../Collision/Solver/RealisticSolver.ts | 57 ++++-- src/spec/ActorSpec.ts | 1 + src/spec/CollisionContactSpec.ts | 162 +++++++++++++++++- src/spec/CollisionShapeSpec.ts | 2 + 10 files changed, 237 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6878db7..854cf62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/sandbox/tests/physics/physics.ts b/sandbox/tests/physics/physics.ts index 78c03463..0ad66bc0 100644 --- a/sandbox/tests/physics/physics.ts +++ b/sandbox/tests/physics/physics.ts @@ -2,7 +2,8 @@ var game = new ex.Engine({ width: 600, - height: 400 + height: 400, + fixedUpdateFps: 60 }); game.backgroundColor = ex.Color.Black; @@ -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) => { @@ -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); @@ -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); @@ -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(); diff --git a/src/engine/Actor.ts b/src/engine/Actor.ts index 4e599186..e91e8681 100644 --- a/src/engine/Actor.ts +++ b/src/engine/Actor.ts @@ -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))); diff --git a/src/engine/Collision/BodyComponent.ts b/src/engine/Collision/BodyComponent.ts index bcd2f910..0b2e3988 100644 --- a/src/engine/Collision/BodyComponent.ts +++ b/src/engine/Collision/BodyComponent.ts @@ -171,6 +171,8 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable 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++; } } @@ -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; + } } } } @@ -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 @@ -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) diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts index a334211a..8230fab5 100644 --- a/src/spec/ActorSpec.ts +++ b/src/spec/ActorSpec.ts @@ -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', () => { diff --git a/src/spec/CollisionContactSpec.ts b/src/spec/CollisionContactSpec.ts index 7867711f..9afb7858 100644 --- a/src/spec/CollisionContactSpec.ts +++ b/src/spec/CollisionContactSpec.ts @@ -105,6 +105,8 @@ describe('A CollisionContact', () => { actorA.vel.x = 10; actorB.vel.x = -10; actorB.pos.x = 19; + actorA.body.bounciness = 0; + actorB.body.bounciness = 0; actorA.collider.update(); actorB.collider.update(); const cc = new ex.CollisionContact( @@ -131,15 +133,171 @@ describe('A CollisionContact', () => { expect(actorA.pos.x).toBeCloseTo(-0.5, 1); expect(actorA.pos.y).toBe(0); - expect(actorA.vel.x).toBeLessThan(0); + expect(actorA.vel.x).toBe(0); expect(actorA.vel.y).toBe(0); expect(actorB.pos.x).toBeCloseTo(19.5, 1); expect(actorB.pos.y).toBe(0); - expect(actorB.vel.x).toBeGreaterThan(0); + expect(actorB.vel.x).toBe(0); expect(actorB.vel.y).toBe(0); }); + it('can resolve perfectly elastic collisions in the Realistic solver', () => { + expect(actorA.pos.x).toBe(0, 'Actor A should be y=10'); + expect(actorA.pos.y).toBe(0, 'Actor A should be y=0'); + expect(actorB.pos.x).toBe(20, 'Actor B should be x=20'); + expect(actorB.pos.y).toBe(0, 'Actor B should be y=0'); + expect(actorA.vel.x).toBe(0, 'Actor A should not be moving in x'); + expect(actorB.vel.x).toBe(0, 'Actor B should not be moving in x'); + actorA.vel.x = 10; + actorB.vel.x = -10; + actorB.pos.x = 19; + actorA.body.bounciness = 1.0; + actorB.body.bounciness = 1.0; + actorA.collider.update(); + actorB.collider.update(); + const cc = new ex.CollisionContact( + colliderA, + colliderB, + ex.Vector.Right.clone(), + ex.Vector.Right.clone(), + ex.Vector.Right.perpendicular(), + [new ex.Vector(10, 0)], + [new ex.Vector(10, 0)], + null + ); + ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here + const solver = new ex.RealisticSolver(); + + solver.solve([cc]); + // Realistic solver uses velocity impulses to correct overlap + EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30); + EulerIntegrator.integrate(actorB.get(TransformComponent), actorB.get(MotionComponent), ex.Vector.Zero, 30); + + expect(actorA.pos.x).toBeCloseTo(-0.5, 1); + expect(actorA.pos.y).toBe(0); + expect(actorA.vel.x).toBe(-10); + expect(actorA.vel.y).toBe(0); + + expect(actorB.pos.x).toBeCloseTo(19.5, 1); + expect(actorB.pos.y).toBe(0); + expect(actorB.vel.x).toBe(10); + expect(actorB.vel.y).toBe(0); + }); + + it('can limit the rotation degree of freedom', () => { + expect(actorA.pos.x).toBe(0, 'Actor A should be y=10'); + expect(actorA.pos.y).toBe(0, 'Actor A should be y=0'); + expect(actorB.pos.x).toBe(20, 'Actor B should be x=20'); + expect(actorB.pos.y).toBe(0, 'Actor B should be y=0'); + expect(actorA.vel.x).toBe(0, 'Actor A should not be moving in x'); + expect(actorB.vel.x).toBe(0, 'Actor B should not be moving in x'); + actorA.vel.x = 10; + actorB.vel.x = -10; + actorB.pos.x = 19; + actorB.pos.y = 10; + actorA.body.bounciness = 0; + actorB.body.bounciness = 0; + actorB.body.limitDegreeOfFreedom.push(ex.DegreeOfFreedom.Rotation); + actorA.collider.update(); + actorB.collider.update(); + const cc = new ex.CollisionContact( + colliderA, + colliderB, + ex.Vector.Right.clone(), + ex.Vector.Right.clone(), + ex.Vector.Right.perpendicular(), + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + null + ); + ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here + const solver = new ex.RealisticSolver(); + solver.solve([cc]); + // Realistic solver uses velocity impulses to correct overlap + EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30); + EulerIntegrator.integrate(actorB.get(TransformComponent), actorB.get(MotionComponent), ex.Vector.Zero, 30); + expect(actorA.rotation).not.toBe(0); + expect(actorA.angularVelocity).not.toBe(0); + expect(actorB.rotation).toBe(0); + expect(actorB.angularVelocity).toBe(0); + }); + + it('can limit the y degree of freedom', () => { + expect(actorA.pos.x).toBe(0, 'Actor A should be y=10'); + expect(actorA.pos.y).toBe(0, 'Actor A should be y=0'); + expect(actorB.pos.x).toBe(20, 'Actor B should be x=20'); + expect(actorB.pos.y).toBe(0, 'Actor B should be y=0'); + expect(actorA.vel.x).toBe(0, 'Actor A should not be moving in x'); + expect(actorB.vel.x).toBe(0, 'Actor B should not be moving in x'); + actorA.vel.x = 10; + actorB.vel.x = -10; + actorB.pos.x = 19; + actorB.pos.y = 10; + actorA.body.bounciness = 0; + actorB.body.bounciness = 0; + actorB.body.limitDegreeOfFreedom.push(ex.DegreeOfFreedom.Y); + actorA.collider.update(); + actorB.collider.update(); + const cc = new ex.CollisionContact( + colliderA, + colliderB, + ex.Vector.Right.clone(), + ex.Vector.Right.clone(), + ex.Vector.Right.perpendicular(), + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + null + ); + ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here + const solver = new ex.RealisticSolver(); + solver.solve([cc]); + // Realistic solver uses velocity impulses to correct overlap + EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30); + EulerIntegrator.integrate(actorB.get(TransformComponent), actorB.get(MotionComponent), ex.Vector.Zero, 30); + expect(actorA.rotation).not.toBe(0); + expect(actorA.angularVelocity).not.toBe(0); + expect(actorB.pos.x).toBeCloseTo(19, 1); + expect(actorB.pos.y).toBe(10); + }); + + it('can limit the x degree of freedom', () => { + expect(actorA.pos.x).toBe(0, 'Actor A should be y=10'); + expect(actorA.pos.y).toBe(0, 'Actor A should be y=0'); + expect(actorB.pos.x).toBe(20, 'Actor B should be x=20'); + expect(actorB.pos.y).toBe(0, 'Actor B should be y=0'); + expect(actorA.vel.x).toBe(0, 'Actor A should not be moving in x'); + expect(actorB.vel.x).toBe(0, 'Actor B should not be moving in x'); + actorA.vel.x = 10; + actorB.pos.x = 19; + actorB.pos.y = 10; + actorA.body.bounciness = 0; + actorB.body.bounciness = 0; + actorB.body.limitDegreeOfFreedom.push(ex.DegreeOfFreedom.X); + actorA.collider.update(); + actorB.collider.update(); + const cc = new ex.CollisionContact( + colliderA, + colliderB, + ex.Vector.Right.clone(), + ex.Vector.Right.clone(), + ex.Vector.Right.perpendicular(), + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + [new ex.Vector(10, 0), new ex.Vector(10, 10)], + null + ); + ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here + const solver = new ex.RealisticSolver(); + solver.solve([cc]); + // Realistic solver uses velocity impulses to correct overlap + EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30); + EulerIntegrator.integrate(actorB.get(TransformComponent), actorB.get(MotionComponent), ex.Vector.Zero, 30); + expect(actorA.rotation).not.toBe(0); + expect(actorA.angularVelocity).not.toBe(0); + expect(actorB.pos.x).toBe(19); + expect(actorB.pos.y).toBeCloseTo(10, 0); + }); + it('emits a collision event on both in the Realistic solver', () => { let emittedA = false; let emittedB = false; diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index 3a08ddb5..20c458fc 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -61,6 +61,7 @@ describe('Collision Shape', () => { it('has a center', () => { actor.pos = ex.vec(170, 300); + actor.collider.update(); const center = circle.center; expect(center.x).toBe(170); expect(center.y).toBe(300); @@ -292,6 +293,7 @@ describe('Collision Shape', () => { it('should collide with other edges when touching the edge end', () => { // position the circle actor in the end of the edge actor.pos = ex.vec(10, -9); + actor.collider.update(); const actor2 = new ex.Actor({ x: 0, y: 0, width: 10, height: 10 }); const edge = actor2.collider.useEdgeCollider(new ex.Vector(0, 0), new ex.Vector(10, 0)); -- 2.51.2