Skip to content

Latest commit

 

History

History
1004 lines (758 loc) · 31 KB

File metadata and controls

1004 lines (758 loc) · 31 KB

Chapter 7: 2D Transformations in Practice

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.

In This Chapter

  • translate(), rotate(), scale() in p5.js — what they actually do to the coordinate system
  • push() and pop() — 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

Related Chapters

  • 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)

1. The Big Idea: Moving the Coordinate System, Not the Shape

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:

  1. Move the origin to the rectangle's center
  2. Rotate
  3. 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 rotate means "go there, then spin in place." rotate then translate means "spin the whole coordinate system, then walk forward in the rotated direction."


2. translate(), rotate(), scale()

translate(x, y)

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)
}

rotate(angle)

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
}

scale(sx, sy)

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.

Transforms Accumulate

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 2x

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


3. push() and pop(): The Matrix Stack

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.

Push/Pop Also Saves Style

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

4. Nested Transforms: The Solar System

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.


5. Scene Graphs: Parent-Child Hierarchies

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

Using the Scene Graph

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.


6. The World Transform: Local to Global

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 };
  }
}

7. Sprite Animation with Transforms

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.

Sprite Sheet Animation

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

8. 2D Camera / Viewport

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

Why the Camera Transform Is an Inverse

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.

Screen to World Conversion

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})`);
}

9. How Unity Does This: RectTransform, Canvas, and 2D Rendering

Everything we've built in this chapter — scene graphs, parent-child transforms, cameras — is exactly what Unity does, just with more infrastructure.

Unity's Transform Component

Every GameObject in Unity has a Transform component with:

  • localPosition — position relative to parent (our x, y)
  • localRotation — rotation relative to parent (our rotation)
  • localScale — scale relative to parent (our scaleX, 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.

RectTransform for UI

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.

Unity's 2D Rendering Pipeline

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.

SpriteRenderer Sorting

Unity sorts 2D sprites by:

  1. Sorting Layer (background, characters, foreground, UI)
  2. Order in Layer (within the same layer, higher numbers draw on top)
  3. 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).


10. Putting It All Together: Animated Scene

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.


11. The Matrix Under the Hood

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 state

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


12. Common Pitfalls (and How to Avoid Them)

Forgetting push()/pop()

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

Order of Operations

// 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 Affects Everything

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

Negative Scale: Don't Forget

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

13. Transform Interpolation: Smooth Transitions

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.


14. Exercises

  1. Clock: Build an analog clock with hour, minute, and second hands. Each hand uses translate to the center, rotate by the appropriate angle, then draws a line. The second hand should tick every frame.

  2. Fractal tree: Draw a tree using recursive transforms. Start with a trunk, then translate to the top, rotate slightly left, draw a shorter branch, pop, rotate slightly right, draw another branch. Recurse 8-10 levels deep.

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

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


15. Key Takeaways

  • Transforms move the coordinate system, not the shapetranslate moves the origin, rotate rotates around it, scale stretches 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 systemTransform component, 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