Introduction
Someone asked me in the #gamedev IRC channel about how to make a 2d vehicle simulator. Instead of spending all day trying to explain the concepts to him, I decided just to write this tutorial. Please bear with me, this is my first tutorial.
So as I mentioned we're going to be learning how to make a basic 2d vehicle simulator. We're going to do it in C# and try do use as few hacks as possible. I've broken the process down into three steps. First, we will learn how to setup a basic game application in C#.NET and how to draw some basic graphics (emphasis on basic.) Next, we will learn how to create a rigid body simulator using a simple Euler integrator with a variable time step. And last but not least, we will calculate vehicle forces simulating the tire patch contacting the road. And that's all there is to it! Let's get started.
Math Requirements
There are two ways to get through this tutorial: you can rush to the end and download the project, or you can read through it and hopefully I'll be able to explain things clearly. If you choose the second route, you're going to need to have a bit of math background. In a 2D simulation this is mostly in the form of a vector object. You'll need to be able to add, subtract, dot, and project 2 vectors. Also you'll need to be able to use a cross product. In 2D this is kind of a fake situation since we know the result will point in the screen's direction, so the result is returned as a scalar. If you're not familiar with any of these terms please look them up now. I tried to write this tutorial without using a matrix object but eventually I cracked and used the Drawing2D.Matrix object to transform and inversely transform a vector between spaces. If you don't know what I'm talking about let me give you an example. Let's say your personal body is your "local space" and the room you're sitting in is the "world space." Let's also say that your monitor is the front of the world, and your eyes look in the forward direction of your local space. If you turn sideways, and transform the monitor's direction into your space, it is now the side direction. Vise versa, if you transform your facing direction into world space, it is the opposite side direction. This is a critical concept so please, if that didn't make sense, do some searching on Google for transforming between spaces. The reason this is so important is because we will be doing all of our vehicle force calculations in local vehicle space. Yet the vehicle itself, and its integrator, persist in world space.Phase One
Phase one, as I mentioned, is to create the renderer; something graphical so we can actually see what our simulation is doing. This will make it a lot easier to debug. Create a windows form project in C# and place a picturebox control on it (name it "screen"). This control is where we will display our simulation. We could just start drawing to this screen but we're going to be using double buffering as well to avoid flicker, so we need to create the back buffer now. That bit of code looks like this.
Graphics graphics; //gdi+
Bitmap backbuffer;
Size buffersize;
//intialize rendering
private void Init(Size size)
{
//setup rendering device
buffersize = size;
backbuffer = new Bitmap(buffersize.Width, buffersize.Height);
graphics = Graphics.FromImage(backbuffer);
}
The Init
function must be called with the size of the "screen" control that you created on the form. This will create a bitmap "backbuffer" to which we can do our
offscreen rendering. We'll then take this backbuffer and draw it to the screen to illiminate any flickering. This is how you draw a basic shape to the backbuffer, and present it to the screen.
//main rendering function
private void Render(Graphics g)
{
//clear back buffer
graphics.Clear(Color.Black);
//draw to back buffer
graphics.DrawLine(new Pen(Color.Yellow), 1, 0, 1, 5);
//present back buffer
g.DrawImage(backbuffer, new Rectangle(0, 0, buffersize.Width, buffersize.Height), 0, 0, buffersize.Width, buffersize.Height, GraphicsUnit.Pixel);
}
This function is called from the on_paint
method of the "screen" control placed on our form. The on_paint
method has a parameter "e" that contains a graphics
object we can use to draw to the control. We pass this graphics object to the render function and as you can see, we draw the backbuffer to it as the very last step.
Now by default, the graphics of a picturebox control has the origin in the top-left corner, and extends downward for +y and to the right for +x. This is highly unnatural for most cases. In
addition to that, it has extremely large units. Since we will be simulating in the metric system, I recommend introducing a scale factor to scale up the simulation and make it much more visible. The
transformation looks like this and takes place after Graphics.Clear()
is called.
graphics.ResetTransform();
graphics.ScaleTransform(screenScale, -screenScale);
graphics.TranslateTransform(buffersize.Width / 2.0f / screenScale, -buffersize.Height / 2.0f / screenScale);
That transformation flips the Y axis so that +Y points up. It simultaneously scales the space by our "screenScale" factor (something like 3.0f should work fine). Next, we translate the
graphics space into the center of the screen control by half of the screen dimensions divided by our scale (since we are now in the scaled space.)
Now the line should draw starting right at the center of the screen.
Forms Wiring
Up until now, I havn't explained how to connect all the functions. The first thing you'll need to do is call the Render
function from your on_paint
event.
Next, you'll need to create a function that gets called continously to update the simulation. It is preferred to call this function on the Application_Idle
event. So create an
event handler for Application_Idle
and have it call your DoFrame
function. Inside this function you'll need to
- Process input
- Update the simulation
- Invalidate the screen control
On_Paint
gets triggerd and the simulation gets drawn. You'll also want to wire up some "key_down" and "key_up" events to keep
track of key states.
The Timer
Since we don't know how often our DoFrame
function will be getting called, we need to code everything to handle a variable time step. To utilize this we must measure the time
between DoFrame
calls. So I'll introduce the timer which, very simply, queries the number of milliseconds that have passed since the computer was turned on. So we store this number
every frame and on a subsequent frame we compute the difference, which gives us the amount of time that has passed since the last frame. Here is my very simple timer object. Note: you will
need to call GetETime
in your intialize function in order to clear the timer, otherwise the first call to it will return the amount of time that has passed since the computer was turned
on.
class Timer
{
//store last time sample
private int lastTime = Environment.TickCount;
private float etime;
//calculate and return elapsed time since last call
public float GetETime()
{
etime = (Environment.TickCount - lastTime) / 1000.0f;
lastTime = Environment.TickCount;
return etime;
}
}
Conclusion of Phase One
So up until now we've covered: setting up a rendering surface using GDI, wiring a form to process a game loop and draw it to the screen, and computing the time that has passed since the last frame. Our application looks like this:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Text;
using System.Windows.Forms;
namespace racing_simulation_2d
{
//our main application form
public partial class frmMain : Form
{
//graphics
Graphics graphics; //gdi+
Bitmap backbuffer;
Size buffersize;
const float screenScale = 3.0f;
Timer timer = new Timer();
//keyboard controls
bool leftHeld = false, rightHeld = false;
bool upHeld = false, downHeld = false;
//vehicle controls
float steering = 0; //-1 is left, 0 is center, 1 is right
float throttle = 0; //0 is coasting, 1 is full throttle
float brakes = 0; //0 is no brakes, 1 is full brakes
public frmMain()
{
InitializeComponent();
Application.Idle += new EventHandler(ApplicationIdle);
screen.Paint += new PaintEventHandler(screen_Paint);
this.KeyDown += new KeyEventHandler(onKeyDown);
this.KeyUp += new KeyEventHandler(onKeyUp);
Init(screen.Size);
}
//intialize rendering
private void Init(Size size)
{
//setup rendering device
buffersize = size;
backbuffer = new Bitmap(buffersize.Width, buffersize.Height);
graphics = Graphics.FromImage(backbuffer);
timer.GetETime(); //reset timer
}
//main rendering function
private void Render(Graphics g)
{
//clear back buffer
graphics.Clear(Color.Black);
graphics.ResetTransform();
graphics.ScaleTransform(screenScale, -screenScale);
graphics.TranslateTransform(buffersize.Width / 2.0f /
screenScale, -buffersize.Height / 2.0f / screenScale);
//draw to back buffer
DrawScreen();
//present back buffer
g.DrawImage(backbuffer, new Rectangle(0, 0, buffersize.Width,
buffersize.Height), 0, 0, buffersize.Width,
buffersize.Height, GraphicsUnit.Pixel);
}
//draw the screen
private void DrawScreen()
{
//draw our simulation here
}
//process game logic
private void DoFrame()
{
//get elapsed time since last frame
float etime = timer.GetETime();
//process input
ProcessInput();
////////////////////////////////
//integrate our simulation here
////////////////////////////////
//redraw our screen
screen.Invalidate();
}
//process keyboard input
private void ProcessInput()
{
if (leftHeld)
steering = -1;
else if (rightHeld)
steering = 1;
else
steering = 0;
if (upHeld)
throttle = 1;
else
throttle = 0;
if (downHeld)
brakes = 1;
else
brakes = 0;
}
private void onKeyDown(object sender, KeyEventArgs e)
{
switch (e.KeyCode)
{
case Keys.Left:
leftHeld = true;
break;
case Keys.Right:
rightHeld = true;
break;
case Keys.Up:
upHeld = true;
break;
case Keys.Down:
downHeld = true;
break;
default: //no match found
return; //return so handled dosnt get set
}
//match found
e.Handled = true;
}
private void onKeyUp(object sender, KeyEventArgs e)
{
switch (e.KeyCode)
{
case Keys.Left:
leftHeld = false;
break;
case Keys.Right:
rightHeld = false;
break;
case Keys.Up:
upHeld = false;
break;
case Keys.Down:
downHeld = false;
break;
default: //no match found
return; //return so handled dosnt get set
}
//match found
e.Handled = true;
}
//rendering - only when screen is invalidated
private void screen_Paint(object sender, PaintEventArgs e)
{
Render(e.Graphics);
}
//when the os gives us time, run the game
private void ApplicationIdle(object sender, EventArgs e)
{
// While the application is still idle, run frame routine.
DoFrame();
}
private void MenuExit_Click(object sender, EventArgs e)
{
this.Close();
}
}
//keep track of time between frames
class Timer
{
//store last time sample
private int lastTime = Environment.TickCount;
private float etime;
//calculate and return elapsed time since last call
public float GetETime()
{
etime = (Environment.TickCount - lastTime) / 1000.0f;
lastTime = Environment.TickCount;
return etime;
}
}
}
Phase Two - Rigid Body Simulation
Ok, now we're getting into some good stuff here. Let's put everything we just covered on the back burner now and talk about some physics. We're going to be using a very simple Euler integration method. Basically, each frame we accumulate a bunch of forces (in our case from each wheel of the vehicle) and calculate the resultant acceleration, which is in the form of A=F/M (the same as F=MA, Newton's second law of motion). We use this to modify Newton's first law of motion, "an object in motion stays in motion..." So we calculate our A, and we integrate it into our V. Without an A, V would be constant, hence staying in motion, if no forces should act on it. Newton's third law gets applied in the form that any potential force the vehicle is applying to the ground, gets applied in the opposite direction to the vehicle (I'll explain this in the vehicle section). This topic is much easier to explain with symbols. So, P is our vehicle position, V is its linear velocity, F is the net force acting on it, M is its mass, A is the resultant acceleration, and T is the time step (the value our timer gave us from the last frame).
A = F / M
V = V + A * T
P = P + V * T
So with a constant mass, and some force, we will generate acceleration, which will in turn generate velocity, which will in turn generate a displacement (a change in P).
This is a basic linear rigid body simulator. Each frame, we total up some F, integrate it, and then zero out F to restart the accumulation the next frame. Now let's talk about rotation. The angular case is nearly identical to the linear case (especially in 2D). Instead of P we have an Angle, instead of V we have an Angular Velocity, instead of F we have a torque, and instead of M we have inertia. So the angular model looks like this
AngA = Torque / Inertia
AngV = AngV + AngA * T
Angle = Angle + AngV * T
Simple huh? Now you may be wondering where this Torque came from. A torque is generated every time you apply a force. Lay a book down on your desk and push on the corner of it. The book should slide
across the desk, but it should also begin to rotate. The slide is caused by the force. This rotation is caused by the torque, and the magnitude of the torque is directly proportional to how far away
from the center of the object the force was applied. If you applied the force directly to the center of the object, the torque would be zero. We need to construct an AddForce
function
for our rigid body. This is what gets called every frame, once per wheel, to accumulate the chassis' rigid body force/torque. The linear case is simple, Force = Force + newForce. The angular case is
a little trickier. We take the cross product of the force direction and the torque arm (the offset between where the force was applied and the center of mass of the body.) In 2D, this results in a
scalar value that we can just add to Torque. So, Torque = Torque + TorqueArm.Cross(Force)
This is what that bit of code looks like. % is the cross product operator for my vector class.
public void AddForce(Vector worldForce, Vector worldOffset)
{
//add linar force
m_forces += worldForce;
//and it's associated torque
m_torque += worldOffset % worldForce;
}
You'll notice the "world" prefix on the parameters. This is because all computation of the rigid body happens in world space. So as your book is rotating on the desk, the worldOffset
value is changing, even though your finger is not moving on the book (this would be the relativeOffset). So if we know we're applying a force "across the book, at the top right corner" we
need to convert both "across" and "top right corner" into world space vectors, then add them to the rigid body.
Code Dump
Here is my rigid body object. You'll notice all the properties I mentioned above. It has a Draw
function which will draw its rectangle to the provided graphics object. It has an
AddForce
function, a space conversion method, to and from world space (very handy), and a function that returns the velocity of a point on the body (in world space). This point velocity
is a combination of the linear velocity and the angular velocity. But the angular velocity is multiplied by the distance the point is from the center of rotation and perpendicular to its offset
direction. So to kill two birds with one stone, I simply find the orthogonal vector to the point offset and multiply it by the angular velocity (then add the linear velocity.)
One thing you may be curious about is how I calculate the inertia value. That is a generalized formula I found at this link.
//our simulation object
class RigidBody
{
//linear properties
private Vector m_position = new Vector();
private Vector m_velocity = new Vector();
private Vector m_forces = new Vector();
private float m_mass;
//angular properties
private float m_angle;
private float m_angularVelocity;
private float m_torque;
private float m_inertia;
//graphical properties
private Vector m_halfSize = new Vector();
Rectangle rect = new Rectangle();
private Color m_color;
public RigidBody()
{
//set these defaults so we dont get divide by zeros
m_mass = 1.0f;
m_inertia = 1.0f;
}
//intialize out parameters
public void Setup(Vector halfSize, float mass, Color color)
{
//store physical parameters
m_halfSize = halfSize;
m_mass = mass;
m_color = color;
m_inertia = (1.0f / 12.0f) * (halfSize.X * halfSize.X)
* (halfSize.Y * halfSize.Y) * mass;
//generate our viewable rectangle
rect.X = (int)-m_halfSize.X;
rect.Y = (int)-m_halfSize.Y;
rect.Width = (int)(m_halfSize.X * 2.0f);
rect.Height = (int)(m_halfSize.Y * 2.0f);
}
public void SetLocation(Vector position, float angle)
{
m_position = position;
m_angle = angle;
}
public Vector GetPosition()
{
return m_position;
}
public void Update(float timeStep)
{
//integrate physics
//linear
Vector acceleration = m_forces / m_mass;
m_velocity += acceleration * timeStep;
m_position += m_velocity * timeStep;
m_forces = new Vector(0,0); //clear forces
//angular
float angAcc = m_torque / m_inertia;
m_angularVelocity += angAcc * timeStep;
m_angle += m_angularVelocity * timeStep;
m_torque = 0; //clear torque
}
public void Draw(Graphics graphics, Size buffersize)
{
//store transform, (like opengl's glPushMatrix())
Matrix mat1 = graphics.Transform;
//transform into position
graphics.TranslateTransform(m_position.X, m_position.Y);
graphics.RotateTransform(m_angle/(float)Math.PI * 180.0f);
try
{
//draw body
graphics.DrawRectangle(new Pen(m_color), rect);
//draw line in the "forward direction"
graphics.DrawLine(new Pen(Color.Yellow), 1, 0, 1, 5);
}
catch(OverflowException exc)
{
//physics overflow :(
}
//restore transform
graphics.Transform = mat1;
}
//take a relative vector and make it a world vector
public Vector RelativeToWorld(Vector relative)
{
Matrix mat = new Matrix();
PointF[] vectors = new PointF[1];
vectors[0].X = relative.X;
vectors[0].Y = relative.Y;
mat.Rotate(m_angle / (float)Math.PI * 180.0f);
mat.TransformVectors(vectors);
return new Vector(vectors[0].X, vectors[0].Y);
}
//take a world vector and make it a relative vector
public Vector WorldToRelative(Vector world)
{
Matrix mat = new Matrix();
PointF[] vectors = new PointF[1];
vectors[0].X = world.X;
vectors[0].Y = world.Y;
mat.Rotate(-m_angle / (float)Math.PI * 180.0f);
mat.TransformVectors(vectors);
return new Vector(vectors[0].X, vectors[0].Y);
}
//velocity of a point on body
public Vector PointVel(Vector worldOffset)
{
Vector tangent = new Vector(-worldOffset.Y, worldOffset.X);
return tangent * m_angularVelocity + m_velocity;
}
public void AddForce(Vector worldForce, Vector worldOffset)
{
//add linar force
m_forces += worldForce;
//and it's associated torque
m_torque += worldOffset % worldForce;
}
}
Testing
To make sure your rigid body works, instantiate one in your Init()
function and apply a force with some offset in the DoFrame
function. If you apply a constant
worldOffset, the body will continue to accelerate its angular velocity. If you take your offset and run it through the RelativeToWorld
function, the body will angularly accelerate in one
direction and then come back the other way, like a pendulum as the point the force is applied to changes. Play around with this for a while, this has to work and make sense in order for the next
section to work.
Phase Three - The Vehicle
Assuming everything has gone well above, you should have a rigid body actor in your scene that you can apply forces to and watch move around. Now all that's left is to calculate these forces in a way that will simulate a vehicle. For that we are going to need a vehicle object. I recommend deriving directly from you rigid body object since the chassis is essentially a rigid body. In addition to that we will need to construct a "wheel" object. This wheel will handle the steering direction of each wheel, the velocity the wheel is spinning, and calculate the forces that that particular wheel applies to the chassis (all in vehicle space). Since our wheel is known to be constrained to the vehicle, we don't need to simulate it as another rigid body (though you could, but not in the 2D case.) We will simply duplicate the angular properties of the rigid body in the wheel object.
So we'll need: Wheel Velocity, Wheel Inertia, and and Wheel Torque. We'll also need the relative offset of the wheel in the vehicle space, and the angle the wheel is facing (this is
constant for the back wheels, unless you want 4 wheel steering.) Just like the rigid body, the wheel's torque function acts as an accumulator, we add torques to it and after it gets integrated the
torque is zeroed out. The AddTorque
function is where you will apply a wheel torque from either the transmission (to make you go) or from the brakes (to make you stop). Internally the
wheel will generate a torque caused by the friction on the road.
The wheel object also needs a SetSteering
function. This function calculates two vectors: an effective Side Direction, and an effective Forward Direction (both in vehicle space) that
the tire patch will act on. The force applied on the tire by the ground acting in the side direction will directly translate into the chassis. Meanwhile the force acting in the forward direction will
not only act on the chassis, but it will induce a rotation of the tire. Here is the SetSteering
function; you will see I used the Drawing2D.Matrix
to transform the initial
forward and side vectors by the steering angle (I had to convert the vectors to "points" in order to transform them by the matrix.)
public void SetSteeringAngle(float newAngle)
{
Matrix mat = new Matrix();
PointF[] vectors = new PointF[2];
//foward vector
vectors[0].X = 0;
vectors[0].Y = 1;
//side vector
vectors[1].X = -1;
vectors[1].Y = 0;
mat.Rotate(newAngle / (float)Math.PI * 180.0f);
mat.TransformVectors(vectors);
m_forwardAxis = new Vector(vectors[0].X, vectors[0].Y);
m_sideAxis = new Vector(vectors[1].X, vectors[1].Y);
}
Force Calculation
So, if the vehicle is sitting there not moving with its front wheels turned, and you push it, a force will be generated in the opposite direction you push. This force gets projected onto these two directions. If the wheels were straight there would be no side force. So the vehicle would simply roll forward. But since the wheels are turned, there is a bit of the force that acts in the "effective side direction" so we apply an opposite force to the chassis. This is what causes you to turn when you steer the wheels. To get this force that gets projected onto the two directions, we need to first determine the velocity difference between the tire patch and the road. If the wheel is spinning at the same speed the ground is wizzing by, then there is effectively no force acting on the vehicle. But as soon as you slam on the brakes and stop the wheel, there is a huge velocity difference and this is what causes the force that stops your car.
So here is the process broken down into 6 steps, for each wheel.
Step 1, calculate the effective direction vectors (with steering function).
Step 2, calculate velocity difference. The ground speed is determined via the "PointVel" function on the rigidbody, given the current wheel's world offset.
Step 3, project this velocity onto the two effective directions.
Step 4, generate an equal and opposite force for the two direction and call this the "response force". This is what gets added to the chassis for each wheel.
Step 5, calculate the torque that the forward response force created on the wheel, and add this to the wheel torque.
Step 6, integrate the wheel torques into the wheel velocity.
That bit of code looks like this:
public Vector CalculateForce(Vector relativeGroundSpeed, float timeStep)
{
//calculate speed of tire patch at ground
Vector patchSpeed = -m_forwardAxis * m_wheelSpeed *
m_wheelRadius;
//get velocity difference between ground and patch
Vector velDifference = relativeGroundSpeed + patchSpeed;
//project ground speed onto side axis
float forwardMag = 0;
Vector sideVel = velDifference.Project(m_sideAxis);
Vector forwardVel = velDifference.Project(m_forwardAxis, out forwardMag);
//calculate super fake friction forces
//calculate response force
Vector responseForce = -sideVel * 2.0f;
responseForce -= forwardVel;
//calculate torque on wheel
m_wheelTorque += forwardMag * m_wheelRadius;
//integrate total torque into wheel
m_wheelSpeed += m_wheelTorque / m_wheelInertia * timeStep;
//clear our transmission torque accumulator
m_wheelTorque = 0;
//return force acting on body
return responseForce;
}
Almost Done!
We're in the home stretch here now. Now we have a way to calculate the force each wheel generates on the chassis. Every frame, all we have to do is set our transmission and brake torques, our steering angle, calculate each wheel force, add these to the chassis, and integrate the rigid body. Badaboom badabing, vehicle done! :)
Conclusion
Here is the entire source code for the project. If you have any questions or comments please feel free to post them here and either I can make things more clear or maybe someone else could offer some better expertise. If you'd like you can email me at Kincaid05 on google's fine emailing service.
Thanks for reading and I hope this was informative.
-Matt Kincaid