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));