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.
1. First person: eyes at the head (shooters, horror) |
2. Third person: over the shoulder, never through walls |
3. Orbit: circles the character (inspecting, editors) |
4. Top-down: from above (twin-stick shooters, puzzles) |
5. Isometric: high and at 45° (strategy, action RPGs) |
6. Side-on: from the side, moving left and right (platformers) |
7. Fixed: on the wall, turning to watch (survival horror) |
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:
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.settingshas its length, the shoulder offset, the lag and the field of view. - First person puts the camera at
settings.eyeHeightand turns the character to face where you look (m_loco->setFacing(m_rig.forward())). - Orbit and cinematic:
settings.orbitDistancefor 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:
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:
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:
// 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:
// 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:

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