Rigidbody #1: Linear Forces
In this first installment of my 2D rigidbody tutorial, we are going to explore linear forces and how we can begin to simulate them in real time on a computer. As you'll come to see, 2D forces are quite easy to understand and implement if you have some basic knowledge of 2D maths. On top of that, they really add a lot of life into what would otherwise be a static 2D scene.
What do we mean by Rigid Body Physics?
When we say that the objects in our scene have "rigidbodies", we are assuming the following things:
- The object can never be disformed by the physics system. This means that, when collisions happen, objects will always bounce off of one another. There will never be an instance where one object squishes or puts a hole into another object. No penetration is allowed.
- Mass is uniformly distributed throughout the object. This assumption allows us to think of the rigidbody as a single point, which represents the center of mass of our object. You will notice later in this tutorial that the rigidbody formulas work regardless of the shape and size of the object as a result of this assumption.
A Tale of Two Sub-Fields
When discussing a rigidy body physics system, we're interested in two sub-fields of physics, namely dynamics and kinematics. Although I'm far as can be from being an expert in either of these fields, I will explain - from a programmer's perspective - what they mean to me:
- Kinematics is the study of how an object's movement changes over time. These are the classic position, velocity, and acceleration equations that you're most likely familiar with from high school or college physics. Kinematics doesn't care about how a system entered into the state that it is in, but rather that the system is in that state.
- Dynamics is the study of whats causes kinematic movement. These are the classic force and momentum equations that you may already be familiar with as well. Whereas kinematics only worries itself with the current state of the system, dynamics wants to know how the system entered the state that it is currently in.
The Data Structure
Now that we have an understanding of these two fundamental fields of physics, we can begin setting up our rigidbody data structure.
struct Rigidbody {
Vector2 force = { 0, 0 };
Vector2 velocity = { 0, 0 };
Vector2 position = { 0, 0 };
float32 mass = 1.f;
};
As you can see, the base data structure exactly mirrors what we already know from 2D newtonian physics. Every frame, we will have some force applied to our rigidbody. We will use that force to get accleration, which, when differentied with respect to time, yields velocity and, ultimately, the new position of our rigidbody. For all of this to work, of course, we need a constant mass for this object.
The Functions
Now, let's put that Rigidbody data structure to work! As I mentioned earlier, you can think of dynamics as the input to the system. What we're going to do now is add a way apply some sort of force to our rigidbody instantaneously.
struct Rigidbody {
Vector2 force = { 0, 0 };
Vector2 velocity = { 0, 0 };
Vector2 position = { 0, 0 };
float32 mass = 1.f;
void update(float32 deltaTimeSeconds) {
applyGravity(deltaTimeSeconds);
Vector2 acceleration = force / mass;
velocity += (acceleration * deltaTimeSeconds);
position += (velocity * deltaTimeSeconds);
force = Vector2 { 0.f, 0.f };
}
void applyGravity(float32 deltaTimeSeconds) {
velocity += (Vector2 { 0.f, -9.8.f } * deltaTimeSeconds);
}
void applyForce(Vector2 f) {
force += f;
}
};
We have three new functions here:
- update: This function will run every single frame during our simulation. In it, we use the total force being applied on the object at this time (found by reordering the classic equation F = ma). From there, we differentiate once with respect to time to get the velocity, and again to get the position. Finally, we reset the total force to zero so that we can start fresh on the next frame.
- applyGravity: We all know that gravity is a constant 9.8 m/s2, so we can simply add that to the velocity every single frame. (Note that the gravity in your game may not 9.8; don't be afraid to mess around with this number).
- applyForce: This function provies a way for external forces to affect our rigidbody directly.
Impulses & Frame-Rate Independence
Although it might be good enough for your use case, allow me to explain why the previous approach is neither realistic nor reliable:
When a force is applied in the real world, it doesn't just get applied for a single moment (i.e. frame) in time: it gets applied over time, or for a given duration of time. At the moment, our current implementation fails to account for this. forces are applied for a given frame, and then forgotten about.
Our current approach has another problem too: the applied force is not frame-rate independent. If you were to apply a force of 50N in the Y direction right now, slower computers would experience larger resultant velocities because their deltaTimeSeconds would be much larger. This is generally something that you'd want to avoid in most applications.
One potential fix for this is to use impulses:
struct Impulse {
Vector2 force = { 0, 0 };
float32 timeOfApplicationSeconds = 0.25f;
float32 timeAppliedSeconds = 0.f;
bool isDead = false;
};
const int32 NUM_IMPULSES = 4;
struct Rigidbody {
int32 numImpulses = 0;
Impulse activeImpulses[NUM_IMPULSES];
Vector2 velocity = { 0, 0 };
Vector2 position = { 0, 0 };
float32 mass = 1.f;
void update(float32 deltaTimeSeconds) {
applyGravity(deltaTimeSeconds);
Vector2 force;
for (int32 idx = 0; idx < numImpulses; idx++) {
Impulse& i = activeImpulses[idx];
float32 nextTimeAppliedSeconds = i.timeAppliedSeconds + deltaTimeSeconds;
if (nextTimeAppliedSeconds >= i.timeOfApplicationSeconds) {
nextTimeAppliedSeconds = i.timeOfApplicationSeconds; // Do the remainder of the time
i.isDead = true;
}
// We apply the force spread out over timeOfApplicationSeconds, so we need
// to calculate the fractional amount of force that was applied in this frame.
float32 impulseDtSeconds = nextTimeAppliedSeconds - i.timeAppliedSeconds;
Vector2 forceToApply = i.force * (impulseDtSeconds / i.timeOfApplicationSeconds);
force += forceToApply * impulseDtSeconds;
i.timeAppliedSeconds = nextTimeAppliedSeconds;
}
Vector2 acceleration = force / mass;
velocity += (acceleration * deltaTimeSeconds);
position += (velocity * deltaTimeSeconds);
// Cleanup any impulses that have expired in the mean time
for (int32 idx = 0; idx < numImpulses; idx++) {
if (activeImpulses[idx].isDead) {
for (int j = idx + 1; j < numImpulses; j++) {
activeImpulses[j - 1] = activeImpulses[j];
}
idx = idx - 1;
numImpulses--;
}
}
}
void applyGravity(float32 deltaTimeSeconds) {
velocity += (Vector2 { 0.f, -9.8f } * deltaTimeSeconds);
}
void applyImpulse(Impulse i) {
if (numImpulses > NUM_IMPULSES) {
printf("Unable to apply impulse. Buffer full.\n");
return;
}
activeImpulses[numImpulses] = i;
numImpulses++;
}
};
While a bit more verbose than our previous example, this approach has more reliable behavior. Forces are no longer treated as single moments in time, but rather "forces applied over time". Because we ensure that the force is applied over time, we guarantee that all users see the same amount of force applied, regardless of frame-rate.For anyone interested, the algorithm for using impulses is as follows:
- Get all impulses acting on the rigidbody at this moment
- For each impulse, find out how much time is remaining. Apply the remaining force, and mark those that have expired as dead
- Calculate the acceleration, velocity, and position as before
- Remove any dead impulses from the list
Live Example
Click 'Play' on the WebAssembly demo below to see a square bouncing around the screen. When you drag the pointer through the square, we will apply an impulse equivalent to how fast you were moving your mouse in the direction that you were moving it.