drawing a cube

In my last post I wrote about how to draw a pretty awesome square - this time, we’ll add an extra dimension. Hopefully I’ll be able to demonstrate that it’s not nearly as complicated as it sounds, most of the discussion will follow from the first post so make sure you’ve read that first.

First, let’s define our cube. A cube has eight points (corners) and six faces. Again, we’ll define our object relative to the origin [0, 0, 0], and we’ll position the corners at [1, 1, 1], [-1, 1, 1], etc.

var cube_faces = [
  // front and back
  [[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]],
  [[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1]],  

  // left and right
  [[-1, -1, 1], [-1, -1, -1], [-1, 1, -1], [-1, 1, 1]],
  [[1, -1, 1], [1, -1, -1], [1, 1, -1], [1, 1, 1]],

  // top and bottom
  [[-1, 1, 1], [1, 1, 1], [1, 1, -1], [-1, 1, -1]],
  [[-1, -1, 1], [1, -1, 1], [1, -1, -1], [-1, -1, -1]],
];

Note that we’ll use the convention that for a vector [x, y, z], +x is up, +y is right, and +z is in to the screen.

We’ll start by making a drawFace function. This will take an array of 3D world coordinates of our face, project them into an array of 2D screen coordinates, and draw them on the canvas.

Projection is any method of converting our 3D point data into 2D data (that we can draw on our 2D screen). We’re going to use a simple perspective projection, which you can read about here. Ever wondered how games deal with changing FOVs or stereographic images? Projection!

So, let’s skim over this wikipedia article and extract the formulas we’ll need. Following the article let’s assume an unrotated camera positioned at c, a viewer positioned at e, and our 3D point at a. We aim to find b, the 2D projection onto our canvas. Since our camera is unrotated, we have d = a - c and combined with the two equations for b we get

d[0] = a[0] - c[0]
d[1] = a[1] - c[1]
d[2] = a[2] - c[2]

b[0] = e[2] * (d[0] / d[2]) - e[0]
b[1] = e[2] * (d[1] / d[2]) - e[1]

For simplicity, we’ll set c = [0, 0, 0]. This simplifies our formula even more since now d = a.

b[0] = e[2] * (a[0] / a[2]) - e[0]
b[1] = e[2] * (a[1] / a[2]) - e[1]

Our drawFace function should end up looking something like this

function drawFace(face_coords) {  
  var screen_coords = [];

  for (var j = 0; j < face_coords.length; j++) {
    screen_coords[j] = [
      (e[2] * (face_coords[j][0]) / face_coords[j][2]) + e[0],
      (e[2] * (face_coords[j][1]) / face_coords[j][2]) + e[1]
    ];  
  }

  // A bit of transparency here means we'll be able to see through our cube.
  ctx.strokeStyle = 'black';
  ctx.fillStyle = 'rgba(100, 100, 100, 0.4)';

  ctx.beginPath();
  ctx.moveTo(screen_coords[0][0], screen_coords[0][1]);
  ctx.lineTo(screen_coords[1][0], screen_coords[1][1]);
  ctx.lineTo(screen_coords[2][0], screen_coords[2][1]);
  ctx.lineTo(screen_coords[3][0], screen_coords[3][1]);
  ctx.lineTo(screen_coords[0][0], screen_coords[0][1]);
  ctx.stroke();
  ctx.fill();
}

But what about our value for e? For now, we’ll just set it to [canvas.width / 2, canvas.height / 2, -200]. Once everything is running, you can tweak this value to get an understand of what it does.

We need a basic draw function to draw all six faces of our cube.

function draw(faces) {
  var world_faces = faces.map(function(face) {
    var world_face = face.map(function(point) {
      var world_point = [
        (10 * point[0]),
        (-10 * point[1]),
        (10 * point[2]) - 60
      ];

      return world_point;
    });

    return world_face;
  });

  world_faces.map(drawFace);
}

ctx.fillStyle = 'rgb(135, 206, 250)';
ctx.fillRect(0, 0, canvas.width, canvas.height);

draw(cube_faces);

Great! Let’s try it out. You’ll notice that I put a few simple transformations in here. The cube is scaled up by a factor of 10, the y component is inverted (so +y is right), and the z component is reduced by 60. This means the cube is move backwards (into the screen) away from the camera.

You should get something like this:

Notice that the rear face is smaller than front face, and the side faces appear to get smaller. Perspective!

Before we get into rotation, there’s one more problem we need to solve. Go ahead and set the fillStyle for our faces to a solid colour. You’ll see something like this.

Since we’re looking at the front of our cube we should only be able to see the front face - why can we see edges of other faces?! This happens because the faces are drawn in the order they appear in our cube_faces array. That means the bottom faces will always been drawn in the order front, back, left, right, top, then bottom. Really, we want the faces that are furthest away from the camera to be drawn first, so subsequent (closer) faces are drawn on top. We can do this by sorting the faces based on their distance to the camera. We’ll so this with Array.prototype.sort.

world_faces.sort(function(a, b) {
  var a_center = [
    (a[0][0] + a[1][0] + a[2][0] + a[3][0]) / 4,
    (a[0][1] + a[1][1] + a[2][1] + a[3][1]) / 4,
    (a[0][2] + a[1][2] + a[2][2] + a[3][2]) / 4
  ];

  var b_center = [
    (b[0][0] + b[1][0] + b[2][0] + b[3][0]) / 4,
    (b[0][1] + b[1][1] + b[2][1] + b[3][1]) / 4,
    (b[0][2] + b[1][2] + b[2][2] + b[3][2]) / 4
  ];

  var a_mag = (
    a_center[0] * a_center[0] +
    a_center[1] * a_center[1] +
    a_center[2] * a_center[2]
  );

  var b_mag = (
    b_center[0] * b_center[0] +
    b_center[1] * b_center[1] +
    b_center[2] * b_center[2]
  );

  return b_mag - a_mag;
});

Given two faces we find the average each faces four corners. This gives us the centre of each face, which we use to compute it’s magnitude - this is the distance between the face and the camera (since our camera is at [0, 0, 0]). We use the comparision between these two magnitudes to sort world_faces. This is called the Painter’s Algorithm given that it’s similar to how a painter would paint a scene - distant objects first.

Finally, let’s beef up our transformations a little. We’ve got scale and translation, but what about rotation? Another trip to Wikipedia gives us some basic matrices for rotations about the x, y, and z axes. Expanding these matrices out into equations is left as an exercise for the reader… I ended up with something like this:

// Rotate X
var tempY = world_point[1];
var tempZ = world_point[2];
world_point[1] = tempY * Math.cos(rot[0]) + tempZ * Math.sin(rot[0]);
world_point[2] = tempZ * Math.cos(rot[0]) - tempY * Math.sin(rot[0]);

// Rotate Y
var tempX = world_point[0];
tempZ = world_point[2];
world_point[0] = tempX * Math.cos(rot[1]) + tempZ * Math.sin(rot[1]);
world_point[2] = tempZ * Math.cos(rot[1]) - tempX * Math.sin(rot[1]);

// Rotate Z
tempX = world_point[0];
tempY = world_point[1];
world_point[0] = tempX * Math.cos(rot[2]) + tempY * Math.sin(rot[2]);
world_point[1] = tempY * Math.cos(rot[2]) - tempX * Math.sin(rot[2]);

And a draw function that looked a little more like this

function draw(faces, scale, rot, trans) {
  var world_faces = faces.map(function(face) {
    var world_face = face.map(function(point) {
      var world_point = [
        point[0],
        -point[1],
        point[2]
      ];

      // Rotate X
      var tempY = world_point[1];
      var tempZ = world_point[2];
      world_point[1] = tempY * Math.cos(rot[0]) + tempZ * Math.sin(rot[0]);
      world_point[2] = tempZ * Math.cos(rot[0]) - tempY * Math.sin(rot[0]);

      // Rotate Y
      var tempX = world_point[0];
      tempZ = world_point[2];
      world_point[0] = tempX * Math.cos(rot[1]) + tempZ * Math.sin(rot[1]);
      world_point[2] = tempZ * Math.cos(rot[1]) - tempX * Math.sin(rot[1]);

      // Rotate Z
      tempX = world_point[0];
      tempY = world_point[1];
      world_point[0] = tempX * Math.cos(rot[2]) + tempY * Math.sin(rot[2]);
      world_point[1] = tempY * Math.cos(rot[2]) - tempX * Math.sin(rot[2]);

      // Scale
      world_point[0] *= scale;
      world_point[1] *= scale;
      world_point[2] *= scale;

      // Translate
      world_point[0] += trans[0];
      world_point[1] += trans[1];
      world_point[2] += trans[2];

      return world_point;
    });

    return world_face;
  });

  world_faces.sort(function(a, b) {
    var a_center = [
      (a[0][0] + a[1][0] + a[2][0] + a[3][0]) / 4,
      (a[0][1] + a[1][1] + a[2][1] + a[3][1]) / 4,
      (a[0][2] + a[1][2] + a[2][2] + a[3][2]) / 4
    ];

    var b_center = [
      (b[0][0] + b[1][0] + b[2][0] + b[3][0]) / 4,
      (b[0][1] + b[1][1] + b[2][1] + b[3][1]) / 4,
      (b[0][2] + b[1][2] + b[2][2] + b[3][2]) / 4
    ];

    var a_mag = (
      a_center[0] * a_center[0] +
      a_center[1] * a_center[1] +
      a_center[2] * a_center[2]
    );

    var b_mag = (
      b_center[0] * b_center[0] +
      b_center[1] * b_center[1] +
      b_center[2] * b_center[2]
    );

    return b_mag - a_mag;
  });

  world_faces.map(drawFace);
}

Remember that order is important. Here I’m rotating around x, then y, then z. With three rotations happening one after the other this can lead to some funky results, but we’ll gloss over that for now…

Stitching this all together with a bit of animation

var t = 0;

function frame() {
  ctx.fillStyle = 'rgb(135, 206, 250)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  draw(cube_faces, 10, [t, 0.6, 0], [0, 0, -60]);

  t += 0.01;

  window.requestAnimationFrame(frame);
}

frame();

And you’ll have something like this!

Pretty cool really.