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:
- Rotate grandchild's position by child's rotation
- Apply child's scale
- Add child's position
- Rotate all that by parent's rotation
- Apply parent's scale
- 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
- A 2D transform is 6 numbers:
[a, b, c, d, tx, ty] - Multiply matrices to combine transforms
- Invert to go from screen space to object space
- Decompose for user-friendly display
- Order matters: rotation before translation gives different results
The matrix is the universal language of spatial transformation. Learn it once, use it everywhere.
Next: Hit Testing →