Coins in Three.js

Minting coins 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

Browsing through the three.js examples to see what the library can do, this example caught my attention. The rings pictured are stationary, 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 fairly easy, as was moving them from right to left across the screen. Whenever one disappeared off the screen on the left side, it 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. 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 cylinder in the middle of the torus and changing the 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 won'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.


// 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 useful bevel feature in its geometry extrusion options. Without the bevel, the surface of the numerals is completely flat, so there aren't as many angled surfaces for light to reflect. With a small bevel, the faces of the coins glint as they turn.

After working out the ratios between each part of the coin, each coin can be given a single number representing the radius, and the rest of the geometry will scale correctly. They're currently set at 2.5 units, but they can easily be made larger or smaller.

I liked the moving lights in the original example, but they don't light up the scene consistently enough. Instead, two directional lights are used which shine from the left and right sides. This makes the lighting a little less dynamic, keeps the coins well lit, and their spinning is enough to produce sparkling patterns. Below is a screenshot of the same coins, lit from the left and right sides.

Left/Right lighting

At this point, the coins were still moving from left to right across the screen. Different orientations were tried, and 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 within a rectangular prism. It didn't look bad, but the rectangular edges could be clearly discerned. So, it was changed to be a cylindrical channel of coins. To do that a random distribution over the circle at one end of the cylinder was created. This was doing using the method described here.

By calculating this for the unit circle, then multiplying by my circle's radius, it gave 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. There are enough paths so it doesn't get repetitive, while saving a ton of processing power.

Instead of calculating only the spawn positions and advancing each coin forward manually, I instead calculated paths, which are essentially unrendered lines consisiting of two 3d vectors (one at the top and one at the bottom of the cylinder). With a path, each coin's position can be automatically set as a percentage of the total length of the path, which then produces the appropriate 3d vector. Once the coin's position advances more than 100% along the path, a new pre-generated path is randomly chosen, and the coin is reset to the beginning 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);
    }
}

Any paths directly in the middle of the cylinder are deliberately left out so coins never "hit" the camera. Instead, they pass very close by, which is a nice effect and lets you see the coin detail up close. This is achieved 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 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 a small circle representing the coin's radius. 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 when a coin is spawned at the beginning of the path. During the first 5% of the path, each coin scales from tiny and invisible to full size. This removes the effect of coins seeming to pop into existence when they spawn, and instead makes it look as if they're speedily arriving from the distance (and only if you look closely enough).


// 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 the most important thing. Adding even a few more polygons to the bevel had a drastic effect on framerate, but very little visual improvement. For the best performance, I reduced the bevel to a minimum of two steps. This way, more coins can fit on the screen.

The rotation calculations, coin movement, and resetting take surprisingly little time each frame. But, even with zero movement calculations, the number of stationary coins has a huge effect on framerate. This might be a limitation of three.js - I've heard that babylon.js is better for performance and has many more features. I may completely rewrite this using that library.

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 fewer coins can be seen anyway due to the screen size.

TODO

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

  • Spawning is random enough that coins rarely intersect one another, but I'd like to guarantee this. I've sketched out a way to do this, and will write about it when implemented.
  • Sometimes it's hard to tell, but I don't think the rotations look natural. They're using Euler angles, which apply rotations in a particular order (XYZ). Using quaternions for rotations should solve this, and it will give me a good excuse to refresh my knowledge of them.
  • It'd be nice to dynamically control the size of the cylinder and number of coins after the animation has started. That way it could update itself when the screen size changes without breaking the current animation.