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:
(-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:
world_point = M × local_pointfor each corner. Same idea later for thousands of mesh vertices.
What you will see (ROM)
source scripts/env.sh
make -C lessons/l07-matricesA square you can move, spin, and resize. The white dot is the translation pivot (where the object’s origin sits in the world).
| Input | Effect |
|---|---|
| Stick | Translate (move the square) |
| L / R | Rotate |
| C-up / C-down | Scale 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:
matrix = a reusable machine
input = local point
output = world pointColumn-major, M × v (course convention)
We store matrices column-major and multiply matrix × vector. You only need to remember:
- Helpers like
ng_mat4_mul_pointdo the multiply. - When we compose matrices, order matters a lot.
Composition: T × R × S (read right-to-left on the point)
The ROM builds:
M = T × R × SFor a point, apply right-most first:
- S — scale in local space
- R — rotate around local origin
- T — translate into the world
Story: “Size the toy, spin the toy, then place the toy on the table.”
Order matters a lot
T × R ≠ R × 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)
| Space | Plain English |
|---|---|
| Local / model | Coordinates as you authored the shape (center at 0 is nice) |
| World | Coordinates 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:
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)
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
- Four local corners of a unit square.
- Each frame, read stick / shoulders → update
tx, ty, rot, scale. - Build M.
- Transform each corner to world.
- Draw edges between world corners.
You’re not “drawing a matrix.” You’re drawing points that the matrix moved for you.
Common noob confusions
| Feeling | Reality |
|---|---|
| “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
- Only use stick (no L/R). Can you place the square in each screen corner?
- Spin with L/R while scale is large — notice rotation is around the white center.
- Read the one-line call to
ng_mat4_trs_zinmain.cand match args to T / R / S. - (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
ROMNAME := l07_matrices
ROM_TITLE := "L07 Matrices"
USE_NG_MATH := 1
include ../../common/lesson.mklessons/l07-matrices/src/main.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.