Skip to content

L07 — Matrices & transforms

Goal

Learn that translate, rotate, and scale can be stored as matrices, combined into one model matrix, and used to place local geometry into the world — without rewriting every corner by hand.

Scary word check

A matrix here is just a grid of numbers the computer multiplies with a point to get a new point. You do not need to multiply 4×4 grids on paper. We use helpers.

The everyday problem

Your square’s corners are defined once, nicely centered at the origin:

text
(-0.5, -0.5), (0.5, -0.5), (0.5, 0.5), (-0.5, 0.5)

In the game you want that square:

  • Somewhere else (translate)
  • Spun (rotate)
  • Bigger or smaller (scale)

You could write special code for each corner every frame. Instead we build one transform M and do:

text
world_point = M × local_point

for each corner. Same idea later for thousands of mesh vertices.

What you will see (ROM)

bash
source scripts/env.sh
make -C lessons/l07-matrices

A square you can move, spin, and resize. The white dot is the translation pivot (where the object’s origin sits in the world).

InputEffect
StickTranslate (move the square)
L / RRotate
C-up / C-downScale up / down

Three basic transforms (stories)

Translate — “put it over there”

Add an offset to every point: “same shape, new place.”

Rotate — “spin around the origin”

In this lesson we rotate around Z (the axis sticking out of the 2D screen). The square turns in place around its local center before we slide it away — because of composition order (below).

Scale — “make it bigger/smaller”

Multiply coordinates by a size. Scale of 40 means our 1×1 local square becomes 40×40 pixels-ish on screen.


Why call it a matrix?

Hardware and engines (Tiny3D included) almost always want a 4×4 matrix for object placement. One matrix can hold rotate + scale + translate together. One multiply per point is a regular, optimizable pattern.

You can think:

text
matrix = a reusable machine
input  = local point
output = world point

Column-major, M × v (course convention)

We store matrices column-major and multiply matrix × vector. You only need to remember:

  • Helpers like ng_mat4_mul_point do the multiply.
  • When we compose matrices, order matters a lot.

Composition: T × R × S (read right-to-left on the point)

The ROM builds:

text
M = T × R × S

For a point, apply right-most first:

  1. S — scale in local space
  2. R — rotate around local origin
  3. T — translate into the world

Story: “Size the toy, spin the toy, then place the toy on the table.”

Order matters a lot

T × RR × T.

  • Rotate then move: object spins around its center, then slides.
  • Move then rotate: object can orbit around the world origin like a planet.

If something “orbits when I only wanted it to spin,” your order or pivot is wrong. This bites everyone once.

Local vs world (two coordinate systems)

SpacePlain English
Local / modelCoordinates as you authored the shape (center at 0 is nice)
WorldCoordinates in the level — after the model matrix

Art assets almost always live in local space. Gameplay asks “where in the level?” → world space.


The bigger pipeline (preview only)

Eventually:

text
local  --M-->  world  --V-->  view  --P-->  clip/screen
         model         camera        lens
  • L07 = M (model)
  • L08 = V (view / camera)
  • Module 2 = P for real on the 3D hardware

You don’t need all of this today — just know M is only the first stage.


Course API (copy-paste level)

c
ng_mat4 M;
ng_mat4_trs_z(&M,
    ng_v3(tx, ty, 0.f),   /* translate */
    rot_radians,          /* rotate about Z */
    ng_v3(s, s, 1.f));    /* scale */

ng_vec3 world = ng_mat4_mul_point(&M, local);

ng_mat4_trs_z builds that T × R × S for 2D-ish demos.


Walk the ROM mentally

  1. Four local corners of a unit square.
  2. Each frame, read stick / shoulders → update tx, ty, rot, scale.
  3. Build M.
  4. Transform each corner to world.
  5. Draw edges between world corners.

You’re not “drawing a matrix.” You’re drawing points that the matrix moved for you.


Common noob confusions

FeelingReality
“I must learn to multiply matrices by hand”No — understand what T/R/S do and order
“Scale then rotate vs rotate then scale”Non-uniform scale + rotate can shear; we use uniform scale
“Pivot is wrong”Rotation is around local origin (0,0); center your mesh on origin in Blender later
“Matrix is 4×4 but we’re in 2D”3D APIs still use 4×4; z=0 is fine for teaching

Exercises

  1. Only use stick (no L/R). Can you place the square in each screen corner?
  2. Spin with L/R while scale is large — notice rotation is around the white center.
  3. Read the one-line call to ng_mat4_trs_z in main.c and match args to T / R / S.
  4. (Stretch) Draw a second square with a fixed offset in local space (a “child” mental model).

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/l07-matrices/Makefile · lessons/l07-matrices/src/main.c

lessons/l07-matrices/Makefile
make
ROMNAME   := l07_matrices
ROM_TITLE := "L07 Matrices"
USE_NG_MATH := 1

include ../../common/lesson.mk
lessons/l07-matrices/src/main.c
c
/**
 * L07 — Matrices & transforms
 * ============================================================================
 * Local square corners → model matrix M = T * R * S → world corners.
 * Stick translates, L/R rotate, C-up/dn scale.
 * White dot = object origin. Order matters: scale, then rotate, then translate.
 * DOCS: docs/guide/m1/l07-matrices.md
 */


#include <libdragon.h>
#include <stdio.h>
#include "ng_math.h"

#define DEADZONE 8

static int dz(int v)
{
    return (v > -DEADZONE && v < DEADZONE) ? 0 : v;
}

static void draw_dot(float x, float y, color_t c)
{
    rdpq_set_mode_fill(c);
    rdpq_fill_rectangle((int)x - 2, (int)y - 2, (int)x + 3, (int)y + 3);
}

static void draw_line(float x0, float y0, float x1, float y1, color_t c)
{
    float dx = x1 - x0, dy = y1 - y0;
    float len = sqrtf(dx * dx + dy * dy);
    int steps = (int)(len / 2.5f);
    if (steps < 1) {
        steps = 1;
    }
    rdpq_set_mode_fill(c);
    for (int i = 0; i <= steps; i++) {
        float t = (float)i / (float)steps;
        int x = (int)(x0 + dx * t);
        int y = (int)(y0 + dy * t);
        rdpq_fill_rectangle(x, y, x + 2, y + 2);
    }
}

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();

    float tx = 160.f, ty = 120.f;
    float rot = 0.f;
    float scale = 40.f;
    char line[80];

    /* Local-space corners of a square centered at origin */
    const ng_vec3 local[4] = {
        ng_v3(-0.5f, -0.5f, 0.f),
        ng_v3(0.5f, -0.5f, 0.f),
        ng_v3(0.5f, 0.5f, 0.f),
        ng_v3(-0.5f, 0.5f, 0.f),
    };

    while (1) {
        joypad_poll();
        joypad_inputs_t in = joypad_get_inputs(JOYPAD_PORT_1);

        tx += (float)dz(in.stick_x) * 0.15f;
        ty -= (float)dz(in.stick_y) * 0.15f;
        if (in.btn.l) {
            rot -= 0.04f;
        }
        if (in.btn.r) {
            rot += 0.04f;
        }
        if (in.btn.c_up) {
            scale += 0.5f;
        }
        if (in.btn.c_down) {
            scale -= 0.5f;
        }
        if (scale < 10.f) {
            scale = 10.f;
        }
        if (scale > 90.f) {
            scale = 90.f;
        }

        ng_mat4 M;
        ng_mat4_trs_z(&M, ng_v3(tx, ty, 0.f), rot, ng_v3(scale, scale, 1.f));

        ng_vec3 world[4];
        for (int i = 0; i < 4; i++) {
            world[i] = ng_mat4_mul_point(&M, local[i]);
        }

        surface_t *disp = display_get();
        rdpq_attach(disp, NULL);
        rdpq_clear((color_t){ .r = 12, .g = 18, .b = 22, .a = 255 });

        color_t edge = { 100, 220, 160, 255 };
        for (int i = 0; i < 4; i++) {
            int j = (i + 1) % 4;
            draw_line(world[i].x, world[i].y, world[j].x, world[j].y, edge);
            draw_dot(world[i].x, world[i].y, (color_t){ 255, 220, 80, 255 });
        }
        draw_dot(tx, ty, (color_t){ 255, 255, 255, 255 });

        rdpq_text_print(NULL, 1, 12, 12, "L07 — Matrices (T * R * S)");
        snprintf(line, sizeof(line), "T=(%.0f,%.0f)  rot=%.0f deg  S=%.0f",
                 tx, ty, ng_rad_to_deg(rot), scale);
        rdpq_text_print(NULL, 1, 12, 28, line);
        rdpq_text_print(NULL, 1, 12, 210, "Stick: translate  L/R: rotate  C-up/dn: scale");

        rdpq_detach_show();
    }
}

What you learned

  • Model matrix places local geometry in the world
  • Translate, rotate, scale as stories + one combined M
  • Composition order: S then R then T for our TRS helper
  • Local vs world

Next

L08 — Camera — moving the eyes, not only the objects.

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