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
- Three.js
- TSL
- three-start
- GSAP
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!

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:

… 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, z1x2, y2, z2x3, y3, z3
Now, we will create three additional vertices to represent the extrusion:
x4, y4, z4x5, y5, z5x6, y6, z6
As they say, a picture is worth more than a thousand words:

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.

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