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 View/Back 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: fly over the stairs and back, then carry on with third person.
input.define("view.tour", "Camera tour", "P")
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. From Lua it's view.shake(0.4); the explosion recipe uses it when it's there. The 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;
}

Next: gameplay.