How to draw isometric cubes with Javascript and HTML canvas

Wed Mar 15 2023

tags: public programming gamedev howto

How do you draw isometric tiles on a HTML canvas element?

A great first resource is Isometric graphics in Inkscape from Nicolás Guarín-Zapata's blog, but that's not going to work as-is for canvas. The difference is that in Inkscape you're drawing the square on the canvas before you make the transformations, but in HTML Canvas you're transforming the canvas first before you draw the square.

There are several key modifications you need to make:

  1. Do the reverse of what's mentioned in the blog post: rotate, transform, then scale, in that order. This is because the complete transformation matrix is given by

    Mcomplete=MrotationMskewMscaling.M_{\textrm{complete}} = M_{\textrm{rotation}} M_{\textrm{skew}} M_{\textrm{scaling}}.

  2. You need to translate the canvas such that it's centered on your square before rotating and skewing. In HTML canvas the CanvasRenderingContext2D.rotate() function the center of rotation is the top-left corner of the canvas and not a location relative to any shape.

  3. You will have to play around with the positions and sizes of the faces if you want them to line up properly. I don't really understand exactly how In addition, note the skew transform for angle that are not 30 degrees. The transform should be Math.tan(angle * (90-this.angle*2) / this.angle * -1), not simply Math.tan(angle * -1). 30 degrees is a special case. This is useful for e.g. playing nicely with isometric pixel art assets that usually use a 26.5 degree angle.

Source code for the example above:

<canvas id="game" width="300" height="500"></canvas>
<script>
let Game = document.getElementById("game");
Game.ctx = Game.getContext("2d");
class IsoRect {
constructor(settings) {
this.position = settings.position;
this.width = settings.width;
this.height = settings.height;
this.direction = settings.direction;
this.color = settings.color;
this.angle = settings.angle ? settings.angle : 30
}
render() {
Game.ctx.save();
Game.ctx.fillStyle = this.color;
const angle = (Math.PI / 180) * this.angle;
Game.ctx.translate(
this.position[0] + this.width / 2,
this.position[1] + this.height / 2,
);
if (this.direction === "side") {
Game.ctx.rotate(angle);
Game.ctx.transform(1, 0, Math.tan(angle), 1, 0, 0);
Game.ctx.scale(1, Math.cos(angle));
} else if (this.direction === "front") {
// front
Game.ctx.rotate(-angle);
Game.ctx.transform(1, 0, Math.tan(angle * -1), 1, 0, 0);
Game.ctx.scale(1, Math.cos(angle));
} else {
// plant
Game.ctx.rotate(angle);
Game.ctx.transform(1, 0, Math.tan(angle * (90-this.angle*2) / this.angle * -1), 1, 0, 0);
Game.ctx.scale(1, Math.cos(angle));
}
Game.ctx.translate(
-(this.position[0] + this.width / 2),
-(this.position[1] + this.height / 2),
);
Game.ctx.fillRect(
this.position[0],
this.position[1],
this.width,
this.height,
);
Game.ctx.restore();
}
}

new IsoRect({
position: [100, 100],
width: 100,
height: 100,
direction: "side",
color: "red",
angle: 26.5,
}).render();
new IsoRect({
position: [188, 100],
width: 100,
height: 100,
direction: "front",
color: "tomato",
angle: 26.5,
}).render();
new IsoRect({
position: [144, 33],
width: 100,
height: 90,
direction: "plant",
color: "orange",
angle: 26.5,
}).render();


new IsoRect({
position: [100, 305],
width: 100,
height: 100,
direction: "side",
color: "red",
}).render();
new IsoRect({
position: [186, 305],
width: 100,
height: 100,
direction: "front",
color: "tomato",
}).render();
new IsoRect({
position: [143, 230],
width: 100,
height: 100,
direction: "plant",
color: "orange",
}).render();
</script>