Camera Basics in LÖVE

Part 1 of the series - Coding a Camera in LÖVE

Introduction

A camera is an important part of most games. A good camera often affects how the player experiences the game. This record goes over how to create a good camera in the LÖVE framework. If you are reading this, having a basic understanding of programming and Lua is recommended.

Before we set up our basic camera, we need to understand how a camera works in games. At a first glance, it might seem that the camera is moving around the world, but it isn’t really so. The camera really doesn’t exist. It is just what we see in our screen that is changing. So the camera is really the window of our game and it does not move at all - it is static. So how does what we see on the screen change? If we can’t move the camera, we simply have to move the game world!

Let us visualize what moving the game world really means. Say the world is located at (x, y) = (0, 0). These are the coordinates of the origin of the world. Initially, let us have our camera also located at this position.

Initial positions
Figure 1: Initial World and Camera Positions

The viewport of our screen (or our camera) is fixed. The question is, how exactly do we move the world to give the illusion that the camera is moving? We can move the world in two directions:

  1. In the direction that the camera wants to move to view the target.
  2. Opposite to the direction that the camera wants to move to view the target.

If we move the world in the direction that we want the camera to move (the target), we end up going away from the target. This is illustrated in the image below.

Moving the World in the same direction
Figure 2: Moving the World towards the target

If we move the world in the opposite direction that we want the camera to move, we end up going towards the target.

Moving the World in the opposite direction
Figure 3: Moving the World away from the target

Now that we know how to move the World around, we can start building our camera.

Let’s set up a simple rectangle in our LÖVE game that will act as the target.

main.lua

local love = require("love")

function love.load()
  player = {
    x = 100,
    y = 100,
    speed = 100
  }
end

function love.update(dt)
  if love.keyboard.isDown("left") then
    player.x = player.x - (player.speed * dt)
  elseif love.keyboard.isDown("right") then
    player.x = player.x + (player.speed * dt)
  end

  if love.keyboard.isDown("up") then
    player.y = player.y - (player.speed * dt)
  elseif love.keyboard.isDown("down") then
    player.y = player.y + (player.speed * dt)
  end
end

function love.draw()
  love.graphics.rectangle("fill", player.x, player.y, 30, 30)
end

If you run this program using the following command from the root directory of your project, you will see a white rectangle in your screen that you can move around using the arrow keys.

bash

love .

If you, however, go out of the screen area, you lose sight of the rectangle. To make sure that doesn’t happen, we will setup a camera.

To keep track of how much the world needs to moved in the x and y coordinates, we will create a Camera table that has two fields storing said x and y values.

The Camera table

local Camera = {
    wx = 0,
    wy = 0,
}

Assuming that we want the target to be in the center of the camera at all times, we can calculate the distance of the target from the camera center and use that to move the world.

Target’s distance from the Camera center
Figure 4: Distance to move the Camera to get Target in the center

The units of x (or pixels) the world needs to be moved to get the target in the center are

Centering the Target in the Camera
Figure 5: After moving the World to the center the Target in the Camera

If the Camera’s width and height are the same as that of the screen, we can get those values in our update loop as follows:

Updating the Camera's x and y values

function love.update(dt)
  ...
  Camera.wx = player.x - love.graphics.getPixelWidth()/2
  Camera.wy = player.y - love.graphics.getPixelHeight()/2
end

Now we need to move the World by some the x and y values stored in the Camera table during the draw loop.

Drawing the World with an offset from the Camera

function love.draw()
  love.graphics.push()
  love.graphics.translate(-Camera.wx, -Camera.wy)
  love.graphics.rectangle("fill", player.x, player.y, 30, 30)
  love.graphics.pop()
end

Let us have a look at each line of code in the draw loop.

Firstly, we have love.graphics.push() in the first line. This is a function provided by LÖVE that pushes all the currently applied transformation to the stack. This is necessary to make sure that any other transformations being applied do not interfere with what we are going to draw next.

Next up is love.graphics.translate(-Camera.wx, -Camera.wy). This translates whatever we draw after this line by -Camera.x and -Camera.y.

Now we draw our player. And then pop the transformations we pushed to the stack using love.graphics.pop() making sure the previous transformations are applied again.

If we run this code now, we see that the target does not move at all when we use our arrow keys. But what is really happening is that the target is now set to always be in the center of the Camera. And since the Camera’s width and height were set to be equal to the screen, the screen center is now the Camera center.

We can make a box to indicate our world boundaries to see if the camera is indeed moving.

Adding World boundaries

function love.draw()
  love.graphics.push()
  love.graphics.translate(-Camera.wx, -Camera.wy)
  love.graphics.rectangle(
    "line",
    0,
    0,
    love.graphics.getPixelWidth(),
    love.graphics.getPixelHeight()
  )
  love.graphics.rectangle("fill", player.x, player.y, 30, 30)
  love.graphics.pop()
end

Now when we run the code, we can see that the target stays in the center of the Camera and is actually moving around the World (or rather the World is moving to keep the target in the center of the Camera) with the target in the center!

We now have our basic Camera setup!

If we want to make our Camera more modular, we will have to move it to a new folder and create a library. I personally prefer doing this to keep the main game loop clean and easy to read.

First we move the Camera table to a new file camera/init.lua. Once that is done, we need to create a few helper methods.

The Camera library

local love = require("love")

local Camera = { }
Camera.__index = Camera

function Camera:new()
  local o = setmetatable({}, Camera)
  o.wx = 0
  o.wy = 0
  o.width = love.graphics.getPixelWidth()
  o.height = love.graphics.getPixelHeight()
  o.target = nil
  return o
end

function Camera:attachTarget(target)
  self.target = target
end

function Camera:update()
  self.wx = self.target.x - self.width/2
  self.wy = self.target.y - self.height/2
end

function Camera:setTransform()
  love.graphics.push()
  love.graphics.translate(-self.wx, -self.wy)
end

function Camera:unsetTransform()
  love.graphics.pop()
end

return Camera

Camera is now a class which has a few fields and methods. You could choose not to create a class but I prefer to and so I’ve made it into one.

We create a new Camera instance by using the Camera:new() method. Camera.target now holds a reference to the target that we want to track, and the target has to be manually attached once.

Data Type for the Target

Keep in mind that the target has to be a table since tables are passed as references in Lua. Make sure that you are careful not to modify those target coordinates or else the target position itself would change.

If you choose not to do so, you can set up the update function to accept the target’s x and y coordinates and update them there. This will remove the risk of modifying the target coordinates.

The Camera:update() method has the logic for calculating the distance to move the world to get the target in the center.

Camera:setTransform() and Camera:unsetTransform() can now be used to apply transforms, draw our sprites, and then unset the transformations.

We can further improve this by utilizing LÖVE’s Transform to keep track of all the translations, rotations, and scaling that we want to apply to the World.

Using Transform to keep track of applied transformations

function Camera:new()
  local o = setmetatable({}, Camera)
  ... o.transform = love.math.newTransform()
  return o
end

function Camera:update()
  self.wx = self.target.x - self.width/2
  self.wy = self.target.y - self.height/2

  self.transform:translate(-self.wx, -self.wy)
end

function Camera:setTransform()
  love.graphics.push()
  love.graphics.applyTransform(self.transform)
end

function Camera:unsetTransform()
  self.transform:reset()
  love.graphics.pop()
end

With these modifications, our draw function can now be changed to the following.

Updated draw function

function love.draw()
  camera:setTransform()
  love.graphics.rectangle(
    "line",
    0,
    0,
    love.graphics.getPixelWidth(),
    love.graphics.getPixelHeight()
  )
  love.graphics.rectangle("fill", player.x, player.y, 30, 30)
  camera:unsetTransform()
end

Many a times we have a Camera that we want to set bounds on, i.e. we do not want to expose the empty space around the World. We can see this situation in Figure 5 where the Camera shows the space outside our World. This is a simple fix. We just need to know the bounds of the World and use these to create guards when updating our Camera’s self._x and self._y fields.

Bounding the Camera to the World boundary

function Camera:new()
  ...
  o.bounds = {
    set = false,
    left_x = nil,            --The left most x coordinate of the World
    top_y = nil,            --The left most y coordinate of the World
    right_x = nil,         --The right most x coordinate of the World - The width of the Camera
    bottom_y = nil          --The right most y coordinate of the World - The height of the Camera
  }
end

function Camera:setBounds(x, y, width, height)
  self.bounds.set = true
  self.bounds.left_x = x
  self.bounds.top_y = y
  self.bounds.right_x = x + width - self.width
  self.bounds.bottom_y = y + height - self.height
end

With this, we have the foundation for bounding the Camera to the World boundary. Next, we need to modify the update function so that the new values of self._x and self._y do not exceed the boundaries.

Modified update function

function Camera:update()
  local x = self.target.x - self.width / 2
  local y = self.target.y - self.height / 2

  if self.bounds.set then
    x = math.max(self.bounds.left_x, math.min(x, self.bounds.right_x))
    y = math.max(self.bounds.top_y, math.min(y, self.bounds.bottom_y))
  end

  self.wx = x
  self.wy = y

  self.transform:translate(-self.wx, -self.wy)
end

Lines 7 and 8 in this snippet utilize math functions to clamp the value of x. We want the top-left part of the Camera to not go any lower than the top x and y of the bounds and stay lower than bottom x and y. This does mean that the target does not remain centered in the four corners. This might be a desirable feature for your game, and if it isn’t, you can simply have bounds.set to false.

Before we go ahead, it would become much more easier for us if we are able to convert the coordinates from World space to Camera space. This means that we convert the coordinates that use the World origin as their origin to coordinates that use the Camera origin as their origin.

Target in World Space
Target in World Space

Target in Camera Space
Target in Camera Space

Doing this is simple enough if we have no rotations or scaling applied to the Camera. We simply subtract the coordinates stored in Camera._x and Camera._y from the World space coordinates.

World Space to Camera Space (NO Scaling or Rotation)

function Camera:worldToCamera(x, y)
  local camera_x = x - self.wx
  local camera_y = y - self.wy
  return camera_x, camera_y
end

To convert the coordinates from Camera Space to World Space we just need to do the reverse of what we did to go from the World Space to Camera Space.

World Space to Camera Space (NO Scaling or Rotation)

function Camera:cameraToWorld(x, y)
  local world_x = x + self.wx
  local world_y = y + self.wy
  return world_x, world_y
end

Damping, simply put, is the smooth movement of the camera instead of jerky motions. In physics, damping is the loss of energy of an oscillating system. Here, we will initially model it as a linear system, but also take a look at oscillating systems later.

To dampen our camera movement each update, we can use the calculated difference between the target and the camera center, subtract the camera position to get the delta (the distance between the target and the camera center), multiply it with a damping factor, and then add it to our current camera coordinates.

This is similar to the equation for Linear Interpolation:

where is the interpolation factor, is the destination, and and are the current position and new position respectively.

Here is the updated code for the Camera:update() function to utilize this interpolation.

Linear Interpolation Damping

function Camera:new()
  ...
  o.damping = 1
  return o
end

function Camera:lerp(a, b, c)
  return a + (b - a) * c
end

function Camera:update()
  local target_x, target_y = self:fromWorldToCamera(self.target.x, self.target.y)

  local delta_x = target_x - self.width / 2
  local delta_y = target_y - self.height / 2

  local x = self.wx + self:lerp(0, delta_x, self.damping)
  local y = self.wy + self:lerp(0, delta_y, self.damping)

  if self.bounds.set then
    x = math.max(self.bounds.left_x, math.min(x, self.bounds.right_x))
    y = math.max(self.bounds.top_y, math.min(y, self.bounds.bottom_y))
  end

  self.wx = x
  self.wy = y

  self.transform:translate(-self.wx, -self.wy)
end

The closer the value of damping is to 0, the slower the camera moves, and subsequently, the farther away the value is from 0, the slower it moves. You can change the value of the damping factor to try out what feels good for your game.

This current setup however, is not frame-independent, which means that on a machine with a faster frame rate, the camera will reach its destination faster. To make the damping equation factor this in, we can use delta time (dt) which is the time passed between two consecutive frames.

Multiplying this along with the damping factor makes our update function frame-independent. At higher frame rates, dt is smaller and so is the product of damping and dt, and lower frame rates dt is larger, which keeps the damping consistent over time.

Linear Interpolation
Linear Interpolation Function

As a consequence of this multiplication, we need to increase the value of damping to make sure that the product does not become too small.

Linear Interpolation Damping (Frame-Rate Independent)

function Camera:lerp(a, b, c, dt)
  dt = dt or 1
  return a + (b - a) * c * dt
end

Now that we have our function which returns lerped values, we can call this in our main update function.

Modified Update Function

function Camera:update(dt)
  ...

  local x = self.wx + self:lerp(0, delta_x, self.damping, dt)
  local y = self.wy + self:lerp(0, delta_y, self.damping, dt)

  ...
end

If you notice, the values of a and b we pass into the lerp fuction are 0 and coordinate - dimension/2. The reason a and b have these values is because we have already subtracted self.x and self.y from the target coordinates when moving them from World space to Camera space.

We have a couple of problems here though.

  1. First up, as the distance gets smaller, the steps we take to reach the target become infinitesimally small, so small that the camera motion may appear to be jerky between steps.

  2. Secondly, unless the delta reaches exactly zero, which would take a while, the calculation will go on forever even if we stop moving our character.

To avoid such unnecessarily small calculations, we can set a threshold that to limit how small the steps can be.

Threshold Capped Lerp

function Camera:new()
  ...
  o.threshold = 0.01
  ...
end

function Camera:update(dt)
  local target_x, target_y = self:fromWorldToCamera(self.target.x, self.target.y)

  local delta_x = target_x - self.width / 2
  local delta_y = target_y - self.height / 2

  local x_step = self:lerp(0, delta_x, self.damping, dt)
  local y_step = self:lerp(0, delta_y, self.damping, dt)

  if math.abs(x_step) < self.threshold then
    x_step = delta_x
  end
  if math.abs(y_step) < self.threshold then
    y_step = delta_y
  end

  local x, y = self:fromCameraToWorld(x_step, y_step)

  if self.bounds.set then
    x = math.max(self.bounds.left_x, math.min(x, self.bounds.right_x))
    y = math.max(self.bounds.top_y, math.min(y, self.bounds.bottom_y))
  end

  self.wx = x
  self.wy = y

  self.transform:translate(-self.wx, -self.wy)
end

Doing this check in the update function instead of lerp ensures that we can use it for the upcoming update functions as well, making our code more modular.

There are many more ways you can rig the camera to dampen movement. Let’s have a look at some more of them.

In this approach, the camera initially moves at higher speeds and then slows down exponentially as it reaches the target location. It is similar to lerp, but instead of multiplying directly by the damping factor, we multiply the delta by a negative exponent of damping subtracted from 1.

Exponential Decay
Exponential Decay Function (exaggerated)

To make this frame-independent, we multiply damping by dt.

Exponential Decay Damping (Frame-Rate Independent)

function Camera:expDecay(a, b, c, dt)
  dt = dt or 1
  return a + (b - a) * (1 - math.exp(-c * dt))
end

Threshold-Capped Exponential Decay Damping (Frame-Rate Independent)

function Camera:update(dt)
  ...

  local x_step = self:expDecay(0, delta_x, self.damping, dt)
  local y_step = self:expDecay(0, delta_y, self.damping, dt)

  ...
end

You can change the curve of damping by tweaking the factor to get the desired smoothness.

We’ve all seen springs and how they oscillate when a force is applied. We use that as the basis here and treat any camera movement as a spring. The actual equation the movement of the spring in an ideal system is as follows:

where is the position of the spring, is the amplitude, is the angular frequency, and is time.

Note that this is for an ideal system and would not end up slowing down over time. It does not account for the damping and velocity, which is something we need to create the smooth camera motion that we are looking for.

To fix this, the equation needs to be modified. Without going into the details and turning this into a Physics class, the new equations would be:

where is the velocity, is the stiffness, is the current position, is the damping factor, is the mass and is the time. For simplicity, we can take the mass to be 1.

We then use this velocity to calculate the new value of .

Under Damped Spring System (Position vs Time)
Under Damped Spring System (Position vs Time)

In each time frame, we reduce the velocity slightly using the damping factor, eventually making the value of reach . If we remove the reduction of velocity because of the damping factor, the camera will keep on oscillating and never come to a stop. You can try this out by setting the damping factor to zero in the code below.

The issue we experienced with exponential decay is also present here, so we need to cap the values to prevent calculations when the positional difference is infinitesimally small.

Under Damped Spring System (Frame-Rate Independent)

function Camera:new()
  ...
  o.velocityX = 0 -- Initialize velocity for X
  o.velocityY = 0 -- Initialize velocity for Y
  o.threshold = 0.01 -- Threshold to prevent infinite calculations
  o.stiffness = 10 -- Spring stiffness (k)
  o.mass = 1 -- Mass of the spring (m)
  o.damping = 2 -- Damping coefficient (c)
  ...
end

function Camera:underDampedSpring(delta, velocity, dt)
  dt = dt or 1
  local force = self.stiffness * delta
  local damping = self.damping * velocity
  velocity = velocity + ((force - damping) / self.mass) * dt
  return velocity * dt, velocity
end

function Camera:update(dt)
  ...

  local x_step, y_step

  x_step, self.velocity_x = self:underDampedSpring(delta_x, self.velocity_x, dt)
  y_step, self.velocity_y = self:underDampedSpring(delta_y, self.velocity_y, dt)

  if math.abs(delta_x) < self.threshold then
    x_step = delta_x
    self.velocity_x = 0
  end

  if math.abs(delta_y) < self.threshold then
    y_step = delta_y
    self.velocity_y = 0
  end

  ...
end

You can play with the spring stiffness and the damping factor to get the result you desire. For the same damping, the higher the stiffness, the more oscillations take place. For the same stiffness, the higher the damping, the more the loss in force per oscillation. If you want more control over the camera movement, you can increase the mass to get slower oscillations and decrease it to get faster oscillations. Keep in mind that mass won’t change the number of oscillations or how much energy is lost in each oscillation.

A critically damped spring has a specific damping coefficient which makes it reach the target as soon as possible without oscillating. The value of the damping coefficient has to conform to the following equation to achieve this.

Critically Damped Spring System (Position vs Time)
Critically Damped Spring System (Position vs Time)

Therefore, the only change that we need to make to simulate this behavior is:

Critically Damped Spring System (Frame-Rate Independent)

function Camera:new()
  ...
  o.damping = 2 * math.sqrt(o.stiffness * o.mass)-- Damping coefficient (c)
  ...
end

With this small change, we can make the camera stop at the target without oscillations!

Smooth Damp is an interpolation method that is used frequently in game dev, particularly for smoothly stepping over positions or rotations over time. It is similar to the Critically Damped Spring system but is implemented with different equations. Below is the code from Unity’s CS Reference in lua.

Unity's Smooth Damp Function

local function smoothDamp(current, target, velocity, smoothTime, maxSpeed, deltaTime)
  -- Ensure smoothTime is not too small
  smoothTime = math.max(0.0001, smoothTime)
  local omega = 2.0 / smoothTime

  -- Calculate the exponential decay factor
  local x = omega * deltaTime
  local exp = 1.0 / (1.0 + x + 0.48 * x ^ 2 + 0.235 * x ^ 3)

  -- Calculate the difference between current and target
  local change = current - target
  local originalTo = target

  -- Clamp the maximum speed (maxChange)
  local maxChange = maxSpeed * smoothTime
  change = math.max(-maxChange, math.min(change, maxChange))

  -- Update target based on the clamped change
  target = current - change

  -- Calculate the new velocity
  local temp = (velocity + omega * change) * deltaTime
  velocity = (velocity - omega * temp) * exp

  -- Calculate the new position
  local output = target + (change + temp) * exp

  -- Prevent overshooting the target
  if (originalTo - current > 0) == (output > originalTo) then
    output = originalTo
    velocity = (output - originalTo) / deltaTime
  end

  return output, velocity
end

We see here that is follows the same idea of modifying the velocity and using it to update the position of the camera. The target in our case would be delta_x and delta_y and our current positions would be (0, 0) since we are in the Camera coordinate space.

Smooth Damp Update Call

function Camera:update(dt)
  ...

  x_step, self.velocity_x = self:smoothDamp(0, delta_x, self.velocity_x, 0.3, 75, dt)
  y_step, self.velocity_y = self:smoothDamp(0, delta_y, self.velocity_y, 0.3, 75, dt)

  ...
end

The Smooth Damp interpolation method provides us with good control over the camera movement without having to configure a lot of parameters and dealing with the physics of things.

With this, you are well equipped to get a smoothly moving camera! Now we move onto the next part of a good game camera, the Deadzones.

Organizing code for multiple damping options

The current setup only allows for a singular damping method exist in the update block at any given time. This means that once the program is run, it is not possible to choose another damping method for a particular case. Adding this functionality to our code would make the Camera more flexible.

We can achieve this in a couple of ways. The easiest, with our current setup, would be to set up a flag that the user can set which can then be compared to find out which damping method to execute. While simple, this means that there will be many checks made during the update loop with an O(n) time complexity in the worst case.

Another method would be to create a table that stores these methods and use the flag to get the function at that index and execute it. This however would require defining the function in the Camera:new() method, and there is also the issue of different number of parameters and return values. This can, however, be worked around by moving the functions to a different file altogether and utilizing closures for making the function signature similar.

utils.lua

local utils = {}

utils.lerp = function(a, b, c, dt)
  dt = dt or 1
  return a + (b - a) * c * dt
end

utils.expDecay = function(a, b, c, dt)
  dt = dt or 1
  return a + (b - a) * (1 - math.exp(-c * dt))
end

utils.underDampedSpring = function(delta, mass, stiffness, damping, velocity, dt)
  dt = dt or 1
  local force = stiffness * delta
  local d = damping * velocity
  velocity = velocity + ((force - d) / mass) * dt
  return velocity * dt, velocity
end

utils.smoothDamp = function(current, target, velocity, smoothTime, maxSpeed, dt)
  smoothTime = math.max(0.0001, smoothTime)
  local omega = 2.0 / smoothTime
  local x = omega * dt
  local exp = 1.0 / (1.0 + x + 0.48 * x ^ 2 + 0.235 * x ^ 3)
  local change = current - target
  local originalTo = target
  local maxChange = maxSpeed * smoothTime
  change = math.max(-maxChange, math.min(change, maxChange))
  target = current - change
  local temp = (velocity + omega * change) * dt
  velocity = (velocity - omega * temp) * exp
  local output = target + (change + temp) * exp
  if (originalTo - current > 0) == (output > originalTo) then
    output = originalTo
    velocity = (output - originalTo) / dt
  end
  return output, velocity
end

return utils

We can now import these in our init.lua file and create closures.

Creating closures to standardize function signature

local utils = require("camera.utils")

---@enum DampingMode
DampingMode = {
  STICKY = 1,
  LERP = 2,
  EXP_DECAY = 3,
  UNDER_DAMPED_SPRING = 4,
  CRITICAL_DAMPED_SPRING = 5,
  SMOOTH_DAMP = 6
}

function Camera:new()
  ...

  o.damping_mode = DampingMode.LERP
  o.damping_method = {
    [DampingMode.STICKY] = function(delta, velocity, dt)
      return delta, velocity
    end,
    [DampingMode.LERP] = function(delta, velocity, dt)
      return utils.lerp(0, delta, o.damping, dt), velocity
    end,
    [DampingMode.EXP_DECAY] = function(delta, velocity, dt)
      return utils.expDecay(0, delta, o.damping, dt), velocity
    end,
    [DampingMode.UNDER_DAMPED_SPRING] = function(delta, velocity, dt)
      return utils.underDampedSpring(delta, o.mass, o.stiffness, o.damping, velocity, dt)
    end,
    [DampingMode.CRITICAL_DAMPED_SPRING] = function(delta, velocity, dt)
      local damping = 2 * math.sqrt(o.stiffness * o.mass)
      return utils.underDampedSpring(delta, o.mass, o.stiffness, damping, velocity, dt)
    end,
    [DampingMode.SMOOTH_DAMP] = function(delta, velocity, dt)
      return utils.smoothDamp(0, delta, velocity, o.smoothTime, o.maxSpeed, dt)
    end
  }

  ...
end

---@param mode DampingMode
function Camera:setDampingMode(mode)
  self.damping_mode = mode
end

With this setup, we can go ahead an modify the Camera:update() method for calculating the steps and velocity in each update call.

Modified update method

function Camera:update(dt)
  ...

  local x_step, y_step
  x_step, self.velocity_x = self.damping_method[self.damping_mode](delta_x, self.velocity_x, dt)
  y_step, self.velocity_y = self.damping_method[self.damping_mode](delta_y, self.velocity_y, dt)

  ...
end

I prefer this setup over the if-else checks as it has a better runtime performance. It takes O(1) time to check the function because of using table lookups. This also separates the functions into a utils file which allows them to be used independently without creating a Camera instance. This setup will require a bit more memory per object instance as the closures would be created separately for each of them. But if there are not many cameras in use, as is the case usually, then that little extra memory usage is a fair trade-off for a more performant update loop.

With this, you can now go ahead and create a camera that follows a specific target! Next time, we will go over some more essential concepts like deadzones, look-ahead, and more.