The Matrix

Every object in the editor has a position, rotation, and scale. Three concepts, twelve numbers.

Or: one matrix, six numbers.

The Problem With Separate Values

First attempt: store position, rotation, scale separately.

const object = {
  x: 100, y: 50,           // position
  rotation: 45,            // degrees
  scaleX: 2, scaleY: 1.5   // scale
};

Works for one object. Now nest them.

Parent (rotated 30°)
  └── Child (rotated 45°)
        └── Grandchild (at position 10, 20)

Where is the grandchild on screen? You need to:

  1. Rotate grandchild's position by child's rotation
  2. Apply child's scale
  3. Add child's position
  4. Rotate all that by parent's rotation
  5. Apply parent's scale
  6. Add parent's position

Every frame. For every object. In every nested group.

The Matrix Solution

A 2D transformation matrix is six numbers:

| a  c  tx |
| b  d  ty |
| 0  0  1  |

Or as an array: [a, b, c, d, tx, ty]

var cfg = {
  "id": "matrix1",
  "type": "matrix-playground",
  "bindings": {
    "a": "matrix.a",
    "b": "matrix.b",
    "c": "matrix.c",
    "d": "matrix.d",
    "tx": "matrix.tx",
    "ty": "matrix.ty"
  },
  "width": 500,
  "height": 250
}

a={{matrix.a}}, b={{matrix.b}}, c={{matrix.c}}, d={{matrix.d}}, tx={{matrix.tx}}, ty={{matrix.ty}}

Drag the shape. Watch the numbers change.

What Each Number Does

tx, ty: Translation. Move the object.

a, d: Scale on X and Y axes. When both are 1, no scaling.

b, c: Skew and rotation. When both are 0, no rotation.

The identity matrix (no transformation):

const identity = [1, 0, 0, 1, 0, 0];
// a=1, b=0, c=0, d=1, tx=0, ty=0

Building Transforms

Translation (move 100 right, 50 down):

[1, 0, 0, 1, 100, 50]

Scale (2x horizontal, 1.5x vertical):

[2, 0, 0, 1.5, 0, 0]

Rotation (45 degrees):

const angle = 45 * Math.PI / 180;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
[cos, sin, -sin, cos, 0, 0]

The Magic: Matrix Multiplication

To combine transforms, multiply matrices:

function multiply(m1, m2) {
  return [
    m1[0] * m2[0] + m1[2] * m2[1],
    m1[1] * m2[0] + m1[3] * m2[1],
    m1[0] * m2[2] + m1[2] * m2[3],
    m1[1] * m2[2] + m1[3] * m2[3],
    m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
    m1[1] * m2[4] + m1[3] * m2[5] + m1[5]
  ];
}

Now that nested hierarchy becomes:

const worldMatrix = multiply(
  parentMatrix,
  multiply(childMatrix, grandchildMatrix)
);

One matrix captures all the combined transforms. Apply it once.

Transforming Points

To transform a point by a matrix:

function transformPoint(matrix, point) {
  return {
    x: matrix[0] * point.x + matrix[2] * point.y + matrix[4],
    y: matrix[1] * point.x + matrix[3] * point.y + matrix[5]
  };
}

Six multiplications, two additions. That's it.

The Inverse: Going Backwards

For hit testing, we need the opposite: given a screen point, where is it in object space?

function invert(m) {
  const det = m[0] * m[3] - m[1] * m[2];
  if (det === 0) return null;  // Can't invert

  const invDet = 1 / det;
  return [
    m[3] * invDet,
    -m[1] * invDet,
    -m[2] * invDet,
    m[0] * invDet,
    (m[2] * m[5] - m[3] * m[4]) * invDet,
    (m[1] * m[4] - m[0] * m[5]) * invDet
  ];
}

The determinant check matters. Scale to zero? No inverse exists.

Decomposition: Back to Human Values

Users don't think in matrices. They think "rotate 30 degrees."

So we decompose:

function decompose(matrix) {
  const [a, b, c, d, tx, ty] = matrix;

  const scaleX = Math.sqrt(a * a + b * b);
  const scaleY = Math.sqrt(c * c + d * d);
  const rotation = Math.atan2(b, a);

  return {
    x: tx,
    y: ty,
    rotation: rotation * 180 / Math.PI,
    scaleX,
    scaleY
  };
}

Display these in the UI. When user changes rotation, rebuild the matrix.

The Gotcha: Operation Order

Matrix multiplication isn't commutative. A × B ≠ B × A.

Rotate then translate:
  Object spins in place, then moves.

Translate then rotate:
  Object moves, then orbits around original center.

Canvas2D and Skia multiply in different orders. Figma uses yet another convention. Getting this wrong means transforms look "backwards."

Our convention: translate × rotate × scale, applied right to left.

Transform Origin

By default, rotation and scale happen around (0, 0).

To rotate around the object's center:

function rotateAroundCenter(angle, cx, cy) {
  const rad = angle * Math.PI / 180;
  const cos = Math.cos(rad);
  const sin = Math.sin(rad);

  return [
    cos, sin, -sin, cos,
    cx - cos * cx + sin * cy,
    cy - sin * cx - cos * cy
  ];
}

Translate to origin, rotate, translate back. Combined into one matrix.

Summary

The matrix is the universal language of spatial transformation. Learn it once, use it everywhere.

Next: Hit Testing →