CanvasContext2D Transform Math for Beginners

It’s easy to go to find a canvas tutorial, and often it will show a developer how to move the canvas context around to draw images using the specialized transform functions on the CanvasContext2DPrototype. These tutorials are absolutely fine for beginners and often, development teams will look no further to optimize their code. However, these tutorials overlook some very simple math that could make the canvas operations faster.

For example, typical canvas transforms normally look like this for sprites:

//draw a sprite and skip the math
ctx.save();
ctx.translate(x, y); //move to canvas coordinates
ctx.rotate(rotation); //rotate in radians
ctx.scale(sx, sy); //scale in size
ctx.translate(-cx, -cy); //center the image relative to itself
ctx.drawImage(img, 0, 0, img.width, img.height);
ctx.restore();

However, this isn’t the fastest way to draw an image on canvas. In the prior example, the ctx.save() and ctx.restore() functions have quite a bit of overhead, especially when combined with 4 extra function calls. It’s probably better to just do a bit of the math yourself.

Doing matrix math can be complicated, but it also enables us to use the ctx.setTransform() function instead of the save and restore functions, which does turn out to be less cpu intensive. The following code snippet is equivalent to the sprite example above:

//don't skip the math!
let cos = Math.cos(rotation), sin = Math.sin(rotation);
//Transform the canvas context
ctx.setTransform(
sx * cos,
sy * sin,
sx * -sin,
sy * cos,
x,
y
);
//draw an image
ctx.drawImage(img, -cx, -cy, img.width, img.height);
//restore the canvas state
ctx.setTransform(1, 0, 0, 1, 0, 0);

Wow! The amount of function calls have been reduced, and we no longer have to save or restore the canvas context. Scaling it inside a for loop can even look like this:

let cos, sin;
for (let box of this.boxes) {
cos = Math.cos(box.rotation); //calculate the cosine ratio
sin = Math.sin(box.rotation); //calculate the sine ratio
ctx.setTransform(
box.size[0] * cos, //box.size[0] is the width
box.size[1] * sin, //box.size[1] is the height
box.size[0] * -sin,
box.size[1] * cos,
box.position[0], //box.position[0] is the x position
box.position[1] //box.position[1] is the y position
);
ctx.beginPath();
ctx.rect(-0.5, -0.5, 1, 1); //set the rectangle path
ctx.setTransform(1, 0, 0, 1, 0, 0); //restore the transform stack
ctx.stroke(); //then draw the rectangle
}
Note: Remember to set the transform back to [1, 0, 0, 1, 0, 0] after defining the position of the box object to make sure the line widths are consistent, when they are stroked with the API.

So what’s happening under the hood? Why does this work?

Enter The Matrix

Beware! Advanced math is ahead!

So to begin, let’s describe what a matrix is. The initial matrix is a (3x3) “identity matrix” that looks like this:

The (3x3) identity canvas matrix represents an unchanged canvas state. This means drawing an image at [0,0] will draw the image at exactly [0,0] with no stretch or rotation.

This matrix is very important, because it’s the exact starting point for every canvas operation.

The ctx.setTransform(a, b, c, d, e, f) function maps to the matrix like this

Note: the order of the letters and positions in the matrix are row first: i.e. [y, x]. This means item b is at [2, 1].

One of the magical rules about Canvas operations is that all the stretching, rotation, and translating happens in two dimensional space. As a result, the bottom row, because of mathematical reasons is always [0, 0, 1].


Starting Off Basic — Save and Restore

Now that we have a starting point, we want to be able to save and restore canvas state in a way that makes sense.

For the sake of example, follow along in code and derive your own canvas functions.

//set currentMatrix to the identity matrix
var currentMatrix = [1, 0, 0, 1, 0, 0];
var cache = new Float64Array(6); //reuse this variable a lot!

Using a Float64Array for number caching is a wise choice, especially for number arrays that get re-used a lot. When creating new TypedArrays, it uses more memory and more CPU cycles up front to make read and write operations faster later, so allocate what you can up front, and reuse a lot later.

Next, we are going to treat the state of those six numbers as a stackable value. Knowing that canvas transforms are stackable allows you to create an array of transforms, then push and pop them as we need to.

In this example, we create a stack, and then describe a push operation.

var stack = [currentMatrix.slice()];//slice copies the currentMatrix
//This is a pushing operation
stack.push(currentMatrix.slice());
ctx.setTransform(
currentMatrix[0],
currentMatrix[1],
currentMatrix[2],
currentMatrix[3],
currentMatrix[4],
currentMatrix[5]
);

Great! Now let’s describe a popping operation.

//remove the top value
stack.pop();
//restore the next value
currentMatrix = stack[stack.length — 1]; //restore previous value
ctx.setTransform(
currentMatrix[0],
currentMatrix[1],
currentMatrix[2],
currentMatrix[3],
currentMatrix[4],
currentMatrix[5]
);

Excelent! We have push and pop defined very well! Let’s start manipulating that stack with some example math.

Lost In Translation!

In order to describe certain kinds of canvas transforms, we need to give them a matrix representation, and indeed, those representations are very simple! A translate matrix looks like this:

Translate Matrix

The following example uses tx and ty in the calculations required for translation.

currentMatrix[4] += currentMatrix[0] * tx + currentMatrix[2] * ty;
currentMatrix[5] += currentMatrix[1] * tx + currentMatrix[3] * ty;
push();
Note that the Y direction is downward facing. The blue square is drawn at [20,20] with ctx.strokeRect(20, 20, 20, 20); and the green rectangle is translated [20, 20] and redrawn with the same coordinates.
Note: Translation only moves the e and f values of the current matrix. This is because a translation does not change how a drawn image is rotated or stretched.

In the side example, the green square is translated from the location of the blue square.


Scaling Your Operation

Next we investigate how scaling operations work. This is what a scale matrix looks like:

Scale Matrix

The following example uses sx and sy in the calculations required for scaling.

currentMatrix[0] *= sx;
currentMatrix[1] *= sx;
currentMatrix[2] *= sy;
currentMatrix[3] *= sy;
push();
The red axis are translated to [200, 200] first. Then a blue square is drawn at [20,20] with ctx.strokeRect(20, 20, 20, 20); The green rectangle is scaled at [-2, -2] with the same coordinates.
Note: Since scaling doesn't affect the current [x,y] coordinates of the transform, scaling is applied to the first four items in the matrix.

As the name scaling implies, scaling makes your images and path coordinates bigger, smaller, or reflects them over a given axis.

In the side example, the blue square is reflected over the x and y axis, then made bigger by the scaling operation.

Rotating The Point Of View

Investigating rotation is a bit harder, because it involves trigonometry functions.

The following example uses the cosine and sine functions to calculate a few transform values. (Note that the a variable is rotation in radians and not degrees)

Rotation Matrix
var cosa = Math.cos(a), sinr = Math.sin(a);
cache[0] = currentMatrix[0];
cache[1] = currentMatrix[1];
cache[2] = currentMatrix[2];
cache[3] = currentMatrix[3];
currentMatrix[0] = cache[0] * cosa + cache[2] * sina;
currentMatrix[1] = cache[1] * cosa + cache[3] * sina;
currentMatrix[2] = cache[0] * -sina + cache[2] * cosa;
currentMatrix[3] = cache[1] * -sina + cache[3] * cosa;
push();
The blue square is drawn at [20,20] with ctx.strokeRect(20, 20, 20, 20); The green rectangle is rotated 90 degrees (or Math.PI / 2 radians) about the center point at [0,0]

In conventional math, rotations happen counter-clockwise, but because the y axis is flipped, they happen clockwise on canvas operations.

Do not forget to convert degrees to radians for Math.cos(), and Math.sin().

const rads = (degrees) => Math.PI * 2 * degrees / 360;

Transformers! Mathematics in disguise!(Extra Credit!)

Last but not least, the full matrix transform is in the example below. Every other transform is derived from this formula, only simplified.

cache.set(currentMatrix);
currentMatrix[0] = cache[0] * t[0] + cache[2] * t[1]; //a...
currentMatrix[1] = cache[1] * t[0] + cache[3] * t[1]; //b...
currentMatrix[2] = cache[0] * t[2] + cache[2] * t[3]; //c...
currentMatrix[3] = cache[1] * t[2] + cache[3] * t[3]; //d...
currentMatrix[4] = cache[0] * t[4] + cache[2] * t[5] + cache[4]; currentMatrix[5] = cache[1] * t[4] + cache[3] * t[5] + cache[5];

Good luck,

-Josh