Editor’s note: With the inaugural Three.js Conference on the horizon this September in Paris, we're celebrating the vibrant Three.js community through this engaging tutorial by Francesco Michelini. He expertly leads us through the journey of transforming a basic icosahedron into a visually dynamic interactive cluster.

🎟️ Still no ticket? Readers of Codrops can use the code CODROPS to avail 15% off at the first Three.js Conference. Grab your ticket here

Often, I find myself scouring social media for creative sparks—whether for client projects or personal experimentation.

This tutorial was inspired by an Instagram post I came across, which eventually evolved into this interactive visualization.

Disclaimer: This tutorial’ll skip the basics of Three.js and won't cover specific bundler setups; I'll leave that for you to explore.

Breaking Down the Effect

Let’s outline what we aim to achieve:

  • A rotating icosahedron with extruded faces
  • Faces that scale dynamically using a built-in noise function
  • A dithered post-process effect

What's great is that two of these three elements are integrated into Three.js, streamlining our workflow.

Tools We'll Use

Understanding Three Start

three-start is an emerging library designed to simplify the initiation of any Three.js project while requiring minimal code. It automatically handles essential setups such as rendering and camera management.

One standout feature is its modular approach, permitting you to partition your Three.js application into manageable modules and components. For instance, a module could manage global aspects like asset loading or physics, while components deliver specific behaviors to individual Object3D instances.

Say you craft a Spin component that rotates an object based on provided axis and speed parameters. You could even stack this component multiple times if you wish to spin an object around various axes at different velocities.

Enough of the theory—let's dive into building!

Setting Up the Project

First up, install the necessary packages:

$ pnpm add three three-start gsap

Next, create a basic HTML structure:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>three-start</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

Lastly, within your /src/main.js file, initialize a basic Three.js scene:

import './style.css';
import * as THREE from "three/webgpu";
import { ThreeStart, ThreeContextEvents } from "three-start";

const starter = new ThreeStart();

starter.mount(document.getElementById("app"));
starter.start();

const { scene, camera } = starter.ctx;

camera.position.z = 4;

// Log updates for verification; this can be removed later.
starter.ctx.on(ThreeContextEvents.Update, () => {
    console.log('update');
});

You should see a blank screen upon opening the browser. Take a look at the console—if you spot the update messages churning out, you are on the right track!

Integrating Our Icosahedron

Let's first establish our material in materials/Inner.js to maintain organization.

import { MeshNormalNodeMaterial, BackSide } from 'three/webgpu'

export const InnerMaterial = new MeshNormalNodeMaterial({
  side: BackSide,
  flatShading: true,
});

Quick note: We're specifically rendering the back faces with side: BackSide for a reason that we'll clarify soon. The MeshNormalNodeMaterial is used temporarily to confirm functionality.

Next, let's introduce our mesh within main.js.

import { InnerMaterial } from "./materials/Inner";

const innerGeometry = new THREE.IcosahedronGeometry(1, 1);
const innerMesh = new THREE.Mesh(innerGeometry, InnerMaterial);
scene.add(innerMesh);

And there you have it—our icosahedron!

Icosahedron

Before we proceed, let’s incorporate a Spin behavior using three-start. We'll define this in behaviors/Spin.js.

import { Object3DBehaviour } from "three-start";

export class Spin extends Object3DBehaviour {
    #initRotY = 0;
    speed = 1;

    constructor(speed = 1) {
        super();
        this.speed = speed;
    }

    onUpdate() {
        const dt = this.ctx.getDeltaTime(); // Frame-independent rotation
        this.object.rotation.y += dt * this.speed;
    }

    onDestroy() {
        this.object.rotation.y = this.#initRotY; // Reset rotation
    }
}

Creating a new behavior is quite straightforward: extend the Object3DBehaviour class, and manage components through dedicated lifecycle hooks!

Finally, attach the Spin behavior to the Icosahedron in main.js.

// Import the addComponent module from three-start
import { ThreeStart, ThreeContextEvents, addComponent } from "three-start";

addComponent(innerMesh, Spin, -0.4); // Add Spin behavior, rotating at -0.4 speed

Done! If implemented correctly, the mesh should now spin.

Note: If aesthetics appear “off,” don’t worry—it’s expected since we’re currently visualizing the inner faces of the mesh.

Forming the Faces

Now we get into the exciting part where we create all the faces, using a BatchedMesh in the process.

First, let’s define our material.

// materials/Cluster.js
import { MeshNormalNodeMaterial } from 'three/webgpu';

export const ClusterMaterial = new MeshNormalNodeMaterial();

Next, we build the actual mesh:

// main.js
import { ClusterMaterial } from './materials/Cluster';

function createExtrudedFaces(mesh) {
    if (!mesh) return console.error('Mesh is required');
  
    const { geometry } = mesh;
    const { position: meshPosition } = mesh;
    const positionAttribute = geometry.getAttribute('position');
    const { length: numVertices } = positionAttribute.array;
    const faceCentroid = new THREE.Vector3();
    const faceDirection = new THREE.Vector3();
    const instanceMatrix = new THREE.Matrix4();
  
    const numFaces = numVertices / 9; // Each face comprises 3 vertices, each with 3 components
  
    // Create our batched mesh
    const facesMesh = new THREE.BatchedMesh(
        numFaces,
        numVertices * 6, // Allocate enough for extrusions
        numVertices * 6, 
        ClusterMaterial,
    );
  
    for (let i = 0; i < numVertices; i += 9) {
        // Extract the vertices of the face.
        const x1 = positionAttribute.array[i];
        const y1 = positionAttribute.array[i + 1];
        const z1 = positionAttribute.array[i + 2];

        const x2 = positionAttribute.array[i + 3];
        const y2 = positionAttribute.array[i + 4];
        const z2 = positionAttribute.array[i + 5];

        const x3 = positionAttribute.array[i + 6];
        const y3 = positionAttribute.array[i + 7];
        const z3 = positionAttribute.array[i + 8];

        // Calculate the face centroid
        faceCentroid.set(x1 + x2 + x3, y1 + y2 + y3, z1 + z2 + z3).divideScalar(3);
        // Determine the face normal
        faceDirection.copy(faceCentroid).sub(meshPosition).normalize();

        // Construct the instance geometry
        const instanceGeometry = new THREE.BufferGeometry();
        const attributeArray = new Float32Array([
            x1, y1, z1,
            x2, y2, z2,
            x3, y3, z3,
        ]);

        const posAttribute = new THREE.Float32BufferAttribute(attributeArray, 3);
        instanceGeometry.setAttribute('position', posAttribute);
        // Use our centroid for positioning
        instanceGeometry.translate(-faceCentroid.x, -faceCentroid.y, -faceCentroid.z);
        instanceGeometry.computeVertexNormals(); // Compute the normals

        // Register geometry in the batched mesh
        const instanceGeometryID = facesMesh.addGeometry(instanceGeometry);
        const instanceID = facesMesh.addInstance(instanceGeometryID);
        instanceMatrix.makeTranslation(faceCentroid.x, faceCentroid.y, faceCentroid.z);
        facesMesh.setMatrixAt(instanceID, instanceMatrix);
    }

    innerMesh.add(facesMesh);
}

createExtrudedFaces(innerMesh);

Here’s a quick summary of the process:

  • Initialize a new BatchedMesh.
  • Loop through the vertices, constructing new geometries for each face.
  • Compute vertex normals.
  • Add geometries to the batched mesh.
  • Integrate the batched mesh back into the primary mesh.

Now, here’s how it looks:

Extruded Faces

… also Extrusion

Unfortunately, Three.js doesn't natively support face extrusion, so we’ll need to get creative and do this manually.

In our earlier step, we defined a new BufferGeometry for each face. Now, we’ll enhance this by adding vertices that extend outward along the faceDirection vector.

We defined three core vertices:

  • x1, y1, z1
  • x2, y2, z2
  • x3, y3, z3

Now, we will create three additional vertices to represent the extrusion:

  • x4, y4, z4
  • x5, y5, z5
  • x6, y6, z6

As they say, a picture is worth more than a thousand words:

Extrusion Visualization
const faceExtrusion = 0.45;

const x4 = x1 + faceDirection.x * faceExtrusion;
const y4 = y1 + faceDirection.y * faceExtrusion;
const z4 = z1 + faceDirection.z * faceExtrusion;

const x5 = x2 + faceDirection.x * faceExtrusion;
const y5 = y2 + faceDirection.y * faceExtrusion;
const z5 = z2 + faceDirection.z * faceExtrusion;

const x6 = x3 + faceDirection.x * faceExtrusion;
const y6 = y3 + faceDirection.y * faceExtrusion;
const z6 = z3 + faceDirection.z * faceExtrusion;

With these extra vertices, we can complete the triangles that form each instance of our BatchedMesh.

Great! We're almost there—next, we'll turn our attention to the visuals.

Animating Instances with Noise

Let’s transition to materials/Cluster.js and adjust it this way:

import { MeshNormalNodeMaterial } from 'three/webgpu';
import { attribute, positionLocal, Fn, float, mx_noise_float, time } from 'three/tsl';

export const ClusterMaterial = new MeshNormalNodeMaterial();

const scaleMin = float(0.15);
const scaleMax = float(0.75);
const centered = attribute('position', 'vec3');
const centroid = positionLocal.sub(centered);

ClusterMaterial.positionNode = Fn(() => {
    const t = time.mul(0.5);
    const noise = mx_noise_float(centroid.yz.add(t));
    noise.remapAssign(-1, 1, scaleMin, scaleMax);

    return centroid.add(centered.mul(noise));
})()

In this snippet, we're creating a noise value using the instance's centroid and the current time as a seed. This noise value fluctuates between -1 and 1 by default. By remapping it to the 0.15 - 0.75 range, we achieve an organic, dynamic motion.

Animated Instances

Next Steps: Coloring

We can hold off on the inner mesh for now and will make adjustments to the InnerMaterial soon.