Part 1 — Your First Pixels | Prerequisites: Chapter 1 (matrices), Chapter 5 (p5.js) | Difficulty: Beginner to Intermediate | Language: JavaScript
In Chapter 1 you learned that a matrix can represent a transformation — rotation, scaling, translation. You multiplied matrices on paper and understood that combining transformations means multiplying their matrices together. In Chapter 5 you drew shapes at specific pixel coordinates. Now we connect those two worlds: you'll use translate(), rotate(), and scale() to move the entire coordinate system, and you'll nest these transforms to build hierarchical structures — a solar system where moons orbit planets that orbit a sun, a robot arm where the forearm moves with the upper arm.
This is not just a graphics trick. Scene graphs — hierarchies of transforms — are the core data structure of every game engine, every 3D modeling tool, every animation system, and every UI framework. Unity's Transform component, CSS's transform property, SVG's transform attribute, Blender's object hierarchy — they all work this way. When you move a parent object, its children move with it automatically. That behavior comes from multiplying transformation matrices in a parent-child chain — exactly what you'll implement in this chapter.
By the end, you'll have a working scene graph, animated sprites, and a 2D camera. The math from Part 0 becomes real.
translate(),rotate(),scale()in p5.js — what they actually do to the coordinate systempush()andpop()— saving and restoring the transformation state- The matrix stack: how nested transforms compose
- Scene graphs: parent-child hierarchies
- Sprite animation with transforms
- 2D camera / viewport
- How Unity Does This: RectTransform, Canvas, 2D rendering pipeline
- Chapter 1 — Matrices and Transforms (the underlying math)
- Chapter 2 — Coordinate Spaces and the View Pipeline (3D version of this)
- Chapter 5 — Drawing with Code (p5.js basics)
- Chapter 11 — Vertex Processing and Transforms (3D pipeline)
- Chapter 31 — Animation Fundamentals (keyframing transforms)
In Chapter 5, you drew a rectangle at (200, 150):
rect(200, 150, 60, 40);There's another way to think about this. Instead of telling the rectangle where to go, you can move the entire coordinate system to (200, 150), then draw the rectangle at (0, 0):
translate(200, 150);
rect(0, 0, 60, 40);Same result. Why would you ever do this? Because rotation and scaling only work relative to the origin. If you want to rotate a rectangle around its own center, you need to:
- Move the origin to the rectangle's center
- Rotate
- Draw the rectangle centered at (0, 0)
function setup() {
createCanvas(600, 400);
rectMode(CENTER);
}
function draw() {
background(20);
// Rotate around own center
translate(300, 200); // Move origin to where we want the rect
rotate(frameCount * 0.02); // Rotate around that point
fill(255, 100, 50);
rect(0, 0, 100, 60); // Draw at origin (which is now at 300, 200)
}What happens if you put the rotate BEFORE the translate?
The rectangle orbits around the canvas origin (0, 0) — the top-left corner — in a wide arc. That's because rotate rotates the entire coordinate system around wherever the origin currently is. Order matters.
translate then rotatemeans "go there, then spin in place."rotate then translatemeans "spin the whole coordinate system, then walk forward in the rotated direction."
Moves the origin. Everything drawn after this is shifted by (x, y):
function draw() {
background(20);
// No transform — drawn at canvas coordinates
fill(100);
rect(50, 50, 60, 40);
// Translated — drawn relative to new origin
translate(200, 100);
fill(255, 0, 0);
rect(0, 0, 60, 40); // This appears at (200, 100)
rect(80, 0, 60, 40); // This appears at (280, 100)
}Rotates the coordinate system around the current origin. Angles are in radians:
function draw() {
background(20);
fill(0, 200, 255);
rectMode(CENTER);
translate(300, 200); // Move origin to center
rotate(PI / 6); // Rotate 30 degrees
rect(0, 0, 150, 50); // Drawn along the rotated axes
}Scales the coordinate system. scale(2) makes everything twice as big. scale(0.5) makes it half:
function draw() {
background(20);
fill(0, 255, 100);
translate(300, 200);
scale(2); // Everything is now 2x
rect(-25, -25, 50, 50); // A 50x50 rect drawn at 2x = 100x100 on screen
}scale(-1, 1) mirrors horizontally. scale(1, -1) mirrors vertically. This is how sprite flipping works in 2D games.
Each transform modifies the current state. They stack:
translate(100, 0); // Move right 100
translate(0, 50); // Move down 50
// Origin is now at (100, 50)
rotate(PI / 4); // Rotate 45 degrees around (100, 50)
scale(2); // Scale 2x around (100, 50)
// Everything drawn now is at (100,50), rotated 45°, scaled 2xThis is matrix multiplication under the hood. Each call multiplies a new matrix onto the current transformation matrix:
currentMatrix = currentMatrix * translateMatrix(100, 0)
currentMatrix = currentMatrix * translateMatrix(0, 50)
currentMatrix = currentMatrix * rotateMatrix(PI/4)
currentMatrix = currentMatrix * scaleMatrix(2)
Remember from Chapter 1: matrix multiplication is not commutative. A * B =/= B * A. The order of your transform calls matters.
Here's the problem: transforms accumulate and affect everything drawn after them. How do you draw one object with its own transforms, then draw another object without the first object's transforms leaking in?
push() saves the current transformation state. pop() restores it.
function draw() {
background(20);
rectMode(CENTER);
// --- Object A: spinning red square ---
push();
translate(200, 200);
rotate(frameCount * 0.03);
fill(255, 0, 0);
rect(0, 0, 80, 80);
pop(); // Transforms reset to what they were before push()
// --- Object B: spinning blue square (independent) ---
push();
translate(400, 200);
rotate(-frameCount * 0.02);
fill(0, 100, 255);
rect(0, 0, 80, 80);
pop();
}Without push()/pop(), Object B's transforms would stack on top of Object A's, and it would orbit around Object A instead of spinning independently.
The name "matrix stack" comes from the implementation: push() pushes a copy of the current matrix onto a stack, and pop() pops it off. This is exactly how OpenGL's old glPushMatrix()/glPopMatrix() worked. The concept is universal.
In p5.js, push() and pop() save and restore not just transforms but also fill, stroke, and other drawing styles:
push();
fill(255, 0, 0);
stroke(255);
strokeWeight(5);
translate(100, 100);
rect(0, 0, 50, 50); // Red with thick white stroke
pop();
rect(200, 200, 50, 50); // Back to whatever fill/stroke was before push()Here's where it gets powerful. You can nest push()/pop() blocks inside each other, and each level inherits the parent's transforms:
let sunAngle = 0;
let earthAngle = 0;
let moonAngle = 0;
function setup() {
createCanvas(600, 600);
}
function draw() {
background(10);
noStroke();
sunAngle += 0.005;
earthAngle += 0.02;
moonAngle += 0.05;
translate(300, 300); // Move to center of canvas
// --- SUN ---
push();
rotate(sunAngle);
fill(255, 200, 50);
circle(0, 0, 80);
// --- EARTH (orbits the sun) ---
push();
rotate(earthAngle);
translate(180, 0); // Move out from sun along rotated x-axis
fill(50, 150, 255);
circle(0, 0, 30);
// --- MOON (orbits the earth) ---
push();
rotate(moonAngle);
translate(40, 0);
fill(200);
circle(0, 0, 10);
pop();
pop();
// --- MARS (also orbits the sun, independent of earth) ---
push();
rotate(earthAngle * 0.5); // Slower orbit
translate(280, 0);
fill(255, 100, 50);
circle(0, 0, 20);
pop();
pop();
}Let's trace what happens to the Moon's position:
Start at canvas center (300, 300) — translate(300, 300)
Rotate by sunAngle — rotate(sunAngle)
Rotate by earthAngle — rotate(earthAngle)
Move 180 pixels along x-axis — translate(180, 0)
Rotate by moonAngle — rotate(moonAngle)
Move 40 pixels along x-axis — translate(40, 0)
Draw at (0, 0) — which is now wherever all those transforms put us
The Moon's final position is the result of all transforms in the chain. It orbits Earth, which orbits the Sun, which sits at the center. Move the Sun — everything moves with it. Move Earth — the Moon moves too, but Mars doesn't. This is the essence of a scene graph.
A scene graph is a tree data structure where each node has a transform, and child transforms are applied on top of parent transforms. Let's formalize it:
class SceneNode {
constructor(name) {
this.name = name;
this.x = 0;
this.y = 0;
this.rotation = 0;
this.scaleX = 1;
this.scaleY = 1;
this.children = [];
this.drawFn = null; // Custom drawing function
}
addChild(child) {
this.children.push(child);
return child;
}
render() {
push();
// Apply this node's transform
translate(this.x, this.y);
rotate(this.rotation);
scale(this.scaleX, this.scaleY);
// Draw this node
if (this.drawFn) {
this.drawFn(this);
}
// Render children (they inherit our transform)
for (let child of this.children) {
child.render();
}
pop();
}
}let root, body, upperArm, forearm, hand;
function setup() {
createCanvas(600, 400);
rectMode(CENTER);
// Build the robot arm hierarchy
root = new SceneNode("root");
root.x = 200;
root.y = 300;
body = root.addChild(new SceneNode("body"));
body.drawFn = () => {
fill(100);
rect(0, -40, 60, 80);
};
upperArm = body.addChild(new SceneNode("upperArm"));
upperArm.x = 30; // Shoulder position relative to body
upperArm.y = -60;
upperArm.drawFn = () => {
fill(150, 50, 50);
rect(40, 0, 80, 20); // Draw arm extending to the right
};
forearm = upperArm.addChild(new SceneNode("forearm"));
forearm.x = 80; // Elbow position relative to upper arm
forearm.drawFn = () => {
fill(50, 150, 50);
rect(35, 0, 70, 16);
};
hand = forearm.addChild(new SceneNode("hand"));
hand.x = 70; // Wrist position relative to forearm
hand.drawFn = () => {
fill(50, 50, 150);
circle(0, 0, 20);
};
}
function draw() {
background(20);
// Animate
upperArm.rotation = sin(frameCount * 0.03) * 0.5;
forearm.rotation = sin(frameCount * 0.05 + 1) * 0.8;
hand.rotation = sin(frameCount * 0.08) * 0.3;
root.render();
// UI
fill(255);
noStroke();
textSize(12);
text("Robot arm — each segment inherits parent's transform", 10, 20);
text("Upper arm rotates → forearm and hand follow", 10, 40);
}The forearm doesn't need to know where the upper arm is in world space. It only knows its position relative to its parent (the upper arm). The scene graph computes the final position by composing all transforms up the chain. This is the same pattern you'll see in Chapter 2's view pipeline, Chapter 11's vertex processing, and Chapter 32's skeletal animation.
Every node in a scene graph has a local transform (position/rotation/scale relative to its parent) and a world transform (its final position/rotation/scale in global coordinates).
The world transform of a node is the product of all transforms from the root to that node:
worldTransform(hand) = root.transform * body.transform * upperArm.transform
* forearm.transform * hand.transform
To get a node's world position (useful for collision detection, drawing debug info, etc.):
class SceneNode {
// ... previous code ...
getWorldPosition() {
// Walk up the parent chain, accumulating transforms
// In a real engine you'd cache this
let worldX = 0;
let worldY = 0;
let worldRot = 0;
let chain = [];
let node = this;
while (node) {
chain.unshift(node);
node = node.parent;
}
for (let n of chain) {
// Rotate the current offset by accumulated rotation
let cos_r = cos(worldRot);
let sin_r = sin(worldRot);
let rx = n.x * cos_r - n.y * sin_r;
let ry = n.x * sin_r + n.y * cos_r;
worldX += rx;
worldY += ry;
worldRot += n.rotation;
}
return { x: worldX, y: worldY, rotation: worldRot };
}
}A classic technique in 2D games: instead of drawing complex shapes, load images (sprites) and use transforms to position, rotate, and flip them.
let shipImg;
function preload() {
// In a real project you'd load an actual image
// shipImg = loadImage('ship.png');
}
function setup() {
createCanvas(600, 400);
// Create a simple "ship" as a graphics buffer (substitute for an image)
shipImg = createGraphics(40, 40);
shipImg.fill(200, 200, 255);
shipImg.noStroke();
shipImg.triangle(20, 0, 0, 40, 40, 40);
}
let shipX = 300, shipY = 200;
let shipAngle = 0;
let shipSpeed = 0;
function draw() {
background(10, 10, 30);
// Controls
if (keyIsPressed) {
if (keyCode === LEFT_ARROW) shipAngle -= 0.05;
if (keyCode === RIGHT_ARROW) shipAngle += 0.05;
if (keyCode === UP_ARROW) shipSpeed = min(shipSpeed + 0.1, 5);
}
shipSpeed *= 0.98; // Friction
// Update position using angle (trig from Chapter 3!)
shipX += cos(shipAngle - HALF_PI) * shipSpeed;
shipY += sin(shipAngle - HALF_PI) * shipSpeed;
// Wrap around screen
shipX = (shipX + width) % width;
shipY = (shipY + height) % height;
// Draw ship with transforms
push();
translate(shipX, shipY);
rotate(shipAngle);
imageMode(CENTER);
image(shipImg, 0, 0);
pop();
// Draw thrust particles when accelerating
if (keyIsPressed && keyCode === UP_ARROW) {
push();
translate(shipX, shipY);
rotate(shipAngle);
// Particles behind the ship (negative y in ship's local space)
for (let i = 0; i < 3; i++) {
let px = random(-5, 5);
let py = random(15, 30);
fill(255, 200, 50, random(100, 255));
noStroke();
circle(px, py, random(2, 6));
}
pop();
}
// UI
fill(255);
noStroke();
textSize(12);
text("Arrow keys to steer. Up = thrust.", 10, 20);
}Notice how the thrust particles are drawn in the ship's local coordinate space — they use the same translate/rotate as the ship. The particles appear behind the ship regardless of which direction it faces, because "behind" is just positive y in the ship's local space.
For frame-by-frame animation (walking characters, explosions), you'd use a sprite sheet — one image containing all frames in a grid:
let spriteSheet;
let frameW = 64, frameH = 64;
let currentFrame = 0;
let totalFrames = 8;
let animSpeed = 0.15;
function draw() {
background(20);
currentFrame += animSpeed;
let frame = floor(currentFrame) % totalFrames;
// Source rectangle on the sprite sheet
let sx = frame * frameW;
let sy = 0;
push();
translate(300, 200);
scale(2); // Draw at 2x size
// image(img, dx, dy, dw, dh, sx, sy, sw, sh)
// This draws a sub-rectangle of the source image
if (spriteSheet) {
image(spriteSheet, -frameW/2, -frameH/2, frameW, frameH, sx, sy, frameW, frameH);
}
pop();
}In a game or visualization, you often want to "scroll" the view — move a camera around a world that's larger than the screen. A 2D camera is simply an inverse transform applied before drawing everything:
let camera = { x: 0, y: 0, zoom: 1 };
function setup() {
createCanvas(800, 600);
}
function draw() {
background(20);
// Camera controls
if (keyIsPressed) {
let camSpeed = 5 / camera.zoom;
if (key === 'w') camera.y -= camSpeed;
if (key === 's') camera.y += camSpeed;
if (key === 'a') camera.x -= camSpeed;
if (key === 'd') camera.x += camSpeed;
}
// Zoom with mouse wheel (handled in mouseWheel event)
// --- Apply camera transform ---
push();
// Order matters: translate to center, scale, then translate by camera offset
translate(width / 2, height / 2);
scale(camera.zoom);
translate(-camera.x, -camera.y);
// --- Draw world ---
drawWorld();
pop();
// --- Draw UI (not affected by camera) ---
fill(255);
noStroke();
textSize(14);
text(`Camera: (${floor(camera.x)}, ${floor(camera.y)})`, 10, 20);
text(`Zoom: ${camera.zoom.toFixed(2)}x`, 10, 40);
text("WASD to pan, scroll to zoom", 10, 60);
}
function drawWorld() {
// Grid
stroke(40);
strokeWeight(1 / camera.zoom); // Keep grid lines thin regardless of zoom
for (let x = -1000; x <= 1000; x += 100) {
line(x, -1000, x, 1000);
}
for (let y = -1000; y <= 1000; y += 100) {
line(-1000, y, 1000, y);
}
// Some objects in world space
noStroke();
fill(255, 100, 50);
circle(0, 0, 40); // Object at world origin
fill(50, 200, 100);
rect(200, -100, 80, 80);
fill(100, 100, 255);
circle(-300, 200, 60);
// World origin marker
stroke(255, 255, 0);
strokeWeight(2 / camera.zoom);
line(-20, 0, 20, 0);
line(0, -20, 0, 20);
}
function mouseWheel(event) {
let zoomFactor = event.delta > 0 ? 0.9 : 1.1;
camera.zoom *= zoomFactor;
camera.zoom = constrain(camera.zoom, 0.1, 10);
}Think about it: if the camera moves right, everything on screen moves left. If the camera zooms in, everything on screen gets bigger. The camera transform is the inverse of the camera's position:
worldToScreen = translate(screenCenter) * scale(zoom) * translate(-cameraPos)
This is the 2D version of the view matrix from Chapter 2. In 3D, the exact same principle applies — the view matrix is the inverse of the camera's world transform.
To find what world position the mouse is pointing at (essential for clicking on objects):
function screenToWorld(sx, sy) {
// Reverse the camera transform
let wx = (sx - width / 2) / camera.zoom + camera.x;
let wy = (sy - height / 2) / camera.zoom + camera.y;
return { x: wx, y: wy };
}
function mousePressed() {
let worldPos = screenToWorld(mouseX, mouseY);
console.log(`Clicked at world position: (${worldPos.x}, ${worldPos.y})`);
}Everything we've built in this chapter — scene graphs, parent-child transforms, cameras — is exactly what Unity does, just with more infrastructure.
Every GameObject in Unity has a Transform component with:
localPosition— position relative to parent (ourx,y)localRotation— rotation relative to parent (ourrotation)localScale— scale relative to parent (ourscaleX,scaleY)position— world position (computed by walking up the parent chain)
When you set a child's localPosition to (10, 0, 0), it's 10 units from its parent — regardless of where the parent is in the world. Move the parent, and the child follows. This is our SceneNode.render() calling translate(this.x, this.y) inside a push()/pop() block.
Unity's 2D UI uses RectTransform — a specialized transform with anchoring and pivots. Anchors define how an element stretches or sticks to edges when the screen resizes. This is analogous to CSS's Flexbox/Grid, but implemented with transform matrices.
For each sprite:
1. Compute world transform (walk parent chain, multiply matrices)
2. Apply camera's inverse transform (view matrix)
3. Apply orthographic projection (2D cameras use orthographic, not perspective)
4. Sort by sorting layer + order in layer
5. Batch sprites with the same texture into one draw call
6. Send batched geometry to GPU
The sprite batching is a critical optimization. Drawing 1000 sprites individually means 1000 draw calls (slow). Batching them into a single draw call with one texture atlas makes it nearly free. This is why sprite sheets exist — putting all your game's sprites on one texture means the GPU can draw them all in one go.
Unity sorts 2D sprites by:
- Sorting Layer (background, characters, foreground, UI)
- Order in Layer (within the same layer, higher numbers draw on top)
- Distance to camera (for 3D games using 2D sprites)
This replaces the "draw order determines visibility" approach of p5.js (where whatever you draw last appears on top).
let scene;
let time = 0;
function setup() {
createCanvas(800, 600);
// Build a scene graph
scene = new SceneNode("root");
scene.x = 400;
scene.y = 300;
// A windmill
let windmill = scene.addChild(new SceneNode("windmill"));
windmill.y = 50;
// Tower
let tower = windmill.addChild(new SceneNode("tower"));
tower.drawFn = () => {
fill(120, 80, 50);
noStroke();
quad(-20, 0, 20, 0, 15, 120, -15, 120);
};
// Blades hub
let hub = windmill.addChild(new SceneNode("hub"));
hub.y = -10;
hub.drawFn = function(node) {
node.rotation = time * 1.5;
fill(180);
noStroke();
circle(0, 0, 15);
};
// Four blades
for (let i = 0; i < 4; i++) {
let blade = hub.addChild(new SceneNode("blade_" + i));
blade.rotation = (TWO_PI / 4) * i;
blade.drawFn = () => {
fill(200, 200, 180);
stroke(150);
strokeWeight(1);
quad(0, -5, 80, -3, 80, 3, 0, 5);
};
}
// A tree swaying in the wind
let tree = scene.addChild(new SceneNode("tree"));
tree.x = -200;
tree.y = 100;
let trunk = tree.addChild(new SceneNode("trunk"));
trunk.drawFn = function(node) {
node.rotation = sin(time * 2) * 0.03; // Slight sway
fill(80, 50, 30);
noStroke();
rect(-8, -80, 16, 80);
};
let canopy = trunk.addChild(new SceneNode("canopy"));
canopy.y = -80;
canopy.drawFn = function(node) {
node.rotation = sin(time * 2.5) * 0.05; // More sway at top
fill(30, 140, 50);
noStroke();
ellipse(0, -20, 80, 70);
};
}
function draw() {
// Sky gradient
for (let y = 0; y < height; y++) {
let t = y / height;
stroke(lerpColor(color(100, 150, 255), color(200, 220, 255), t));
line(0, y, width, y);
}
// Ground
noStroke();
fill(80, 140, 60);
rect(0, height * 0.65, width, height * 0.35);
time += 0.016;
scene.render();
}This scene demonstrates nested transforms in action: the windmill blades rotate around the hub, which is attached to the tower. The tree trunk sways, and the canopy sways more because it inherits the trunk's sway and adds its own.
When you call translate(100, 50) in p5.js, you're multiplying the current transformation matrix by a translation matrix. Let's make this connection to Chapter 1 explicit.
The current transform is a 3x3 matrix (using homogeneous coordinates for 2D):
Current transform matrix:
┌ a b tx ┐
│ c d ty │
└ 0 0 1 ┘
Where a, b, c, d encode rotation and scale, and tx, ty encode translation.
translate(100, 50) multiplies by: rotate(θ) multiplies by: scale(2, 3) multiplies by:
┌ 1 0 100 ┐ ┌ cos(θ) -sin(θ) 0 ┐ ┌ 2 0 0 ┐
│ 0 1 50 │ │ sin(θ) cos(θ) 0 │ │ 0 3 0 │
└ 0 0 1 ┘ └ 0 0 1 ┘ └ 0 0 1 ┘
When you draw a point at (x, y), p5.js transforms it by multiplying:
┌ x' ┐ ┌ a b tx ┐ ┌ x ┐
│ y' │ = │ c d ty │ * │ y │
└ 1 ┘ └ 0 0 1 ┘ └ 1 ┘
x' = a*x + b*y + tx
y' = c*x + d*y + ty
You can actually see this matrix in the Canvas 2D API:
// Get the current transform matrix
let ctx = drawingContext; // p5.js exposes the native context
let matrix = ctx.getTransform();
console.log(`a: ${matrix.a}, b: ${matrix.b}, c: ${matrix.c}, d: ${matrix.d}`);
console.log(`tx: ${matrix.e}, ty: ${matrix.f}`);And you can set it directly:
// Manually set the transform (bypassing translate/rotate/scale)
drawingContext.setTransform(
1, 0, // a, c (first column)
0, 1, // b, d (second column)
200, 150 // tx, ty (translation)
);
// This is identical to translate(200, 150) from a clean stateUnderstanding that transforms are matrices means you can compose them manually, invert them (for screen-to-world conversion), and think about transform performance. But for daily coding, translate/rotate/scale is easier to read than raw matrix values.
Transforms leak. If you rotate for one object and forget to pop, everything after it is rotated too. Always wrap independent objects in push()/pop().
// These produce DIFFERENT results:
// A: Move then rotate (spin in place at 200, 200)
translate(200, 200);
rotate(angle);
// B: Rotate then move (orbit around origin, then draw at rotated offset)
rotate(angle);
translate(200, 200);The rule of thumb: transforms are applied in reverse order to the shapes. The last transform before drawing is the "most local" — it's applied to the shape first.
scale(2) doubles not just the shape but also positions, stroke weight, and distances:
push();
translate(200, 200);
scale(3);
strokeWeight(1); // This is now 3 pixels on screen!
circle(10, 10, 20); // This is at (230, 230) and 60px wide on screen
pop();If you want stroke weight to stay constant regardless of scale, divide by the scale factor:
strokeWeight(1 / currentScale);scale(-1, 1); // Mirror horizontally — useful for facing-left sprites
scale(1, -1); // Mirror vertically — flip upside down
scale(-1, -1); // Rotate 180 degrees (same as rotate(PI))In Chapter 31 (Animation Fundamentals), you'll learn about keyframing transforms. The basic idea: to smoothly animate an object from one transform to another, you interpolate each component over time.
let startX = 100, startY = 100, startRot = 0, startScale = 1;
let endX = 500, endY = 300, endRot = TWO_PI, endScale = 2;
let t = 0;
function setup() {
createCanvas(600, 400);
rectMode(CENTER);
}
function draw() {
background(20);
// Ease t from 0 to 1 and back
t = (sin(frameCount * 0.02) + 1) / 2;
// Interpolate each transform component
let currentX = lerp(startX, endX, t);
let currentY = lerp(startY, endY, t);
let currentRot = lerp(startRot, endRot, t);
let currentScale = lerp(startScale, endScale, t);
push();
translate(currentX, currentY);
rotate(currentRot);
scale(currentScale);
fill(0, 200, 255);
stroke(255);
strokeWeight(2 / currentScale); // Keep stroke consistent
rect(0, 0, 50, 50);
pop();
// UI
fill(255);
noStroke();
textSize(12);
text(`t: ${t.toFixed(2)} pos: (${floor(currentX)}, ${floor(currentY)})`, 10, 20);
text(`rotation: ${degrees(currentRot).toFixed(0)}° scale: ${currentScale.toFixed(2)}`, 10, 40);
}This is linear interpolation (lerp). In practice, you'd use easing functions (Chapter 31) for more natural motion — ease-in, ease-out, elastic, bounce. But the core idea is always the same: interpolate the transform parameters.
What happens if you try to interpolate rotation from 350 degrees to 10 degrees?
Linear interpolation goes 350 -> 180 -> 10, spinning the long way around (340 degrees of rotation). You wanted 350 -> 360 -> 10, the short way (20 degrees). This is the same hue interpolation problem from Chapter 6. The fix: detect when the angle difference is more than 180 degrees and adjust.
-
Clock: Build an analog clock with hour, minute, and second hands. Each hand uses
translateto the center,rotateby the appropriate angle, then draws a line. The second hand should tick every frame. -
Fractal tree: Draw a tree using recursive transforms. Start with a trunk, then
translateto the top,rotateslightly left, draw a shorter branch,pop,rotateslightly right, draw another branch. Recurse 8-10 levels deep. -
Parallax scene: Create a 2D scene with 3 layers (background, midground, foreground) that move at different speeds as you move the mouse, creating a parallax depth effect. Each layer uses the 2D camera technique with different zoom/scroll speeds.
-
Articulated figure: Build a stick figure with a scene graph. Head, torso, upper arms, forearms, hands, upper legs, lower legs, feet. Animate it walking. Each joint should inherit its parent's transform.
- Transforms move the coordinate system, not the shape —
translatemoves the origin,rotaterotates around it,scalestretches from it - push()/pop() save and restore the transform state — essential for drawing independent objects
- Transforms compose through matrix multiplication — the order matters because matrix multiplication is not commutative
- Scene graphs are trees of transforms — each child inherits its parent's transform, creating hierarchical movement
- A 2D camera is an inverse transform — camera moves right means world shifts left
- Screen-to-world conversion reverses the camera transform — essential for mouse interaction
- Unity uses the same system —
Transformcomponent, parent-child hierarchy, sorting layers, sprite batching - This is the foundation of ALL hierarchical graphics — skeletons, UI layouts, game object trees, 3D scene graphs all use this exact pattern