L08 — Camera & projection
Goal
See the camera as “where my eyes are,” not as a mesh you draw. Learn the view transform (world → camera-relative) and get intuition for projection (the lens) before real 3D hardware.
A story that unlocks the whole lesson
You’re in a museum (the world). Statues stand still.
When you walk left, the statues appear to slide right on your retina. The statues didn’t move — you did.
Games do the same: we rarely animate the whole world backwards. We store a camera pose and compute:
“Where is this world point, relative to my eyes?”That relative position is view space. Drawing uses that.
What you will see (ROM)
source scripts/env.sh
make -C lessons/l08-camera| Thing | Meaning |
|---|---|
| Colored dots | Props fixed in world space |
| Red dot | World origin (0,0) |
| White reticle | Screen center = “where the camera is looking through” |
| Stick | Moves the camera, not the props |
| A | Toggle zoom (cheap stand-in for lens / FOV feel) |
Try this
Push stick left. Props slide right. That’s the museum story. You’re not failing math — the view transform is working.
View transform (2D version in this ROM)
We use a simple orthographic idea:
screen = (world - camera) * zoom + screen_center| Piece | Meaning |
|---|---|
world - camera | “Where is the prop relative to my eye?” |
* zoom | Bigger zoom → world looks larger (telephoto vibes) |
+ screen_center | Draw relative to the middle of the TV |
This is the translation part of a view matrix. Full 3D also rotates so “camera forward” matches looking down a standard axis; see ng_mat4_look_at later — same idea, more components.
You don’t draw the camera
The camera is numbers (position, orientation, lens). You draw world stuff transformed into view/screen. If you need a “camera mesh” for a cutscene, that’s just another model — not the mathematical camera.
Model vs view (tie to L07)
| Matrix | Answers |
|---|---|
| Model (M) | Where is this object in the world? |
| View (V) | How does the world look from the camera? |
Object path:
local --M--> world --V--> viewGameplay often moves either the player model, the camera, or both (third-person follow = camera depends on player).
Projection — the “lens” (intuition only)
So far L08 is basically a flat camera (orthographic-ish): no vanishing points.
| Kind | Feel | Examples |
|---|---|---|
| Orthographic | Distance doesn’t shrink objects; parallel lines stay parallel | Top-down strategy, many 2D games, UI |
| Perspective | Far things look smaller; has FOV | Mario 64, most 3D games |
Perspective needs extra work:
- FOV — field of view (wide angle vs zoom)
- Near / far planes — only draw a depth slice (too near or too far = clipped)
- A projection matrix P
Tiny3D Module 2 will set FOV/near/far on a viewport. Your job is choosing values that match your world scale (if near/far are wrong, things vanish or z-fight).
Frustum (vocab)
The visible volume of a perspective camera looks like a pyramid with the top cut off — the view frustum. “Outside the frustum” ≈ off-screen or clipped.
Full chain (map for later)
local --M--> world --V--> view --P--> clip --> hardware/screen
model camera lens| Stage | Lesson |
|---|---|
| M | L07 |
| V | L08 (2D now, 3D in Module 2) |
| P | Module 2 Tiny3D viewport |
Why moving the camera feels “inverted”
Students often say: “I added to camera.x but the world went the wrong way.”
Remember:
view = world - cameraIf camera.x increases, world - camera decreases → props shift left on screen when you “move right,” depending on axis signs. Match the museum story, not gut panic.
Common noob confusions
| Feeling | Reality |
|---|---|
| “I should move every object opposite the stick” | That’s emulating a camera the hard way; use a camera offset instead |
| “Zoom is FOV” | Related idea, not identical math — good enough intuition for now |
| “Camera is an object at the reticle” | Reticle is screen center; camera is a world pose |
| “look_at is magic” | It builds a view matrix from eye, target, and up — readable in ng_math.c when ready |
Exercises
- Move until the red origin sits under the white reticle. What’s your camera position roughly?
- Toggle zoom with A without moving — props grow/shrink around the view.
- Explain to a rubber duck why stick-left makes props slide right.
- (Stretch) Skim
ng_mat4_look_atcomments/code — identify eye, target, up.
Full lesson source
The blocks below are imported from the real repository files at build time (VitePress <<< snippets). They are not hand-copied into this markdown.
lessons/l08-camera/Makefile · lessons/l08-camera/src/main.c
lessons/l08-camera/Makefile
ROMNAME := l08_camera
ROM_TITLE := "L08 Camera"
USE_NG_MATH := 1
include ../../common/lesson.mklessons/l08-camera/src/main.c
/**
* L08 — Camera & projection (2D teaching analogue)
* ============================================================================
* Museum story: props stay put; you move the camera; props appear to slide.
* screen = (world - camera) * zoom + center
* Stick moves camera; A toggles zoom (lens intuition).
* DOCS: docs/guide/m1/l08-camera.md
*/
#include <libdragon.h>
#include <stdio.h>
#include "ng_math.h"
#define DEADZONE 8
#define NUM_PROPS 8
static int dz(int v)
{
return (v > -DEADZONE && v < DEADZONE) ? 0 : v;
}
static void draw_dot(float x, float y, color_t c, int r)
{
rdpq_set_mode_fill(c);
rdpq_fill_rectangle((int)x - r, (int)y - r, (int)x + r + 1, (int)y + r + 1);
}
int main(void)
{
display_init(RESOLUTION_320x240, DEPTH_16_BPP, 2, GAMMA_NONE,
FILTERS_RESAMPLE);
rdpq_init();
rdpq_text_register_font(1, rdpq_font_load_builtin(FONT_BUILTIN_DEBUG_VAR));
joypad_init();
/* World-space prop positions */
ng_vec2 props[NUM_PROPS] = {
ng_v2(0, 0), ng_v2(80, 0), ng_v2(-70, 40), ng_v2(40, -60),
ng_v2(-100, -30), ng_v2(120, 50), ng_v2(20, 90), ng_v2(-40, -100),
};
ng_vec2 cam = ng_v2(0, 0);
float zoom = 1.f;
char line[72];
while (1) {
joypad_poll();
joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);
joypad_buttons_t pressed = joypad_get_buttons_pressed(JOYPAD_PORT_1);
cam.x += (float)dz(in.stick_x) * 0.2f;
cam.y -= (float)dz(in.stick_y) * 0.2f;
if (pressed.a) {
zoom = (zoom < 1.5f) ? 1.8f : 1.f;
}
surface_t *disp = display_get();
rdpq_attach(disp, NULL);
rdpq_clear((color_t){ .r = 10, .g = 14, .b = 24, .a = 255 });
float cx = 160.f, cy = 120.f;
/* View: screen = (world - cam) * zoom + screen_center */
for (int i = 0; i < NUM_PROPS; i++) {
ng_vec2 w = props[i];
float sx = (w.x - cam.x) * zoom + cx;
float sy = (w.y - cam.y) * zoom + cy;
if (sx < -10 || sx > 330 || sy < -10 || sy > 250) {
continue;
}
color_t c = (i == 0) ? (color_t){ 255, 80, 80, 255 }
: (color_t){ 100, 200, 255, 255 };
draw_dot(sx, sy, c, (i == 0) ? 4 : 3);
}
/* Camera reticle at screen center */
draw_dot(cx, cy, (color_t){ 255, 255, 255, 255 }, 2);
rdpq_text_print(NULL, 1, 12, 12, "L08 — Camera (2D view)");
snprintf(line, sizeof(line), "cam=(%+.0f,%+.0f) zoom=%.1f", cam.x, cam.y, zoom);
rdpq_text_print(NULL, 1, 12, 28, line);
rdpq_text_print(NULL, 1, 12, 200, "Stick moves camera A toggles zoom");
rdpq_text_print(NULL, 1, 12, 216, "Red prop is world origin. Screen = (world-cam)*zoom");
rdpq_detach_show();
}
}What you learned
- Camera = pose + lens, not a required mesh
- View space = world relative to camera
- Perspective vs ortho at a gut level
- Where V sits between model and projection
Next
L09 — Meshes — what we actually draw: triangles and the data glued to their corners.