Skip to content

Cameras

The camera decides what kind of game it feels like. The cookbook game has the eight cameras most games use, and switches between them while you play: 1 to 8 pick one, Tab (or d-pad right on a pad) goes to the next, P plays a camera path, K shakes.

cmake --build build --target cookbook && cd build/bin && ./cookbook
First person 1. First person: eyes at the head (shooters, horror) Third person 2. Third person: over the shoulder, never through walls Orbit 3. Orbit: circles the character (inspecting, editors)
Top-down 4. Top-down: from above (twin-stick shooters, puzzles) Isometric 5. Isometric: high and at 45° (strategy, action RPGs) Side-on 6. Side-on: from the side, moving left and right (platformers)
Fixed 7. Fixed: on the wall, turning to watch (survival horror) Cinematic 8. Cinematic: a smooth path through keyframes (intros, cutscenes)

How each one is made

A camera is just two points: where it is (position) and what it looks at (target), plus a field of view. Every frame, the game sets them on app.camera(). Four of the eight are modes of kke::CameraRig (engine/include/kke/CameraRig.h); the other four are a few lines each. This is the whole of it, from games/cookbook/CookbookPlayer.cpp:

games/cookbook/CookbookPlayer.cpp
    const glm::vec3 chest = feet + glm::vec3(0.0f, 1.2f, 0.0f);
    switch (m_view) {
    case View::First:
    case View::Third:
    case View::Orbit:
    case View::Cinematic:
        // kke::CameraRig does these four: eyes, a spring arm that never
        // goes through walls, an orbit, and a smooth keyframed path.
        m_rig.update(dt, feet, ray, cam);
        break;
    case View::TopDown:
        // Straight down, a little tilted so walls still read as walls.
        cam.position = feet + glm::vec3(0.0f, 11.0f, 2.0f);
        cam.target = feet;
        break;
    case View::Iso:
        // High, far and at 45 degrees, with a narrow lens: almost no
        // perspective, the look of isometric games.
        cam.position = feet + glm::vec3(9.0f, 11.0f, 9.0f);
        cam.target = feet;
        cam.fovDegrees = 30.0f;
        break;
    case View::Side:
        // Beside the level, looking along -Z; the stick only goes left
        // and right (moveForward() is zero), like a platformer.
        cam.position = glm::vec3(feet.x, chest.y + 1.0f, feet.z + 12.0f);
        cam.target = glm::vec3(feet.x, chest.y, feet.z);
        break;
    case View::Fixed:
        // Bolted to the wall, turning to watch the player.
        cam.position = fixedCameraAt;
        cam.target = chest;
        break;
    case View::Count: break;
    }
    if (m_view == View::TopDown || m_view == View::Side || m_view == View::Fixed) cam.fovDegrees = 50.0f;
  • Third person is a spring arm, like Unreal's: a line from above the character's shoulders back to the camera. When something's in the way the arm shortens at once (you never see through a wall) and grows back smoothly. m_rig.settings has its length, the shoulder offset, the lag and the field of view.
  • First person puts the camera at settings.eyeHeight and turns the character to face where you look (m_loco->setFacing(m_rig.forward())).
  • Orbit and cinematic: settings.orbitDistance for the first, m_rig.setCinematic(keyframes, loop) for the second.

Which way is forward?

With a camera that isn't behind the character, "push the stick up" has to mean something else. In top-down, up on the stick is up on the screen; in side-on, only left and right exist; with a fixed camera, up is away from the camera. The cookbook's player works out forward per camera and moves the character relative to that:

games/cookbook/CookbookPlayer.cpp
glm::vec3 CookbookPlayer::moveForward() const {
    switch (m_view) {
    case View::TopDown: return { 0.0f, 0.0f, -1.0f };                               // up on the screen
    case View::Iso: return glm::normalize(glm::vec3(-1.0f, 0.0f, -1.0f));          // away from the camera
    case View::Side: return { 0.0f, 0.0f, 0.0f };                                   // only left and right
    case View::Fixed: {                                                             // away from the wall camera
        glm::vec3 f = m_rigid->world().characterPosition(m_player) - fixedCameraAt;
        f.y = 0.0f;
        return glm::length(f) > 1e-3f ? glm::normalize(f) : glm::vec3(0, 0, -1);
    }
    default: return m_rig.forward();
    }
}

Cameras from Lua

The cookbook game gives scripts a view table: view.mode(name), view.shake(amount) and view.path(keyframes, loop). Its scripts/cameras.lua binds the number keys to them:

games/cookbook/scripts/cameras.lua
local cameras = { "first", "third", "orbit", "topdown", "iso", "side", "fixed", "cinematic" }
for i, name in ipairs(cameras) do
  input.define("view." .. name, name .. " camera", tostring(i)) -- keys 1 to 8
end

hook.Add("Think", "cameras.keys", function()
  for _, name in ipairs(cameras) do
    if input.pressed("view." .. name) then
      view.mode(name)
      print("camera: " .. name)
    end
  end
end)

A camera path is a list of keyframes, each a position, a point to look at and a time in seconds; the camera glides through them on a smooth curve (Catmull-Rom):

-- P or the d-pad up: fly over the stairs and back, then carry on with
-- third person.
input.define("view.tour", "Camera tour", "P", "dpad_up")
hook.Add("Think", "cameras.tour", function()
  if input.pressed("view.tour") then
    view.path({
      { pos = Vec(-12, 3, 4), target = Vec(-6, 1, -4), time = 0 },
      { pos = Vec(-8, 5, -1), target = Vec(-6, 2, -6), time = 3 },
      { pos = Vec(-1, 4, -3), target = Vec(-6, 1, -6), time = 6 },
    }, false)
    timer.Simple(6.5, function() view.mode("third") end)
  end
end)

view isn't part of the engine: it's how any game can hand its own C++ to Lua. The bindings are about thirty lines in CookbookPlayer::registerLua, shown in C++.

Screen shake

Shake sells an explosion or a heavy landing. Add "trauma" (0 to 1) when something happens; each frame, turn the camera by a small smooth wobble that grows with trauma squared (so little bumps stay little), and let trauma fade. In any game, Lua's camera.shake(strength, seconds) does this on top of whatever camera is on (the explosion recipe uses it); the cookbook game also has its own view.shake(0.4), to show how it's done. Its C++, on top of whichever camera is on:

games/cookbook/CookbookPlayer.cpp
    // Shake on top of whichever camera: turn the view by a small, smooth
    // wobble that fades as the trauma does (Procedural.h).
    if (m_trauma > 0.0f) {
        const glm::vec3 wobble = shakeOffset(m_trauma, m_time);
        const glm::vec3 look = cam.target - cam.position;
        const glm::vec3 side = glm::normalize(glm::cross(look, glm::vec3(0, 1, 0)));
        glm::mat4 turn = glm::rotate(glm::mat4(1.0f), glm::radians(wobble.x), glm::vec3(0, 1, 0));
        turn = glm::rotate(turn, glm::radians(wobble.y), side);
        cam.target = cam.position + glm::vec3(turn * glm::vec4(look, 0.0f));
        cam.up = glm::vec3(glm::rotate(glm::mat4(1.0f), glm::radians(wobble.z), glm::normalize(look)) * glm::vec4(0, 1, 0, 0));
        m_trauma = std::max(0.0f, m_trauma - 0.9f * dt); // gone in about a second
    } else {
        cam.up = glm::vec3(0, 1, 0);
    }

The wobble itself is smooth noise, not random jumps:

games/cookbook/Procedural.h
// Camera shake from "trauma" (0..1, after Squirrel Eiserloh's GDC talk):
// the offset grows with trauma squared, so small bumps stay small, and it
// is smooth noise (a few sines at unrelated rates), not random jumps.
// Returns yaw, pitch and roll in degrees.
inline glm::vec3 shakeOffset(float trauma, float time, float maxDegrees = 6.0f) {
    const float t = std::clamp(trauma, 0.0f, 1.0f);
    const float amount = t * t * maxDegrees;
    auto noise = [time](float a, float b, float c) {
        return (std::sin(time * a) + std::sin(time * b + 1.3f) * 0.6f + std::sin(time * c + 4.1f) * 0.3f) / 1.9f;
    };
    return glm::vec3(noise(23.0f, 37.0f, 61.0f), noise(29.0f, 43.0f, 53.0f), noise(19.0f, 31.0f, 47.0f)) * amount;
}

Cutscenes in any game

view belongs to the cookbook game. Every game has the engine's camera.* for cutscenes: camera.path flies through a list of shots and blends in from (and back to) whatever camera the game has, so the player's own camera code keeps running underneath. camera.hold parks the camera on one shot until camera.release, and the CameraDone hook says when a path is over. Press C:

A fly-around of a crate tower

cutscene.lua
-- cutscene.lua: a fly-around of a crate tower that ends on a close-up and
-- holds it. C plays it again, or (while it's on) gives the camera back.
-- (docs/cookbook/cameras.md)

input.define("cutscene", "Cutscene", "C")

local tower = Vec(0, 0, 8)
for i = 0, 5 do
  local shade = 0.5 + i * 0.08
  physics.box { pos = tower + Vec(0, 0.5 + i, 0), size = Vec(1, 1, 1), color = Vec(shade, 0.35, 0.2), static = true }
end
local top = tower + Vec(0, 5.5, 0)

local function flyAround()
  local shots = {}
  for i = 0, 8 do
    local a = i / 8 * math.pi * 1.5      -- three quarters of the way round,
    local height = 6 - i * 0.4           -- coming down as it goes
    shots[#shots + 1] = { pos = tower + Vec(math.cos(a) * 9, height, math.sin(a) * 9), look = tower + Vec(0, 3, 0) }
  end
  -- The close-up: near the top, zoomed in.
  shots[#shots + 1] = { pos = top + Vec(2.5, 0.5, -2.5), look = top, fov = 35 }
  camera.path(shots, { seconds = 8, blend = 1, hold = true })
end

hook.Add("Init", "cutscene.start", flyAround)

hook.Add("Think", "cutscene.keys", function()
  if input.pressed("cutscene") then
    if camera.directing() then camera.release(1) else flyAround() end
  end
end)

hook.Add("CameraDone", "cutscene.done", function()
  kke.log("the fly-around is over: C gives the camera back")
end)

Download cutscene.lua

Shots without a t are spread evenly over seconds. A shot's fov zooms (35 here for the close-up); without one the game's own is kept. The whole API is in Scripting and kke/CameraDirector.h.

Next: gameplay.