Making Pachinko with Physics
The intent for this project was to write a 2D physics system with support to handle rigid body collisions between different 2D objects. The idea was to be able to create a simple Pachinko styled game that could handle bouncing and rotating 2D discs that could collide on a series of obstacles that could be represented as other 2D shapes.
The starting point
At the start of this project, the Prodigy custom C++ engine was only capable of rendering and computing axis-aligned bounding boxes(AABB) and discs. This meant that implementing a physics system would have rectangular objects that would collide but be unable to rotate. It also meant that I would need to create other shapes and to handle objects like capsules, rounded boxes, and other shapes to be able to fully implement Pachinko style physics as desired. So a road map was followed to incorporate the required features and tech to achieve the desired effect.
Project Roadmap
Below was the roadmap followed to implement the required features in the engine in order to implement a functional 2D physics system.
##Project Plan## #Week 1 and 2# - Implement a simple physics zoo supporting AABB and disc2D collisions. - Dynamic objects should fall off the screen under the effect of gravity. - Visualize objects by color based on object properties #Week 3 and 4# - Have a basic 2D Physics system with elastic collision response with adjustable restitution and mass. - Generate collision Manifolds as area of object overlap during collision - Resolving momentum for inelastic collisions #Week 5 and 6# - Implementing Oriented Bounding Boxes - Implementing Capsules - Adding mouse support to application #Week 7 and 8# - Implementing rotational forces(torque) - Updating intersection code to generate a contact point, as well as be able to apply torque. #Week 9 and 10# - Adding frictional impulses to the system - Adding angular drag forces - Adding rotation and linear constraints on rigid bodies - Adding nested clocks to slow down or speedup time as needed to observe the system - Implementing a debug view on hovering objects in the scene #Week 11 and 12# - Adding collision events using an event system - Adding trigger volumes to the system using collider shapes
Using what I already had
Without adding any major new features, I was able to implement a collision zoo that used simple physics logic to handle collision resolution between AABB and disc2D objects. The system used equations from the separating axis theorem to handle collision events between the 2 objects.
I did need to implement a physics system with the existing primitive shapes which required some definition of a rigid body and a collider. To do so, I followed the following architecture.
class Collider2D { public: virtual void SetMomentForObject() = 0; virtual bool Contains(Vec2 worldPoint) = 0; void SetCollision(bool inCollision); void SetCollisionEvent(const std::string& eventString); void SetColliderType(eColliderType2D type); void Destroy(); bool IsTouching(Collision2D* collision, Collider2D* otherCollider); eColliderType2D GetType(); void FireCollisionEvent(EventArgs& args); public: Rigidbody2D* m_rigidbody = nullptr; eColliderType2D m_colliderType = COLLIDER_UNKOWN; bool m_inCollision = false; bool m_isAlive = true; std::string m_onCollisionEvent = ""; }; //-------------------------------------------------------------------------------- class AABB2Collider: public Collider2D { public: explicit AABB2Collider(const Vec2& minBounds, const Vec2& maxBounds); ~AABB2Collider(); virtual void SetMomentForObject(); virtual bool Contains(Vec2 worldPoint); AABB2 GetLocalShape() const; //Shape relative to rigidbody AABB2 GetWorldShape() const; //Shape in world Vec2 GetBoxCenter() const; public: AABB2 m_localShape; }; //-------------------------------------------------------------------------------- class Disc2DCollider: public Collider2D { public: explicit Disc2DCollider(const Vec2& centre, float radius); ~Disc2DCollider(); virtual void SetMomentForObject(); virtual bool Contains(Vec2 worldPoint); Disc2D GetLocalShape() const; Disc2D GetWorldShape() const; public: Disc2D m_localShape; };
//-------------------------------------------------------------------------------- struct PhysicsMaterialT { float restitution = 1.f; }; //-------------------------------------------------------------------------------- class Rigidbody2D { public: Rigidbody2D( float mass = 1.0f); explicit Rigidbody2D(PhysicsSystem* physicsSystem, eSimulationType simulationType, float mass = 1.0f); ~Rigidbody2D(); //Apply a single step of movement void Move(float deltaTime); void ApplyRotation(); //Apply specific movement inline void MoveBy(Vec2 movement) { m_transform.m_position += movement * Vec2(m_constraints.x, m_constraints.y);} //Impulses void ApplyImpulses(Vec2 linearImpulse, float angularImpulse); void ApplyImpulseAt(Vec2 linearImpulse, Vec2 pointOfContact); //Forces and Torques inline void AddForce(Vec2 force) { m_frameForces += force; } inline void AddTorque(float torque) { m_frameTorque += torque; } //Render void DebugRender(RenderContext* renderContext, const Rgba& color) const; //Mutators void SetSimulationMode(eSimulationType simulationType); Collider2D* SetCollider(Collider2D* collider); void SetObject(void* object, Transform2* objectTransform); void SetConstraints(const Vec3& constraints); void SetConstraints(bool x, bool y, bool rotation); void Destroy(); //Accessors Vec2 GetPosition() const; eSimulationType GetSimulationType(); float GetLinearDrag(); float GetAngularDrag(); public: PhysicsSystem* m_system = nullptr; // system this rigidbody belongs to; void* m_object = nullptr; // user (game) pointer for external use Transform2* m_object_transform = nullptr; // what does this rigidbody affect Transform2 m_transform; // rigidbody transform (mimics the object at start of frame, and used to tell the change to object at end of frame) Vec2 m_gravity_scale = Vec2::ONE; // how much are we affected by gravity Vec2 m_velocity = Vec2::ZERO; float m_angularVelocity = 0.f; float m_mass; // how heavy am I Collider2D* m_collider = nullptr; // my shape; (could eventually be made a set) bool m_isTrigger = false; PhysicsMaterialT m_material; float m_momentOfInertia = 0.f; float m_rotation = 0.f; Vec2 m_frameForces = Vec2::ZERO; float m_frameTorque = 0.f; float m_friction = 1.f; // Friction along the surface float m_linearDrag = 0.1f; float m_angularDrag = 0.1f; Vec3 m_constraints = Vec3(0.f, 1.f, 0.f); bool m_isAlive = true; private: eSimulationType m_simulationType = TYPE_UNKOWN; };
struct Collision2D { Collider2D *m_Obj; Collider2D *m_otherObj; Manifold2D m_manifold; // may be referred to as a "contact" void InvertCollision(); }; typedef std::function<bool(Collision2D* out, Collider2D* a, Collider2D* b)> CollisionCheck2DCallback ; // 2D arrays are [Y][X] remember extern CollisionCheck2DCallback COLLISION_LOOKUP_TABLE[][COLLIDER2D_COUNT]; //-------------------------------------------------------------------------------- bool CheckAABB2ByAABB2(Collision2D* out, Collider2D* a, Collider2D* b ); bool CheckAABB2ByDisc(Collision2D* out, Collider2D* a, Collider2D* b ); bool CheckDiscByDisc(Collision2D* out, Collider2D* a, Collider2D* b ); bool CheckDiscByAABB2(Collision2D* out, Collider2D* a, Collider2D* b ); bool GetCollisionInfo( Collision2D *out, Collider2D * a, Collider2D *b ); //-------------------------------------------------------------------------------- //Manifold Generation //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, AABB2Collider const &obj0, AABB2Collider const &obj1 ); bool GetManifold( Manifold2D *out, AABB2Collider const &obj0, Disc2DCollider const &obj1 ); bool GetManifold( Manifold2D *out, Disc2DCollider const &obj0, Disc2DCollider const &obj1 ); bool GetManifold( Manifold2D *out, Disc2DCollider const &disc, AABB2Collider const &box );
With these systems in place, I was able to implement the desired frame logic that would become my physics step. The idea was for each frame in the game to call into the physics system update step where all the objects in the scene could be identified using a transform and be manipulated based on a set of forces. The forces were either the forces that affected all objects in the scene such as gravity and forces that were applied on an object by other objects in the scene or externally.
Below is the high-level program flow I followed to achieve the desired collision detection and resolution in the physics system.
void PhysicsSystem::Update( float deltaTime ) { CopyTransformsFromObjects(); SetAllCollisionsToFalse(); RunStep( deltaTime ); CopyTransformsToObjects(); } void PhysicsSystem::RunStep( float deltaTime ) { m_frameCount++; //First move all rigidbodies based on forces on them MoveAllDynamicObjects(deltaTime); UpdateAllCollisions(); } void PhysicsSystem::MoveAllDynamicObjects(float deltaTime) { int numObjects = static_cast<int>(m_rbBucket->m_RbBucket[DYNAMIC_SIMULATION].size()); for (int objectIndex = 0; objectIndex < numObjects; objectIndex++) { if(m_rbBucket->m_RbBucket[DYNAMIC_SIMULATION][objectIndex] != nullptr) { m_rbBucket->m_RbBucket[DYNAMIC_SIMULATION][objectIndex]->Move(deltaTime); } } } void PhysicsSystem::UpdateAllCollisions() { //Check Static vs Static to mark as collided CheckStaticVsStaticCollisions(); //Dynamic vs Static set ResolveDynamicVsStaticCollisions(true); //Dynamic vs Dynamic set ResolveDynamicVsDynamicCollisions(true); //Dynamic vs static set with no resolution ResolveDynamicVsStaticCollisions(false); }
At the high level, the idea was to copy all the transform information of the objects in the simulation as the first step in the system. Then by moving all the dynamic objects based on the forces acting on them, I was able to accurately measure their overlap after the collision. This allows me to determine how much force needs to be applied as a result of the collision to move the colliding objects apart and how far apart they need to be after the resolution.
Finally, an update collision step allows me to the resolution based on the transform information of all objects in the system that was previously cached off. Below is a GIF of the result achieved by this simple physics system.
Collision Handling
The collision handling was done using separating axis theorem. The elastic collision equations governing the collisions were found here. Using these equations I was able to resolve the collision using the implementation as follows:
bool CheckAABB2ByAABB2(Collision2D* out, Collider2D* a, Collider2D* b) { //Check collision between 2 boxes AABB2Collider* boxA = reinterpret_cast<AABB2Collider*>(a); AABB2Collider* boxB = reinterpret_cast<AABB2Collider*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *boxA, *boxB); if(result) { //Manifold is valid out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; } else { //Invalid manifold as no collision out->m_Obj = nullptr; out->m_otherObj = nullptr; } return result; } //-------------------------------------------------------------------------------- bool CheckAABB2ByDisc(Collision2D* out, Collider2D* a, Collider2D* b) { //box vs disc AABB2Collider* boxA = reinterpret_cast<AABB2Collider*>(a); Disc2DCollider* discB = reinterpret_cast<Disc2DCollider*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *boxA, *discB); if(result) { out->m_manifold = manifold; out->m_Obj = a; out->m_otherObj = b; } else { out->m_Obj = nullptr; out->m_otherObj = nullptr; } return result; } //-------------------------------------------------------------------------------- bool CheckDiscByDisc(Collision2D* out, Collider2D* a, Collider2D* b) { //disc vs disc Disc2DCollider* discA = reinterpret_cast<Disc2DCollider*>(a); Disc2DCollider* discB = reinterpret_cast<Disc2DCollider*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *discA, *discB); if(result) { // out here out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; return true; } else { out->m_otherObj = nullptr; out->m_Obj = nullptr; return false; } }
bool GetCollisionInfo( Collision2D* out, Collider2D* a, Collider2D* b ) { uint aType = a->GetType(); uint bType = b->GetType(); if(aType >= COLLIDER2D_COUNT && bType >= COLLIDER2D_COUNT) { ERROR_AND_DIE("The Collider type was not part of the COLLISION_LOOKUP_TABLE"); } CollisionCheck2DCallback callBack = COLLISION_LOOKUP_TABLE[aType][bType]; if (callBack == nullptr) { return false; // no known collision; } else { return callBack( out, a, b ); } } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, AABB2Collider const &boxA, AABB2Collider const &boxB ) { //Get the intersecting box Vec2 min = boxA.GetWorldShape().m_maxBounds.Min(boxB.GetWorldShape().m_maxBounds); Vec2 max = boxA.GetWorldShape().m_minBounds.Max(boxB.GetWorldShape().m_minBounds); //AABB2 collisionBox = AABB2(max, min); if(max < min) { GenerateManifoldBoxToBox(out, min, max); AABB2 boxAShape = boxA.GetWorldShape(); AABB2 boxBShape = boxB.GetWorldShape(); if(out->m_normal.y == 0.f) { if(((boxAShape.m_maxBounds + boxAShape.m_minBounds)/2).x < ((boxBShape.m_maxBounds + boxBShape.m_minBounds)/2).x) { //pushing out on x out->m_normal *= -1; } } else if(out->m_normal.x == 0.f) { if(((boxAShape.m_maxBounds + boxAShape.m_minBounds)/2).y < ((boxBShape.m_maxBounds + boxBShape.m_minBounds)/2).y) { //pushing out on y out->m_normal *= -1; } } return true; } else { return false; } } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, AABB2Collider const &box, Disc2DCollider const &disc ) { Vec2 discCentre = disc.GetWorldShape().GetCentre(); AABB2 boxShape = box.GetWorldShape(); Vec2 closestPoint = GetClosestPointOnAABB2( discCentre, boxShape ); Vec2 boxCenter = boxShape.GetBoxCenter() + boxShape.m_minBounds; float distanceSquared = GetDistanceSquared2D(discCentre, closestPoint); float radius = disc.GetWorldShape().GetRadius(); //float distanceBwCenters = GetDistanceSquared2D(discCentre, boxCenter); float distance = 0; //Check is box inside disc if(closestPoint == discCentre) { //box is inside disc distance = GetDistance2D(discCentre, boxCenter); Vec2 normal = boxCenter - discCentre; normal.Normalize(); out->m_normal = normal; out->m_penetration = distance; return true; } if(distanceSquared < radius * radius) { //out here distance = GetDistance2D(discCentre, closestPoint); Vec2 normal = closestPoint - discCentre; normal.Normalize(); out->m_normal = normal; out->m_penetration = radius - distance; return true; } else { return false; } } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, Disc2DCollider const &disc, AABB2Collider const &box) { Vec2 discCentre = disc.GetWorldShape().GetCentre(); AABB2 boxShape = box.GetWorldShape(); Vec2 closestPoint = GetClosestPointOnAABB2( discCentre, boxShape ); float distanceSquared = GetDistanceSquared2D(discCentre, closestPoint); float radius = disc.GetWorldShape().GetRadius(); if(closestPoint == discCentre) { return IsDiscInBox(out, discCentre, boxShape, radius); } if(distanceSquared < radius * radius) { //out here float distance = GetDistance2D(discCentre, closestPoint); Vec2 normal = discCentre - closestPoint; normal.Normalize(); out->m_normal = normal; out->m_penetration = radius - distance; return true; } else { return false; } }
Adding all the new features
Now that I had a basic setup in place, I was able to test collisions in a zoo and make use of the existing object primitives I had support for. To add more features, I could now architect an interface and encapsulate data based on my usage. Below are the various features that were incorporated:
The Oriented Bounding Box (OBB)
Axis aligned bounding boxes allowed me to use them as colliders in a physics system but they have 1 problem. They are axis-aligned. To have box colliders in the scene that could rotate based on impulses acting on the object would require a new type of bounding box. To implement an oriented bounding box(OBB) for this system I created a new class with methods to help access certain properties of the OBB.
The Capsule
With the addition of an oriented bounding box, it became a lot easier to think about the capsule. It had a spine and a radius around this spine which could quite easily be visualized as an OBB of width 0 and some radius ‘r’ and height ‘h’. The resolution of physics would be very similar except that it would now need to account for an OBB thickness. The logic for this calculation was simplified thanks to using Voronoi regions to early out for the simple cases where the contact point was in the disc-shaped regions of the capsule.
Below is my implementation:
class Capsule2D { public: Capsule2D(); Capsule2D( Vec2 pos ); // Equivalent to a point; Capsule2D( Vec2 center, float radius ); // Equivalent to a disc; Capsule2D( Vec2 p0, Vec2 p1, float radius ); ~Capsule2D(); // Modification Utility void SetPosition( Vec2 pos ); // special care here; Use the center of the line as position, but maintain shape void SetPositions( Vec2 p0, Vec2 p1 ); void Translate( Vec2 offset ); void RotateBy( float degrees ); // Helpers for describing it; inline const Vec2& GetStart() const { return m_start; }; inline const Vec2& GetEnd() const {return m_end; }; // Collision Utility bool Contains( Vec2 worldPoint ); Vec2 GetClosestPoint( Vec2 worldPoint ); Vec2 GetCenter() const; // Useful if you want to use AABB2 as a "broad phase" & "mid phase" check // like checking if something fell outside the world AABB2 GetBoundingAABB() const; public: Vec2 m_start = Vec2::ZERO; Vec2 m_end = Vec2::ZERO; float m_radius = 0.0f; // Note: defaults basically make a "point" at 0; float m_rotationDegrees = 0.0f; };
class OBB2 { public: OBB2(); ~OBB2(); explicit OBB2( Vec2 center, Vec2 size = Vec2::ZERO, float rotationDegrees = 0.0f ); explicit OBB2( AABB2 const &aabb ); //Modification Utilities void Translate( Vec2 offset ); void MoveTo( Vec2 position ); void RotateBy( float degrees ); void SetRotation( float degrees ); void SetSize( Vec2 newSize ); //Accessors for OBB Properties inline const Vec2& GetRight() const { return m_right; } inline const Vec2& GetUp() const { return m_up; } inline const Vec2& GetCenter() const { return m_center; } inline const Vec2& GetHalfExtents() const { return m_halfExtents; } //Accessors for the corners themselves inline Vec2 GetBottomLeft() const { return m_center - m_halfExtents.x * GetRight() - m_halfExtents.y * GetUp(); } inline Vec2 GetBottomRight() const { return m_center + m_halfExtents.x * GetRight() - m_halfExtents.y * GetUp(); } inline Vec2 GetTopLeft() const { return m_center - m_halfExtents.x * GetRight() + m_halfExtents.y * GetUp(); } inline Vec2 GetTopRight() const { return m_center + m_halfExtents.x * GetRight() + m_halfExtents.y * GetUp(); } // Collision Utility Vec2 ToLocalPoint( Vec2 worldPoint ) const; Vec2 ToWorldPoint( Vec2 localPoint ) const; void GetPlanes(Plane2D* out) const; void GetCorners(Vec2* out) const; void GetSides(Segment2D* out) const; bool Contains( Vec2 worldPoint ) const; Vec2 GetClosestPoint( Vec2 worldPoint ) const; bool Intersects( OBB2 const &other ) const; public: Vec2 m_right = Vec2( 1.0f, 0.0f ); Vec2 m_up = Vec2( 0.0f, 1.0f ); Vec2 m_center = Vec2( 0.0f, 0.0f ); Vec2 m_halfExtents = Vec2( 0.0f, 0.0f ); };
bool CheckOBB2ByOBB2( Collision2D* out, Collider2D* a, Collider2D* b ) { //OBB vs OBB BoxCollider2D* boxA = reinterpret_cast<BoxCollider2D*>(a); BoxCollider2D* boxB = reinterpret_cast<BoxCollider2D*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *boxA, *boxB); if(result) { //out here out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; return true; } else { out->m_Obj = nullptr; out->m_otherObj = nullptr; return false; } } //-------------------------------------------------------------------------------- bool CheckCapsuleByCapsule( Collision2D * out, Collider2D * a, Collider2D * b ) { //Capsule vs Capsule CapsuleCollider2D* capA = reinterpret_cast<CapsuleCollider2D*>(a); CapsuleCollider2D* capB = reinterpret_cast<CapsuleCollider2D*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *capA, *capB); if(result) { //out here out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; return true; } else { out->m_Obj = nullptr; out->m_otherObj = nullptr; return false; } } //-------------------------------------------------------------------------------- bool CheckCapsuleByOBB2( Collision2D* out, Collider2D* a, Collider2D* b ) { //Capsule vs OBB2 CapsuleCollider2D* capA = reinterpret_cast<CapsuleCollider2D*>(a); BoxCollider2D* boxB = reinterpret_cast<BoxCollider2D*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *capA, *boxB); if(result) { //out here out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; return true; } else { out->m_Obj = nullptr; out->m_otherObj = nullptr; return false; } } //-------------------------------------------------------------------------------- bool CheckOBB2ByCapsule( Collision2D* out, Collider2D* a, Collider2D* b ) { //OBB2 vs Capsule BoxCollider2D* boxA = reinterpret_cast<BoxCollider2D*>(a); CapsuleCollider2D* capB = reinterpret_cast<CapsuleCollider2D*>(b); Manifold2D manifold; bool result = GetManifold(&manifold, *boxA, *capB); if(result) { //out here out->m_Obj = a; out->m_otherObj = b; out->m_manifold = manifold; return true; } else { out->m_Obj = nullptr; out->m_otherObj = nullptr; return false; } } //-------------------------------------------------------------------------------- void Collision2D::InvertCollision() { Collider2D* col = m_Obj; m_Obj = m_otherObj; m_otherObj = col; m_manifold.m_normal *= -1.f; }
bool GetManifold( Manifold2D *out, BoxCollider2D const &a, BoxCollider2D const &b ) { OBB2 boxA = a.GetWorldShape(); OBB2 boxB = b.GetWorldShape(); Plane2D planesOfThis[4]; // p0 Plane2D planesOfOther[4]; // p1 Segment2D segmentsOfThis[4]; Segment2D segmentsOfOther[4]; Vec2 cornersOfThis[4]; // c0 Vec2 cornersOfOther[4]; // c1 boxA.GetPlanes( planesOfThis ); boxA.GetCorners( cornersOfThis ); boxA.GetSides (segmentsOfThis ); boxB.GetPlanes( planesOfOther ); boxB.GetCorners( cornersOfOther ); boxB.GetSides( segmentsOfOther ); int inFrontOfThis = 0; int inFrontOfOther = 0; //Data for the manifold generation float bestDistToA = 100000; Vec2 bestPointToA; float bestDistToB = 100000; Vec2 bestPointToB; Plane2D bestThisPlane; Plane2D bestOtherPlane; Segment2D bestSegmentThis; Segment2D bestSegmentOther; Vec2 worstCornerToPlane[4]; float worstDistancesToThis[4]; Vec2 worstCornerToOtherPlane[4]; float worstDistancesToOther[4]; for(int planeIndex = 0; planeIndex < 4;planeIndex++) { Plane2D const &thisPlane = planesOfThis[planeIndex]; Plane2D const &otherPlane = planesOfOther[planeIndex]; inFrontOfThis = 0; inFrontOfOther = 0; for(int cornerIndex = 0; cornerIndex < 4; cornerIndex++) { Vec2 const &cornerOfThis = cornersOfThis[cornerIndex]; Vec2 const &cornerOfOther = cornersOfOther[cornerIndex]; float otherFromThis = thisPlane.GetDistance( cornerOfOther ); float thisFromOther = otherPlane.GetDistance( cornerOfThis ); if(cornerIndex == 0) { bestDistToA = otherFromThis; bestPointToA = cornerOfOther; bestThisPlane = thisPlane; bestSegmentThis = segmentsOfThis[planeIndex]; bestSegmentOther = segmentsOfOther[planeIndex]; worstCornerToPlane[planeIndex] = cornerOfOther; worstDistancesToThis[planeIndex] = otherFromThis; bestDistToB = thisFromOther; bestPointToB = cornerOfThis; bestOtherPlane = otherPlane; worstCornerToOtherPlane[planeIndex] = cornerOfThis; worstDistancesToOther[planeIndex] = thisFromOther; } //Update the feature point if(otherFromThis < bestDistToA) { bestDistToA = otherFromThis; bestPointToA = cornerOfOther; bestThisPlane = thisPlane; bestSegmentThis = segmentsOfThis[planeIndex]; worstCornerToPlane[planeIndex] = cornerOfOther; worstDistancesToThis[planeIndex] = otherFromThis; } //Update the feature point for other plane if(thisFromOther < bestDistToB) { bestDistToB = thisFromOther; bestPointToB = cornerOfThis; bestOtherPlane = otherPlane; bestSegmentOther = segmentsOfOther[planeIndex]; worstCornerToOtherPlane[planeIndex] = cornerOfThis; worstDistancesToOther[planeIndex] = thisFromOther; } inFrontOfThis += (otherFromThis >= 0.0f) ? 1 : 0; inFrontOfOther += (thisFromOther >= 0.0f) ? 1 : 0; } //We are not intersecting if there are exactly 4 in front of either plane. Early out bro if ((inFrontOfThis == 4) || (inFrontOfOther == 4)) { return false; } } float bestCaseThis = 0.f; float bestCaseOther = 0.f; int bestCaseIndexThis = 0; int bestCaseIndexOther = 0; Vec2 bestContactThis; Vec2 bestContactOther; for(int worstCaseIndex = 0; worstCaseIndex < 4; worstCaseIndex++) { if(worstCaseIndex == 0) { bestCaseThis = worstDistancesToThis[worstCaseIndex]; bestCaseOther = worstDistancesToOther[worstCaseIndex]; bestContactThis = worstCornerToPlane[worstCaseIndex]; bestContactOther = worstCornerToOtherPlane[worstCaseIndex]; } if(worstDistancesToThis[worstCaseIndex] > bestCaseThis) { bestCaseThis = worstDistancesToThis[worstCaseIndex]; bestContactThis = worstCornerToPlane[worstCaseIndex]; bestCaseIndexThis = worstCaseIndex; } if(worstDistancesToOther[worstCaseIndex] > bestCaseOther) { bestCaseOther = worstDistancesToOther[worstCaseIndex]; bestContactOther = worstCornerToOtherPlane[worstCaseIndex]; bestCaseIndexOther = worstCaseIndex; } } //Check which of the 2 are larger (smaller -ve number) if(bestCaseOther > bestCaseThis) { out->m_penetration = bestCaseOther * -1.f; out->m_normal = planesOfOther[bestCaseIndexOther].m_normal; out->m_contact = bestContactOther; //DEBUG // DebugRenderOptionsT options; // options.relativeCoordinates = true; // options.space = DEBUG_RENDER_SCREEN; // options.beginColor = Rgba::GREEN; // g_debugRenderer->DebugRenderPoint2D(options, bestContactOther, 1.f); } else { out->m_penetration = bestCaseThis * -1.f; out->m_normal = planesOfThis[bestCaseIndexThis].m_normal * -1.f; out->m_contact = bestContactThis; //DEBUG // DebugRenderOptionsT options; // options.relativeCoordinates = true; // options.space = DEBUG_RENDER_SCREEN; // options.beginColor = Rgba::BLUE; // g_debugRenderer->DebugRenderPoint2D(options, bestContactThis, 1.f); } return true; } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, BoxCollider2D const &a, float aRadius, BoxCollider2D const &b, float bRadius ) { OBB2 boxA = a.GetWorldShape(); OBB2 boxB = b.GetWorldShape(); if (GetManifold( out, a, b )) { out->m_penetration += (aRadius + bRadius); return true; } Segment2D sidesA[4]; boxA.GetSides(sidesA); Segment2D sidesB[4]; boxB.GetSides(sidesB); Vec2 cornersA[4]; boxA.GetCorners(cornersA); Vec2 cornersB[4]; boxB.GetCorners(cornersB); float bestMatchToA = 10000000; float bestMatchToB = 10000000; Vec2 bestA; Vec2 bestB; for(int sideIndex = 0; sideIndex < 4; sideIndex++) { Segment2D const &sideA = sidesA[sideIndex]; Segment2D const &sideB = sidesB[sideIndex]; for(int cornerIndex = 0; cornerIndex < 4; cornerIndex++) { Vec2 const &cornerA = cornersA[cornerIndex]; Vec2 const &cornerB = cornersB[cornerIndex]; Vec2 closestA = sideA.GetClosestPoint(cornerB); Vec2 closestB = sideB.GetClosestPoint(cornerA); // distances are... // closestA to cornerB // closestB to cornerA float lengthSquared = (closestA - cornerB).GetLengthSquared() ; if (lengthSquared < bestMatchToA) { bestA = closestA; bestB = cornerB; bestMatchToA = lengthSquared; } lengthSquared = (closestB - cornerA).GetLengthSquared() ; if (lengthSquared < bestMatchToB) { bestA = cornerA; bestB = closestB; bestMatchToB = lengthSquared; } } } // two closets points are bestA, bestB; // normal is directoin between them; // penetration is sum of radius - distance; float distance = GetDistance2D(bestA, bestB); if(distance > (aRadius + bRadius)) { return false; } else { out->m_normal = bestB - bestA; out->m_penetration = aRadius + bRadius - distance; return true; } } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, CapsuleCollider2D const &a, CapsuleCollider2D const &b ) { //Call the GetManifold with 2 BoxColliders and radius return GetManifold(out, a.GetWorldShape(), a.GetCapsuleRadius(), b.GetWorldShape(), b.GetCapsuleRadius()); } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, OBB2 const &a, float aRadius, OBB2 const &b, float bRadius ) { if (GetManifold( out, a, b )) { out->m_penetration += (aRadius + bRadius); return true; } Segment2D sidesA[4]; a.GetSides(sidesA); Segment2D sidesB[4]; b.GetSides(sidesB); Vec2 cornersA[4]; a.GetCorners(cornersA); Vec2 cornersB[4]; b.GetCorners(cornersB); float bestMatch = 10000000; // float bestMatchToB = 10000000; Vec2 bestA; Vec2 bestB; for(int sideIndex = 0; sideIndex < 4; sideIndex++) { Segment2D const &sideA = sidesA[sideIndex]; Segment2D const &sideB = sidesB[sideIndex]; for(int cornerIndex = 0; cornerIndex < 4; cornerIndex++) { Vec2 const &cornerA = cornersA[cornerIndex]; Vec2 const &cornerB = cornersB[cornerIndex]; Vec2 closestA = sideA.GetClosestPoint(cornerB); Vec2 closestB = sideB.GetClosestPoint(cornerA); // distances are... // closestA to cornerB // closestB to cornerA float lengthSquared = (closestA - cornerB).GetLengthSquared() ; if (lengthSquared < bestMatch) { bestA = closestA; bestB = cornerB; bestMatch = lengthSquared; } lengthSquared = (closestB - cornerA).GetLengthSquared() ; if (lengthSquared < bestMatch) { bestA = cornerA; bestB = closestB; bestMatch = lengthSquared; } } } // two closets points are bestA, bestB; // normal is directoin between them; // penetration is sum of radius - distance; float distance = GetDistance2D(bestA, bestB); if(distance > (aRadius + bRadius)) { return false; } else { Vec2 disp = bestB - bestA; if(disp.GetLengthSquared() < 0.000001) { out->m_normal = Vec2(0.f, 1.f); } else { out->m_normal = disp.GetNormalized() * -1.f; } out->m_penetration = (aRadius + bRadius) - distance; out->m_contact = bestA + aRadius * -1.f * out->m_normal; // DebugRenderOptionsT options; // options.relativeCoordinates = true; // options.space = DEBUG_RENDER_SCREEN; // options.beginColor = Rgba::GREEN; // g_debugRenderer->DebugRenderPoint2D(options, out->m_contact, 1.f); return true; } } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, BoxCollider2D const &a, CapsuleCollider2D const &b ) { return GetManifold(out, a.GetWorldShape(), 0.f, b.GetWorldShape(), b.GetCapsuleRadius()); } //-------------------------------------------------------------------------------- bool GetManifold( Manifold2D *out, CapsuleCollider2D const &a, BoxCollider2D const &b ) { return GetManifold(out, a.GetWorldShape(), a.GetCapsuleRadius(), b.GetWorldShape(), 0.f); }
//------------------------------------------------------------------------------------------------------------------------------ class BoxCollider2D: public Collider2D { public: explicit BoxCollider2D( Vec2 center, Vec2 size = Vec2::ZERO, float rotationDegrees = 0.0f ); ~BoxCollider2D(); virtual void SetMomentForObject(); virtual bool Contains(Vec2 worldPoint); OBB2 GetLocalShape() const; OBB2 GetWorldShape() const; public: OBB2 m_localShape; }; //------------------------------------------------------------------------------------------------------------------------------ class CapsuleCollider2D: public Collider2D { public: explicit CapsuleCollider2D ( Vec2 start, Vec2 end, float radius ); ~CapsuleCollider2D(); virtual void SetMomentForObject(); virtual bool Contains(Vec2 worldPoint); OBB2 GetLocalShape() const; OBB2 GetWorldShape() const; const Capsule2D& GetReferenceShape() const; float GetCapsuleRadius() const; public: Capsule2D m_referenceCapsule; OBB2 m_localShape; float m_radius; };
Mouse Controls and Debug
Adding mouse control would make the application easier to use by allowing the user to drag the mouse to draw objects of the desired size. It would also allow the user to perform actions such as clicking to hold an object and hovering to view information about the object. Adding these features made debugging easier and allowed for better user experience.
To make life a little easier when verifying contact positions being generated for the OBB and Capsules, I needed a simple debug interface that can allow me to identify the points. I added a draw call to show the user the contact points on the screen and change color based on time passed since the collision. While debugging, things ended up looking like this:
Below is the implementation I followed to use the mouse cursor:
class GameCursor { public: GameCursor(); ~GameCursor(); void StartUp(); void Update(float deltaTime); void Render() const; void HandleKeyPressed( unsigned char keyCode ); void HandleKeyReleased( unsigned char keyCode ); void SetCursorPosition(const Vec2& position); const Vec2& GetCursorPositon() const; private: Vec2 m_cursorPosition = Vec2::ZERO; Rgba m_cursorColor = Rgba::WHITE; float m_cursorThickness = 0.25f; float m_cursorRingRadius = 2.0f; //Movement Data Vec2 m_movementVector = Vec2::ZERO; float m_cursorSpeed = 0.25f; };
GameCursor::GameCursor() { StartUp(); } GameCursor::~GameCursor() { } void GameCursor::StartUp() { m_cursorPosition = Vec2(WORLD_CENTER_X, WORLD_CENTER_Y); } void GameCursor::Update( float deltaTime ) { UNUSED(deltaTime); //New code to implement cursor at mouse position; IntVec2 intVecPos = g_windowContext->GetClientMousePosition(); IntVec2 clientBounds = g_windowContext->GetClientBounds(); Vec2 worldPos = Game::GetClientToWorldPosition2D(intVecPos, clientBounds); m_cursorPosition = worldPos; } void GameCursor::Render() const { std::vector<Vertex_PCU> ringVerts; AddVertsForRing2D(ringVerts, m_cursorPosition, m_cursorRingRadius, m_cursorThickness, m_cursorColor); std::vector<Vertex_PCU> lineVerts; Vec2 vertLineOffset = Vec2(0.f, m_cursorRingRadius); Vec2 horLineOffset = Vec2(m_cursorRingRadius, 0.f); AddVertsForLine2D(lineVerts, m_cursorPosition - vertLineOffset - Vec2(0.f, 0.5f), m_cursorPosition + vertLineOffset + Vec2(0.f, 0.5f), m_cursorThickness, m_cursorColor); AddVertsForLine2D(lineVerts, m_cursorPosition - horLineOffset - Vec2(0.5f, 0.f), m_cursorPosition + horLineOffset + Vec2(0.5f, 0.f), m_cursorThickness, m_cursorColor); //g_renderContext->BindTexture(nullptr); g_renderContext->BindTextureViewWithSampler(0U, nullptr); g_renderContext->DrawVertexArray(lineVerts); g_renderContext->DrawVertexArray(ringVerts); } void GameCursor::HandleKeyPressed( unsigned char keyCode ) { switch( keyCode ) { case UP_ARROW: m_movementVector.y += 1.f; break; case DOWN_ARROW: m_movementVector.y -= 1.f; break; case RIGHT_ARROW: m_movementVector.x += 1.f; break; case LEFT_ARROW: m_movementVector.x -= 1.f; break; } } void GameCursor::HandleKeyReleased( unsigned char keyCode ) { switch( keyCode ) { case UP_ARROW: m_movementVector.y = 0.f; break; case DOWN_ARROW: m_movementVector.y = 0.f; break; case RIGHT_ARROW: m_movementVector.x = 0.f; break; case LEFT_ARROW: m_movementVector.x = 0.f; break; } } void GameCursor::SetCursorPosition( const Vec2& position ) { m_cursorPosition = position; } const Vec2& GameCursor::GetCursorPositon() const { return m_cursorPosition; }
Saving and loading scene information
The first game feature I added to the application was being able to create a scene with a bunch of physics objects and be able to save and load these scenes. This would allow me to make scenes as desired and save them using the console in the game engine. Below is what the process looked like:
void Game::SaveToFile(const std::string& filePath) { //Save all scene objects to the file tinyxml2::XMLDocument saveDoc; tinyxml2::XMLNode* rootNode = saveDoc.NewElement("SavedGeometry"); saveDoc.InsertFirstChild(rootNode); int numObjects = (int)m_allGeometry.size(); for (int index = 0; index < numObjects; index++) { //Save all the object properties using XML XMLElement* geometry = saveDoc.NewElement("GeometryData"); XMLElement* rbElem = saveDoc.NewElement("RigidBody"); geometry->InsertEndChild(rbElem); //Rigidbody data rbElem->SetAttribute("SimType", m_allGeometry[index]->m_rigidbody->GetSimulationType()); rbElem->SetAttribute("Shape", m_allGeometry[index]->m_collider->m_colliderType); rbElem->SetAttribute("Mass", m_allGeometry[index]->m_rigidbody->m_mass); rbElem->SetAttribute("Friction", m_allGeometry[index]->m_rigidbody->m_friction); rbElem->SetAttribute("AngularDrag", m_allGeometry[index]->m_rigidbody->m_angularDrag); rbElem->SetAttribute("LinearDrag", m_allGeometry[index]->m_rigidbody->m_linearDrag); rbElem->SetAttribute("Freedom", m_allGeometry[index]->m_rigidbody->m_constraints.GetAsString().c_str()); rbElem->SetAttribute("Moment", m_allGeometry[index]->m_rigidbody->m_momentOfInertia); rbElem->SetAttribute("Restitution", m_allGeometry[index]->m_rigidbody->m_material.restitution); XMLElement* colElem = saveDoc.NewElement("Collider"); geometry->InsertEndChild(colElem); //Collider data eColliderType2D type = m_allGeometry[index]->m_collider->GetType(); Collider2D* collider = m_allGeometry[index]->m_collider; switch (type) { case COLLIDER_BOX: { BoxCollider2D* boxCollider = reinterpret_cast<BoxCollider2D*>(collider); colElem->SetAttribute("Center", boxCollider->GetWorldShape().GetCenter().GetAsString().c_str()); colElem->SetAttribute("Size", (Vec2(boxCollider->GetWorldShape().GetHalfExtents()) * 2.f).GetAsString().c_str()); colElem->SetAttribute("Rotation", boxCollider->m_rigidbody->m_rotation); } break; case COLLIDER_CAPSULE: { CapsuleCollider2D* col = reinterpret_cast<CapsuleCollider2D*>(collider); colElem->SetAttribute("Start", col->GetReferenceShape().m_start.GetAsString().c_str()); colElem->SetAttribute("End", col->GetReferenceShape().m_end.GetAsString().c_str()); colElem->SetAttribute("Radius", col->GetCapsuleRadius()); } break; } //Transform stuff XMLElement* tranformElem = saveDoc.NewElement("Transform"); geometry->InsertEndChild(tranformElem); tranformElem->SetAttribute("Position", m_allGeometry[index]->m_transform.m_position.GetAsString().c_str()); tranformElem->SetAttribute("Rotation", m_allGeometry[index]->m_transform.m_rotation); tranformElem->SetAttribute("Scale", m_allGeometry[index]->m_transform.m_scale.GetAsString().c_str()); rootNode->InsertEndChild(geometry); } //Save to the file tinyxml2::XMLError eResult = saveDoc.SaveFile(filePath.c_str()); if (eResult != tinyxml2::XML_SUCCESS) { printf("Error: %i\n", eResult); ASSERT_RECOVERABLE(true, Stringf("Error: %i\n", eResult)); } }
void Game::LoadFromFile(const std::string& filePath) { //Delete all existing objects for (int index = 0; index < (int)m_allGeometry.size(); index++) { delete m_allGeometry[index]; m_allGeometry[index] = nullptr; } m_allGeometry.erase(m_allGeometry.begin(), m_allGeometry.end()); //Open the xml file and parse it tinyxml2::XMLDocument saveDoc; saveDoc.LoadFile(filePath.c_str()); if (saveDoc.ErrorID() != tinyxml2::XML_SUCCESS) { printf("\n >> Error loading XML file from %s ", filePath); printf("\n >> Error ID : %i ", saveDoc.ErrorID()); printf("\n >> Error line number is : %i", saveDoc.ErrorLineNum()); printf("\n >> Error name : %s", saveDoc.ErrorName()); ERROR_AND_DIE(">> Error loading Save Game XML file "); return; } else { //Load from the file and spawn required objects XMLElement* rootElement = saveDoc.RootElement(); XMLElement* geometry = rootElement->FirstChildElement(); while(geometry != nullptr) { //Read RB data first XMLElement* elem = geometry->FirstChildElement("RigidBody"); int type = ParseXmlAttribute(*elem, "SimType", 0); int shape = ParseXmlAttribute(*elem, "Shape", 0); float mass = ParseXmlAttribute(*elem, "Mass", 0.1f); float friction = ParseXmlAttribute(*elem, "Friction", 0.f); float angularDrag = ParseXmlAttribute(*elem, "AngularDrag", 0.f); float linearDrag = ParseXmlAttribute(*elem, "LinearDrag", 0.f); Vec3 freedom = ParseXmlAttribute(*elem, "Freedom", Vec3::ONE); float moment = ParseXmlAttribute(*elem, "Moment", INFINITY); float restitution = ParseXmlAttribute(*elem, "Restitution", 1.f); //Read Collider data elem = elem->NextSiblingElement("Collider"); Geometry* entity = nullptr; switch (shape) { case COLLIDER_BOX: { Vec2 center = ParseXmlAttribute(*elem, "Center", Vec2::ZERO); Vec2 size = ParseXmlAttribute(*elem, "Size", Vec2::ZERO); float rotation = ParseXmlAttribute(*elem, "Rotation", 0.f); if (type == STATIC_SIMULATION) { if (size.x == 80.f) { entity = new Geometry(*g_physicsSystem, STATIC_SIMULATION, BOX_GEOMETRY, center, rotation, size.y, Vec2::ZERO, true); } else { entity = new Geometry(*g_physicsSystem, STATIC_SIMULATION, BOX_GEOMETRY, center, rotation, size.y); } entity->m_rigidbody->m_mass = mass; entity->m_rigidbody->SetConstraints(false, false, false); } else { entity = new Geometry(*g_physicsSystem, DYNAMIC_SIMULATION, BOX_GEOMETRY, center, rotation, size.y); entity->m_rigidbody->m_mass = mass; entity->m_rigidbody->SetConstraints(freedom); } entity->m_rigidbody->m_material.restitution = restitution; entity->m_rigidbody->m_friction = friction; entity->m_rigidbody->m_angularDrag = angularDrag; entity->m_rigidbody->m_linearDrag = linearDrag; entity->m_rigidbody->m_momentOfInertia = moment; } break; case COLLIDER_CAPSULE: { Vec2 start = ParseXmlAttribute(*elem, "Start", Vec2::ZERO); Vec2 end = ParseXmlAttribute(*elem, "End", Vec2::ZERO); float radius = ParseXmlAttribute(*elem, "Radius", 0.f); UNUSED(radius); Vec2 disp = start - end; Vec2 norm = disp.GetNormalized(); float length = disp.GetLength(); Vec2 center = end + length * norm * 0.5f; float rotationDegrees = disp.GetAngleDegrees() + 90.f; if (type == STATIC_SIMULATION) { entity = new Geometry(*g_physicsSystem, STATIC_SIMULATION, CAPSULE_GEOMETRY, start, rotationDegrees, 0.f, end); entity->m_rigidbody->m_mass = INFINITY; entity->m_rigidbody->SetConstraints(false, false, false); } else { entity = new Geometry(*g_physicsSystem, DYNAMIC_SIMULATION, CAPSULE_GEOMETRY, start, rotationDegrees, 0.f, end); entity->m_rigidbody->m_mass = mass; entity->m_rigidbody->SetConstraints(freedom); } entity->m_rigidbody->m_friction = friction; entity->m_rigidbody->m_angularDrag = angularDrag; entity->m_rigidbody->m_linearDrag = linearDrag; entity->m_rigidbody->m_momentOfInertia = moment; entity->m_rigidbody->m_material.restitution = restitution; } break; default: { ERROR_AND_DIE("The rigidbody shape in XML file is unknown"); } break; } //Read Collider data elem = elem->NextSiblingElement("Transform"); Vec2 position = ParseXmlAttribute(*elem, "Position", Vec2::ZERO); float rotation = ParseXmlAttribute(*elem, "Rotation", 0.f); Vec2 scale = ParseXmlAttribute(*elem, "Scale", Vec2::ZERO); entity->m_transform.m_position = position; entity->m_transform.m_rotation = rotation; entity->m_transform.m_scale = scale; m_allGeometry.push_back(entity); //Proceed to next sibling geometry = geometry->NextSiblingElement(); } } }
Implementing triggers
Adding triggers to the game was as simple as using the existing rigid bodies but simplifying them to only detect collision but provide no resolution steps. This was a very simple feature to add but provided a lot of added usage from the system. By creating event callbacks that can be bound to the collision events, the triggers could be used in the system for a variety of gameplay behaviors.
Below is the implementation I followed to use triggers:
class Trigger2D { public: Trigger2D(PhysicsSystem* physicsSystem, eSimulationType simType); ~Trigger2D(); //Update void Update(uint frameNumber); void UpdateTouchesArray(Rigidbody2D* rb, uint frameNumber); //Render void DebugRender(RenderContext* renderContext, const Rgba& color) const; //Mutators void SetSimulationMode(eSimulationType simulationType); void SetCollider(Collider2D* collider); void SetTransform(const Transform2& transform); void SetOnEnterEvent(const std::string& enterEventString); void SetOnExitEvent(const std::string& exitEventString); //Accessors Vec2 GetPosition() const; eSimulationType GetSimulationType(); public: PhysicsSystem* m_system = nullptr; // system this trigger belongs to; Transform2 m_transform; // trigger transform Collider2D* m_collider = nullptr; // my shape; (could eventually be made a set) std::string m_onEnterEvent = ""; std::string m_onExitEvent = ""; private: eSimulationType m_simulationType = TYPE_UNKOWN; std::vector<TriggerTouch2D*> m_touches; };
class TriggerTouch2D { public: explicit TriggerTouch2D(Collider2D* collider, uint entryFrame); ~TriggerTouch2D(); inline void SetCurrentFrame(uint frameNumber) { m_currentFrame = frameNumber; } inline Collider2D* GetCollider() { return m_collider; } inline uint GetCurrentFrame() { return m_currentFrame; } inline uint GetEntryFrame() { return m_entryFrame; } void Destroy(); private: Collider2D* m_collider; uint m_entryFrame; uint m_currentFrame; };
The results
Finally using all these features, we are able to create a complete 2D physics system that utilizes colliders to simulate rigid bodies and triggers.
It ends up looking something like this:
Retrospectives
Things that went well 🙂
- Architecture for the project was laid out in a way adding features would be very easy.
- Setting up mouse controls improved the UX greatly
- The design choice to separate rigid body and trigger as opposed to repurposing a rigid body to add trigger behavior.
- Using pillboxes that can be handled as either an OBB rigid body or capsule-shaped rigid body.
Things that went wrong 🙁
- Lacked the time to create a game out of this.
- Saving and loading objects could have been done easier if binary file storage was used. That way it could have been as simple as loading an array of rigid bodies rather than parsing their information via XML.
Things I would do differently if I had a second attempt
- Definitely would perform scene save and load using binary files to simplify that process.
- I would like to incorporate simple 2D joint systems like support for a spring joint and fixed joint.