Transforming 3D Design: Crafting Interactive Clusters with Three.js and TSL

Aug 12, 2026 845 views

Creating an Interactive 3D Cluster with Three.js, TSL and Three Start

This tutorial guides you through the transformation of a basic icosahedron into a lively, interactive 3D cluster, utilizing Three.js, TSL, WebGPU, noise, interaction, and post-processing techniques.

3D Cluster Example

Editor’s note: As we gear up for the inaugural Three.js Conference in Paris this September, we're excited to highlight the work of Francesco Michelini. In this detailed tutorial, he walks us through the steps of creating a dynamic, interactive 3D cluster, focusing on essential technologies such as Three.js, TSL, WebGPU, and three-start.

🎟️ Haven't grabbed your ticket yet? Through our collaboration with the first Three.js Conference, Codrops readers can enjoy a 15% discount using the code CODROPS. Get your ticket here

If you’re anything like me, you often find yourself scrolling through social platforms, searching for ideas—be it for client work or personal projects.

This tutorial was inspired by a post I discovered on Instagram, which eventually blossomed into this interactive visual piece.

Disclaimer: This tutorial doesn't dive deep into every facet of Three.js or specific bundler configurations. I’ll leave the finer details for you to explore!

Understanding the Components of the Effect

Let's outline the key elements of our interactive experience:

  • A rotating icosahedron with extruded faces.
  • Dynamic scaling of the faces using a built-in noise function.
  • A dithering post-processing effect.

The highlight here? Two of these three essential components come pre-packaged with Three.js, making our job easier!

Tools We'll Use

Introducing Three Start

three-start is an emerging library design that simplifies starting a Three.js project with minimal code, effortlessly handling essentials like the render loop, renderer, camera, and resizing events.

What stands out about three-start is its modular approach, allowing you to break down a Three.js application into components and modules, enhancing maintainability.

Modules serve as global features that maintain a constant presence across your application, handling tasks like asset loading and physics. Components define behaviors applicable to individual Object3D elements.

For example, you could create a Spin component that specifies an axis and speed parameter. This allows you to rotate objects consistently across the scene—potentially even on multiple axes and speeds.

Let's jump right in and start building!

Setting Up the Project

To kick things off, install the necessary packages:

$ pnpm add three three-start gsap

Next, establish the fundamental 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>

In your /src/main.js file, set up 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 messages on each update to verify functionality.
starter.ctx.on(ThreeContextEvents.Update, () => {
  console.log('update')
})

You’ll open your browser and, well, see... nothing but a black screen. But check the console—if it’s logging a lot of update messages, then you’re on the right track!

Initiating Our Icosahedron

First, create the material in materials/Inner.js for better organization:

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

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

Important: We're rendering the back faces intentionally with side: BackSide for a specific effect, and we’ll revisit this as we progress.

Next, incorporate the mesh into 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 we have it, our igloo-shaped icosahedron!

Icosahedron Representation

Before diving deeper, let’s introduce a Spin behavior through three-start. Create 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();
    this.object.rotation.y += dt * this.speed; // This ensures continuous rotation.
  }

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

Creating a behavior isn’t difficult—just extend the Object3DBehaviour class and manage everything through lifecycle hooks.

Add the Spin behavior to the Icosahedron in main.js:

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

...

// Now add it to the mesh, with `-0.4` as the speed parameter for the Spin class.
addComponent(innerMesh, Spin, -0.4);

If you followed correctly, the mesh should be spinning now.

Keep in mind: If the visuals seem “off,” it's normal since we're rendering the inner faces of the mesh.

Creating and Extruding the Faces

Here's where the excitement kicks in! We need to create the facets of the icosahedron, utilizing a BatchedMesh for this.

Let's start with the material for these faces:

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

export const ClusterMaterial = new MeshNormalNodeMaterial()

Now, it’s time to handle the mesh itself:

// 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 has 3 vertices.

  // Initialize the batched mesh.
  const facesMesh = new THREE.BatchedMesh(
    numFaces,
    numVertices * 6,
    numVertices * 6,
    ClusterMaterial,
  );

  // Loop through the vertices, creating geometry for each face.
  for (let i = 0; i < numVertices; i += 9) {
    const [x1, y1, z1, x2, y2, z2, x3, y3, z3] = positionAttribute.array.slice(i, i + 9);

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

    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);
    instanceGeometry.translate(-faceCentroid.x, -faceCentroid.y, -faceCentroid.z);

    // Compute normals for the instance geometry.
    instanceGeometry.computeVertexNormals();
    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);

Key highlights include:

  • Creating a new BatchedMesh.
  • Iterating through vertices to construct geometries, nine at a time.
  • Computing normals and adding to the main mesh.

Here's how it looks now:

Faces of the Icosahedron

Extruding Each Face

Unfortunately, Three.js lacks a straightforward way to extrude faces, so we have to take a manual approach here.

In the previous step, we generated a new BufferGeometry for each face. This time, we’ll further build on this by extending those geometries, translating them outward along the faceDirection.

Recalling the vertices defined earlier:

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

We’ll generate additional vertices and extrude them a bit:

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

Check out the visual representation for clarity:

Extruded face visualization
...

// In the `createExtrudedFaces()` function, define the extrusion distance
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;

...

// Update `attributeArray` to include the new vertices
const attributeArray = new Float32Array([
  x1, y1, z1,
  x2, y2, z2,
  x4, y4, z4,

  x2, y2, z2,
  x5, y5, y5,
  x4, y4, z4,

  x2, y2, z2,
  x3, y3, z3,
  x5, y5, z5,

  x3, y3, z3,
  x6, y6, z6,
  x5, y5, z5,

  x3, y3, z3,
  x1, y1, z1,
  x6, y6, z6,

  x1, y1, z1,
  x4, y4, z4,
  x6, y6, z6,

  x4, y4, z4,
  x5, y5, z5,
  x6, y6, z6,
]);

Here’s the outcome:

Completed extrusion

With those six vertices, we laid the groundwork for all triangles that make up each instance of our BatchedMesh.

Almost there! Now let’s tackle the visual elements.

Animating with Noise

For the next phase, update materials/Cluster.js as follows:

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));
})()

This segment generates a noise value influenced by the centroid of each instance combined with the time variable. This noise, originally ranging from -1 to 1, is remapped to create an organic movement scale between 0.15 and 0.75.

Animated Cluster Effect

Final Touches on Colors

Let's hide the inner mesh by adjusting the InnerMaterial to complete our cluster project.

Looking Ahead

As we wrap up this deep dive into creating interactive 3D effects using TSL and Three.js, it's clear that the techniques we've explored are just the tip of the iceberg for developers venturing into this space. The ability to manipulate materials and create interactivity with minimal code is a promising advance in graphics programming. What stands out is the efficiency of using nodes for shaders and materials. By treating uniforms and functions as discrete modules, the workflow for developing complex visuals becomes considerably more manageable and less error-prone. If you're working in a similar tech stack, adopting this modular approach can streamline your development process and make collaboration smoother. Here’s an important takeaway: while diving into graphics programming can seem daunting, especially with libraries like TSL at play, persistence pays off. Once you grasp the initial complexities, the creative possibilities expand exponentially. You may find that the initial learning curve morphs into a powerful toolset that enhances your projects significantly. Keep an eye on upcoming advancements in WebGPU and similar technologies, as they're poised to push the boundaries of performance and visual fidelity even further. Engaging with communities around these tools can provide invaluable insights and inspiration. So, get hands-on, experiment, and contribute your findings back to the community. You never know - your next idea might just spark the development of something extraordinary.
Source: Francesco Michelini · tympanus.net

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Creating an Interactive 3D Cluster with Three.js, TSL and...