Skip to content

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:

text
“Where is this world point, relative to my eyes?”

That relative position is view space. Drawing uses that.

What you will see (ROM)

bash
source scripts/env.sh
make -C lessons/l08-camera
ThingMeaning
Colored dotsProps fixed in world space
Red dotWorld origin (0,0)
White reticleScreen center = “where the camera is looking through”
StickMoves the camera, not the props
AToggle 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:

text
screen = (world - camera) * zoom + screen_center
PieceMeaning
world - camera“Where is the prop relative to my eye?”
* zoomBigger zoom → world looks larger (telephoto vibes)
+ screen_centerDraw 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)

MatrixAnswers
Model (M)Where is this object in the world?
View (V)How does the world look from the camera?

Object path:

text
local --M--> world --V--> view

Gameplay 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.

KindFeelExamples
OrthographicDistance doesn’t shrink objects; parallel lines stay parallelTop-down strategy, many 2D games, UI
PerspectiveFar things look smaller; has FOVMario 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)

text
local  --M-->  world  --V-->  view  --P-->  clip  -->  hardware/screen
         model         camera        lens
StageLesson
ML07
VL08 (2D now, 3D in Module 2)
PModule 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:

text
view = world - camera

If 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

FeelingReality
“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

  1. Move until the red origin sits under the white reticle. What’s your camera position roughly?
  2. Toggle zoom with A without moving — props grow/shrink around the view.
  3. Explain to a rubber duck why stick-left makes props slide right.
  4. (Stretch) Skim ng_mat4_look_at comments/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
make
ROMNAME   := l08_camera
ROM_TITLE := "L08 Camera"
USE_NG_MATH := 1

include ../../common/lesson.mk
lessons/l08-camera/src/main.c
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.

N64 Educator v1.2.2 — libdragon + Tiny3D · branch master