Minting Coins in Three.js

How to produce golden coins out of thin air for real fun and imaginary profit

In this article I'll explain a little about how the fullscreen visualization on my homepage was made.

The code is open source, so you can try it yourself. If I ever change the homepage of this site, the visualization will always be viewable on this page.

Creating the Visualization

I wanted to try Three.js so I was looking through the examples to see what the library can do. This example of stationary rings with moving lights caught my attention, and I wondered if I could make it more interesting by animating them.

Original Rings

Figuring out how to rotate the rings within the animation loop was easy, and so was moving them from right to left across the screen. Whenever one disappeared off the screen on the left side, my code would reset its x position to the right side. I changed the light sources around as well, animated them in different patterns, and played with the configuration options to see what was most interesting.

I wondered if I could combine geometries to form a disc out of the rings. It turns out Three.js has some nice built in functionality which takes any number of geometries (sets of 3d vertices) and merges them. So, you can start with a few primitive shapes, arrange them how you like, then merge them all into a single complex shape.


var ringGeometry = new THREE.TorusGeometry(
    2.5, 0.25, 8, 64
);
var cylinderGeometry  = new THREE.CylinderGeometry(
    2.5, 2.5, 0.45, 16
);
cylinderGeometry.rotateX(Math.PI / 2);
var combinedGeometry = new THREE.Geometry();
combinedGeometry.merge(ringGeometry);
combinedGeometry.merge(cylinderGeometry);

Adding a squashed cylinder in the middle of the torus and changing the mesh material to a golden color made them look like coins. They looked nice, but only had a flat face, so adding some text to each face was the next step.


var objectMaterial = new THREE.MeshStandardMaterial({
    color: 0xd4af37,
    roughness: 0.5,
    metalness: 1.0
});

Three.js has a feature where 3d text can be made using json-formatted fonts, but rather than do that, I wanted to learn how to create the shapes by hand. I decided on 1 and 0 for the coin faces - two sides of the binary coin. Each coin represents one bit of pure computational potential. You could call them bit-coins if you're feeling snarky, but I wouldn't subject my readers to that.

Numeral Graph

I drew the outlines of the numbers on graph paper, figured out the positioning of the vertices, guessed the bezier control points, and added each point to the the three.js path vertices before extruding the geometry.

The code to draw the 1 and 0 shape by moving from one vertex to the next looks like this:


// create the "1" geometry
let oneShape = new THREE.Shape();
oneShape.moveTo(-3, 1);
oneShape.lineTo(-3, 2);
oneShape.bezierCurveTo(-2.75, 1.75, -0.75, 3.5, -1, 4);
oneShape.lineTo(1, 4);
oneShape.lineTo(1, -3);
oneShape.lineTo(3, -3);
oneShape.lineTo(3, -4);
oneShape.lineTo(-3, -4);
oneShape.lineTo(-3, -3);
oneShape.lineTo(-1, -3);
oneShape.lineTo(-1, 2);
oneShape.bezierCurveTo(-0.8, 1.8, -2.8, 0.8, -3, 1);

// create the "0" geometry
let zeroShape = new THREE.Shape();
zeroShape.moveTo(0, 4);
zeroShape.bezierCurveTo(4, 4, 4, -4, 0, -4);
zeroShape.bezierCurveTo(-4, -4, -4, 4, 0, 4);
let zeroHole = new THREE.Path();
zeroHole.moveTo(0, 2.5);
zeroHole.bezierCurveTo(-2.5, 2.5, -2.5, -2.5, 0, -2.5);
zeroHole.bezierCurveTo(2.5, -2.5, 2.5, 2.5, 0, 2.5);
zeroShape.holes.push(zeroHole);
let zeroSlash = new THREE.Shape();
zeroSlash.moveTo(-3.5, -2.5);
zeroSlash.lineTo(2.5, 3.5);
zeroSlash.lineTo(3.5, 2.5);
zeroSlash.lineTo(-2.5, -3.5);
zeroSlash.lineTo(-3.5, -2.5);

Three.js has a nice bevel feature in its geometry extrusion options. Without the bevel, the numerals have a sharp 90 degree edge, so there isn't much of a surface for light to reflect off. By adding a small bevel, the faces of the coins glint as they turn.

You can see what the coin looks like without the bevel on the left, and with the bevel on the right:

After working out the ratios between each part of the coin (the torus, cylinder, and two numerals) I encapsulated that logic in a function whose arguments include a scaling factor

I liked the moving lights in the original example, but they didn't light up the scene consistently. So, I replaced them with two directional lights which shine from the left and right sides. This makes the lighting less dynamic, but keeps the coins well lit, and their spinning is enough to produce sparkling patterns.

Below is a screenshot of the same coins in the same positions, but lit from the left and right sides only.

Left/Right lighting

At this point, the coins were still moving from left to right across the screen. I tried different orientations for their movement, and found I liked it best when they were aimed at the camera. It gives the appearance of flying through a field of them, or having them dropped from above, and reminds me of those old Windows screensavers.

Improvements

At first, the distribution of coins was limited to a rectangular prism. It didn't look bad, but with so many coins, the rectangular edges could be clearly seen. So, I changed it to be a cylindrical channel of coins. To do that, a random distribution over the circle at one end of the cylinder was created using the method described here.

By calculating for the unit circle and multiplying by my circle's radius, it gives a random scatter of coins. However, calculating a square root during every coin's initial placement and replacement (when it drifts off-screen then respawns at the start) ruined the framerate. To fix this issue, an array of random positions (equal to 5x the total number of coins) is pre-calculated. Then, one of those positions is randomly selected whenever a coin needs a new spawn position.

Instead of calculating only the spawn positions and advancing each coin forward manually, I instead calculated paths, which are unrendered lines made of two 3d vectors (one at the top and one at the bottom of the cylinder). Using a path, each coin's position can be set as a percentage of the total length of the path, and Three.js handles the calculation of the actual vector in 3d space. Once the coin's position advances more than 100% along the path, a new pre-generated path is chosen, and the coin is reset to the start of that path's position.


calculateCoinPaths() {
    this.coinPaths = [];
    let numPaths = this.opts.totalCoins * 5;
    for (var i = 0; i < numPaths; i++) {
        let unitRadius = Math.random();
        let unitRadiusRoot = Math.sqrt(unitRadius);
        let theta = Math.random() * 2 * Math.PI;
        let scaledRadius = unitRadiusRoot * this.opts.radius;

        // because of the default orientation of the cylinder,
        // the position is plotted on x/z (looking top down) instead of x/y
        let x = scaledRadius * Math.cos(theta);
        let z = scaledRadius * Math.sin(theta);

        let startVector = new THREE.Vector3(x, this.opts.length / 2, z);
        let endVector = new THREE.Vector3(x, -1 * (this.opts.length / 2), z);
        let lineCurve = new THREE.LineCurve3(startVector, endVector);
        this.coinPaths.push(lineCurve);
    }
}

All paths directly in the middle of the cylinder are deliberately left out so that coins never "hit" the camera. Instead, they pass very close by, which gives a nice effect and lets you see the coin detail up close. This is done during the random path generation.

When any path's x or z value (not x/y in this case since the cylinder is oriented with each end being on the x/z plane) is less than the absolute value of the coin's radius, that position is instead adjusted to be equal to the radius. This means that any path that would have appeared within one coin's radius of the center of the cylinder is instead pushed out to that threshold. This slightly increases the probability that a coin will fly by close to the camera.


// exclude paths from an area around the center equal
// to a coin's radius since that's where the camera will be placed
if (this.opts.excludeCenter) {
    if (Math.abs(x) < Coins.coinRadius) {
        x = Coins.coinRadius * (x < 0 ? -1 : 1);
    }
    if (Math.abs(z) < Coins.coinRadius) {
        z = Coins.coinRadius * (z < 0 ? -1 : 1);
    }
}

Finally, I added a little scaling animation for when a coin is first spawned at the beginning of its path. During the first 5% of the path, the coin scales from tiny to full size. This fixes the pop-in effect of newly spawned coins, and instead makes it look as if they're quickly arriving from the distance.


// scale coins starting their path so they don't
// just pop into existence which is slightly jarring
let scale = coin.pathProgress <= this.opts.scalePoint
    ? Math.max(coin.pathProgress / this.opts.scalePoint, 0.0001)
    : 1;
coin.scale.set(scale, scale, scale);

Performance

The total number of polygons is one of the most important things to consider for performance. Even with zero movement calculations, increasing the amount of completely stationary coins has a huge effect on framerate. According to Chrome's profiler, the rotation calculations, coin movement, and resetting take very little time each frame.

Adding even a few more small polygons to the numeral bevel had a terrible effect on framerate, even though it made very little visual improvement. For the best performance, I reduced the bevel to exactly two steps. This way, more coins can fit on the screen, while there's still enough of a bevel to catch the light.

Optimization

The number of coins and the radius in which they spawn is optimized for different screen sizes. I get 60fps on my desktop, phone, and tablet and they all look about the same, even though different amounts of coins render on each. On smaller screens it spawns fewer coins, but that's ok since fewer coins can be seen due to the smaller viewport.

There are some additional small improvements I'd like to make:

Currently, spawning is random enough that coins mostly don't intersect one another, but I'd like to guarantee this. I've sketched out a way to do it, and will write about it when implemented.

Sometimes it's hard to tell, but I don't think the rotations look 100% natural. They're done using Euler angles, which apply rotations in a particular order (XYZ). Using quaternions for rotations should solve this, and it'll be a good excuse to refresh my knowledge.

Finally, it'd be nice to dynamically control the size of the cylinder and number of coins after the animation has started so that the whole thing could be truly responsive. Then the entire animation would update itself seamlessly when the screen size changes without breaking the current animation.

I hope you've found this helpful! I enjoyed learning how to use Three.js, and may try a library like babylon.js next time for a different perspective.