Vector math for games

Introduction

Trigonometry is a scary topic for many game developers... it certainly was for me when I started making games. This brief tutorial attempts to dispel some of the fear attached to vector math through simple, concrete examples.

Coordinate system

Want to learn some cool trig functions? Before diving in, consider that most 2D game engines and rendering systems typically work with the y-axis pointing South. This is done out of convenience, so that the origin (0, 0) remains in the upper-left corner of the screen. However, if you've taken any trigonometry in school, then you'll probably remember that the y-axis points North! That's why your game logic code, especially involving math, should never be affected by how you draw stuff on the screen.

When using trig function like sine or cosine, we have to start with a few assumptions:

  1. the y-axis points North
  2. angles are measured in radians where Pi = 180 degrees = 3.14159 radians
  3. an angle of 0 points East and it rotates counter-clockwise as its value increases
Did I lose you yet? This diagram explains it better:

Example 1: The coordinate system is projected so that the x-axis points right and the y-axis points up. The green arc segment represent an angle (measured in radians).

Degrees to radians

In trigonometry, we use radians to measure and express angles. When programming, there is rarely any use of storing your angles in degrees. Nevertheless, Lua provides a couple of conversion functions:
radians = math.rad(degrees)
degrees = math.deg(radians)
As you already know, PI or 3.14159265359 radians is equivalent to 180 degrees. It's quite simple to convert between the two different units.
radians = degrees*(math.pi/180)
degrees = radians*(180/math.pi)
Just remember that radians are numbers and they can be negative too!

Vectors and points

A point is simply a position in space whereas a vector is a combination of direction and magnitude. Think of a car located at some point (100, 100) with a velocity vector of (0, 10). The position of the car (100, 100) is a point relative to the origin of some coordinate system. The velocity of the car (0, 10) is a vector that tells us the heading (angle) and the speed (magnitude).

Angles and rotation

Vector angle

When programming games we often start with a given heading and speed. These two values are sometimes called "polar" coordinates. It's fairly easy to convert the angle (or heading) and the length (speed) to the nice Cartesian coordinates that we all know and love:
function vpolar(a, d)
  d = d or 1
  local x = math.cos(a)*d
  local y = math.sin(a)*d
  return x, y
end
The previous function accepts the length as an optional parameter. If we didn't multiply both components by the length, we would end up with a unit vector (length = 1). It's important to remember that all trigonometric functions work in radians. If you prefer using degrees, you have to convert your angle (or heading) to radians using math.rad().



Example 2: Polar vs Cartesian coordinates

What if we already have an existing vector and want to find its angle? This is where the atan2 function comes into play. Just remember that atan2 assumes that the y-axis points up and rotates counter-clockwise.

function vangle(x, y)
  return math.atan2(y, x)
end
Please note that this function accepts the y coordinate as its first parameter. When the y value of the supplied vector is negative, atan2 returns a negative angle. In fact, this function always returns a value between -PI and PI. Luckily, we can easily clamp any angle to the positive range (0 to 360) using the modulo operator.
positive = radians%(math.pi*2)

Angle between two points

The previous function is used when dealing with vectors. To find the angle from one point to another, we have to modify the code slightly:
function angle(x, y, x2, y2)
  return math.atan2(y - y2, x - x2)
end
The atan2 function should never be used with zero-length (or degenerate) vectors. Degenerate vectors do not have a meaningful direction or an "angle" at all. However, atan2(0, 0) returns zero which typically means "East".



Example 3: Angle from one point to another, measured using atan2 (from the right in counter-clockwise direction)

Rotating vectors

Vectors can be rotated by 90 or 180 degrees with no computation at all, simply by swapping and negating their x and y values.
x, y = -y, x -- 90 degrees counterclockwise
x, y = y, -x -- 90 degrees clockwise
x, y = -x, -y -- 180 degrees
The following function can rotate a vector by an arbitrary angle in radians.
function rotate(x, y, a)
  local c = math.cos(a)
  local s = math.sin(a)
  return c*x - s*y, s*x + c*y
end

Angle between two vectors

Finding the angle between two vectors is a common problem in games. Note that the angle between two vectors is not the same as the direction from one point to another. For example, if he have the vectors 1,0 and 0,1 then the angle between them is pi/2 or 90 degrees. That is, the difference between the headings of the two vectors is 90 degrees.

One approach to finding the angle between vectors is using the arcsine function. The tricky part in the code involves checking the cross product (x*y2 - y*x2) to see if we should negate the angle. This is necessary because arcsine always returns a positive number.

function vangle(x, y, x2, y2)
  local arc = (x*x + y*y)*(x2*x2 + y2*y2)
  arc = sqrt(arc)
  if arc > 0 then
    arc = math.acos((x*x2 + y*y2)/arc)
    if x*y2 - y*x2 < 0 then
      arc = -arc
    end
  end
  return arc
end

Another way of calculating the angle between two vectors is by using math.atan2. Then, we can use the modulo operator ("%") to clamp the resulting angle. This method works when dealing strictly with angles too. The only requirement is that both vectors have non-zero length.

function vangle2(x, y, x2, y2)
  local a = math.atan2(y2, x2) - math.atan2(y, x)
  return (a + math.pi)%(math.pi*2) - math.pi
end



Example 4: Angle between vectors

Another common problem is rounding an angle to the nearest cardinal direction. This operation is useful if you want to restrict the movement of an agent or projectile.

function cardinal(angle, steps)
  steps = steps or 8
  return math.floor(steps*angle/(2*math.pi) + steps + 0.5)%steps
end

Distance and length

Vector length

The vector length or magnitude is calculated using the Pythagorean theorem: a2 + b2 = c2. In order to find the length of a vector, the formula is restated as: sqrt(a2 + b2) = c.
function length(x, y)
  return math.sqrt(x*x + y*y)
end
For efficiency purposes, the code examples use multiplication instead of the more formal: x^2 + y^2.

Distance between two points

It's not difficult to find the distance between two points using the Pythagorean theorem. Imagine that the two points represent the vertices of the hypotenuse on a right-angled triangle. All we have to do is find the square root of the sum of the two sides squared:
function distance(x, y, x2, y2)
  local dx, dy = x2 - x, y2 - y
  return math.sqrt(dx*dx + dy*dy)
end



Example 5: Distance between two points

Normalizing and scaling vectors

Normalizing a vector means changing its length to 1 while retaining its angle. You have to be careful with this function since when the vector's x and y values are both 0 a division by 0 will occur. There shouldn't be a case in your program where you are trying to normalize a zero-length vector.
function normalize(x, y)
  local d = math.sqrt(x*x + y*y)
  assert(d > 0)
  return x/d, y/d
end

Scaling a vector means multiplying its x and y components by a number. The result is another vector with the same heading, but different length.

function scale(x, y, s)
  return x*s, y*s
end
If we scale a vector by its inverse length (1/length(x, y)) we get a normalized vector whose length is 1.

Products and projection

Dot product

The dot product is an operation between two vectors whose result is a single number. The dot product is commutative so dot(a,b) == dot(b,a).
function dot(x, y, x2, y2)
  return x*x2 + y*y2
end
The dot product tells us about the angle (R) between the two vectors. It is positive, when the angle is acute and negative if it's obtuse. When the dot product is zero, the two vectors are perpendicular. For two normalized vectors:
cos(R) = dot(ax, ay, bx, by)
R = math.acos(dot(ax, ay, bx, by)
For vectors that may not be normalized:
L = length(ax, ay)*length(bx, by)
cos(R) = dot(ax, ay, bx, by)/L
R = math.acos(dot(ax, ay, bx, by)/L)

Suppose that we have a moving car P with a heading of h. Using the dot product, we can find if the car is facing a target point T. When the dot product of the h and d is positive (where: d = T - P), point T is in front the moving car and when negative it must be behind it. If the dot product is zero, the car's heading is perpendicular to point T.



Example 6: Dot product

We can take this example one step further by rotating the heading vector by 90 degrees before calculating the dot product. In that case, the sign of the dot product shows us if the target is on the left or right hand side of the car. When the dot product is 0, the heading vector must be pointing directly at the target point T.

Cross product

In 3D, the cross product of two vectors produces a third vector perpendicular to the original two. In 2D, the result is a number equal to the area of the parallelogram spanned by the two vectors.
function cross(x, y, x2, y2)
  return x*y2 - y*x2
end
The cross product is zero if the two vectors are parallel. Notice that the cross product is not commutative, therefore cross(a,b) == -cross(b,a).

Vector projection

Suppose we have two vectors a and b. If we normalize vector a (an) and multiply it by the dot product of an and b we get vector bp. The resulting vector bp is basically vector b projected onto the axis defined by a.
an = normalize(a)
bp = an*dot(an, b)
This technique allows us to project vectors to any arbitrary axis. Here is an example with three points P0, P1, P2 and the resulting, projected point P3.
a = P1 - P0
b = P2 - P0
an = normalize(a)
bp = an*dot(an, b)
P3 = bp + P0



Example 7: Projecting the point P2 onto an axis defined by P0 and P1

Real-time applications

Moving to target

Moving a sprite gradually to some target position is one of the most common tasks in 2D games. First, we need to figure out how much the sprite's position has to change in a single frame. This travel distance (step) equals the sprite's desired velocity (speed) times the interval of time per one frame (dt). Next, we find the normalized vector between the moving sprite and its target. The normalized vector has a length of 1 and is pointing in the direction of the target. Then we simply multiply the normalized vector by the distance that the sprite should travel (step).
local sprite = { x = 0, y = 0, speed = 100 }

function sprite.updatePosition(dt)
  -- vector from sprite to target
  local mx, my = love.mouse.getPosition()
  local dx = mx - sprite.x
  local dy = my - sprite.y
  local dist = math.sqrt(dx*dx + dy*dy)
  local step = sprite.speed*dt
  if dist <= step then
    -- we have arrived
    sprite.x = mx
    sprite.y = my
  else
    -- normalize vector (between the target and sprite)
    local nx = dx/dist
    local ny = dy/dist
    -- keep moving
    sprite.x = sprite.x + nx*step
    sprite.y = sprite.y + ny*step
  end
end
The code above could be simplified, but it's hopefully easier to understand in this more descriptive form.



Example 8: Moving at constant speed toward the mouse cursor.

Rotating to target

Next, we are going to solve a common problem in games - rotating a sprite so that it's "aiming" at a target point. Look at the following diagram where P is the position of the rotating sprite, h is its heading vector and T is the target position. Our goal is to find the angle formed between the vectors h and d. This angle is the amount we have to rotate our sprite so that it faces the target. The formula for finding the desired angle is:
angle = (target - heading + pi)%(2*pi) - pi
Notice that the resulting angle measures the arc from the current heading to the target.

local sprite = { x = 0, y = 0, angle = 0, turnrate = math.pi }

function sprite.updateAngle(dt)
  -- angle from sprite to target
  local mx, my = love.mouse.getPosition()
  local dx = mx - sprite.x
  local dy = my - sprite.y
  local target = math.atan2(my - dy, mx - dx)
  -- arc between heading and target
  local dist = target - sprite.angle
  dist = (dist + math.pi)%(math.pi*2) - math.pi
  local step = sprite.turnrate*dt
  if math.abs(dist) <= step then
    -- target angle reached
    sprite.angle = target
  else
    -- keep rotating
    if dist < 0 then
      step = -step
    end
    sprite.angle = sprite.angle + step
  end
end


Example 9: Rotating at constant speed toward the mouse cursor (T).