Cube Rendering

Introduction

Last time I mentioned that I had been seeing many hyper-cube animations and wanted to make one of my own. Last time (Part One), I talked about how n-cubes could be generated and stored as a graph. Now, I want to take that generator and render a cube. I could leverage many libraries; that said, since this is more about my curiosity and will not be released outside this post, I want to build it all from scratch.

Rendering

Drawing and rendering a square is straightforward and not very complicated. Only two things are needed: take the binary strings, convert them to points graph the points To convert the points is straightforward and can be accomplished with a function that looks like this.

const bitToCoordinate = (bit) => (size) =>
  ({
    0: -size,
    1: size,
  }[bit]);

The same can be accomplished without function currying, but I recently came across the syntax and wanted to play with it.

This function can create a new map mapping binary strings to their points. With this new mapping, we can render our square (2-dimensional cube) on a canvas.

const convertGraphToPoints = (graph, sideLength = 100) => {
  return Object.keys(graph).reduce(
    (pointsObject, binaryString) => ({
      ...pointsObject,
      [binaryString]: binaryString
        .split("")
        .map((bit) => bitToCoordinate(bit)(sideLength / 2)),
    }),
    {}
  );
};

/**
 * Draws a square
 *
 * @param {Object} ctx a canvas' 2d context
 */
const draw = (ctx) => {
  const graph = cubeGenerator(2);
  const _points = convertGraphToPoints(graph);

  ctx.beginPath();
  for (const [fromBinary, toBinaries] of Object.entries(graph)) {
    const [fromX, fromY] = _points[fromBinary];

    ctx.moveTo(fromX, fromY);

    for (const binary of toBinaries) {
      const [toX, toY] = _points[binary];

      ctx.lineTo(toX, toY);
      ctx.moveTo(fromX, fromY);
    }
  }

  ctx.stroke();
  ctx.closePath();
};

Wiring with code up with the cube generator, the following is produced (I've placed the hierarchical diagram next to it for reference to what is going on behind the scenes).

Rendering a square is straightforward since it's two-dimensional, and we're graphing on a plane. When going up dimensions, we can't take the points and graph them onto a plane as they are. Instead, we will need to add another step - projection.

Projection

A projection is the transformation of points and lines in one plane onto another plane by connecting corresponding points on the two planes with parallel lines.1 For now, let's only think in terms of projecting our cube (3D) onto the 2D plane getting rendered on screen. We want to project the points of vertices of our 3D cubes to the canvas and connect them with the same rules described in the previous post.

So how do we go about this? There are many different types of projections, the one I want to use is perspective projection.

Artist uses perspective projections for drawing three-dimensional scenes. Two main characteristics of perspective are vanishing points and perspective foreshortening. Due to foreshortening object and lengths appear smaller from the center of projection. More we increase the distance from the center of projection, smaller will be the object appear. 2

In Perspective projection lines of projection do not remain parallel. The lines converge at a single point called a center of projection. The projected image on the screen is obtained by points of intersection of converging lines with the plane of the screen. The image on the screen is seen as of viewer’s eye were located at the centre of projection, lines of projection would correspond to path travel by light beam originating from object. 2

The math behind a perspective projection is similar triangles and geometry, and like most geometric things, they are best described with a picture.

The diagram above shows the two similar triangles we can use to calculate (x', y').

(x',y') = (d * (x/z), d * (y/z))

we will add some additional math to move back our projected cube away from the plane. This change will help us see the cube better.

(x',y') = (d * (x/(z + d)), d * (y/(z + d))

It doesn't need to be d; it could be a completely different variable. Addionally, I am not an expert on this subject for some further more indepth reading this pdf and this website is what I used to help understand the math behind this.

Using these equations, we can start drawing our cube (I've placed the hierarchical diagram next to it for reference to what is going on behind the scenes).

The cube may look a little weird at first, but once you get your bearings, it's easy to see that this is the frame of a cube if you were looking at it head-on. It would be easier to see the cube if we were to rotate it, but we will get there in a little. For now, we need to address the fact that we've only been looking at the 3D case; we're after the more general case. Luckily this pattern of dividing all the coordinates by the dropped value extends past the third dimension. To project down from n-dimensions to two, we can do the following.

// nCoordinates is an array representing each coordinate
// with (nCoordinates[0], nCoordinates[1]) being (x,y)
export const projectPoints = (nCoordinates) => {
  const dimension = nCoordinates.length;
  const mutatableList = nCoordinates.slice();
  const d = 150;

  // minus 2 is to stop at the second dimension
  for (let i = 0; i < dimension - 2; i++) {
    // grab the highest dimension's value
    const droppedValue = mutatableList.pop();

    // re-project all points
    for (let j = 0; j < mutatableList.length; j++) {
      mutatableList[j] = (mutatableList[j] * d) / (droppedValue + d);
    }
  }

  return mutatableList;
};

With that, we can get our first glance at the fourth dimension cube we're making.

Since this is a fourth-dimensional object, it's hard to wrap our heads around what is going on; however, adding in some rotation may help us understand more about what is going on.

Rotation

There are numerous ways to rotate a shape, especially if we look at dimension-specific options. In an earlier post, I discussed a way to rotate a 2D object using complex numbers. There is a way to rotate 3D objects using quaternions, the complex numbers' older brother, that's a subject for another time. The most common way to rotate something is using a rotation matrix. This way is reliable but does have some drawbacks like gimbal lock.

Gimbal lock is the loss of one degree of freedom in a three-dimensional, three-gimbal mechanism that occurs when the axes of two of the three gimbals are driven into a parallel configuration, "locking" the system into rotation in a degenerate two-dimensional space.3

That said, since it's the most straightforward way (in my opinion) to rotate something, let's use it to rotate the cubes. You can find a list of rotation matrices here:

You can multiply one of the 3D/4D matrices with any of the other 3D/4D matrices to combine rotation.

There are ways to generate higher dimension rotation matrices, but for simplicity's sake (and since I'm only after the hyper-cube), these are the three we can focus on.

To rotate our points, we need to:

  1. move them into matrix form
  2. multiply by a rotation matrix
  3. move them to coordinates
  4. project down

To move the coordinates into a matrix:

const toMatrix = (coordinates) => {
  return coordinates.map((row) => [row]);
};

To multiply matrices:

export const multiplyMatrices = (matrixA, matrixB) => {
  let result = [];

  for (let i = 0; i < matrixA.length; i++) {
    result[i] = [];

    for (let j = 0; j < matrixB[0].length; j++) {
      let sum = 0;

      for (let k = 0; k < matrixA[0].length; k++) {
        sum += matrixA[i][k] * matrixB[k][j];
      }

      result[i][j] = sum;
    }
  }
  return result;
};

Return back to coordinates and project:

export const rotate = (coordinates) => {
  const rotatedCoordinates = multiplyMatrices(
    rotationMatrix,
    toMatrix(coordinates)
  ).flat(); // `.flat` is step 3

  const projectedPoints = projectPoints(rotatedCoordinates);
};

Where rotationMatrix is any of the above matrices. With this we can start rotating some cubes, well, in the first case a square.

Above it was mentioned that 3D/4D rotation matrices can be multiplied together to create a single matrix that will rotate the shape by all of the rotation matrices. Here is an example of this.

x-axis

y-axis

z-axis

Below is a matrix created from combining all three.

And finally we get to what these two articles have been leading to the fourth dimension. Just like 3D rotation matrices, 4D rotation matrices can be combined.

Combination of XW and XZ rotation matrices

And there we have it. We've gone from binary block codes to rotating a 4D cube.

Footnotes

  1. Wolfram ↩

  2. Geeks for Geeks ↩ ↩2

  3. Gimbal Lock ↩