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.
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: 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:
// 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;
}
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)