Three.js Experiments

Low-Poly 3D Clouds in Three.js

One of the six site backgrounds is a field of low-poly clouds drifting across the page. Each cloud is a cluster of squashed icosahedra rendered through a custom 3-band cel shader. Everything is drawn in a single draw call via InstancedMesh.

See it running here. Source on GitHub.

One geometry, many instances

A single jittered IcosahedronGeometry with subdivision level 2 is shared across every visible cloud blob. The component uses around 100 clusters by default, each with 3-15 blobs, so the scene has roughly 800-1500 instances. Drawing them as separate meshes would mean 800+ draw calls per frame. InstancedMesh keeps it at one.

const cloudGeo = makeCloudGeo(2, 0.25);
const instancedMesh = new THREE.InstancedMesh(cloudGeo, cloudMat, totalBlobs);

Each instance gets its own 4x4 transform matrix:

dummy.position.set(cluster.x + blob.ox, cluster.y + blob.oy, cluster.z + blob.oz);
dummy.scale.set(blob.sx, blob.sy, blob.sz);
dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix.clone());

The dummy is a throwaway Object3D used purely for matrix composition. setMatrixAt copies the result into the instanced buffer.

Jittering the icosahedron

A plain icosahedron is too regular. Walking through each vertex and pushing it in or out along its normal by a random factor breaks the symmetry without changing the topology:

function makeCloudGeo(detail, jitter) {
  const geo = new THREE.IcosahedronGeometry(1, detail);
  const pos = geo.attributes.position.array;
  for (let i = 0; i < pos.length; i += 3) {
    const len = Math.sqrt(pos[i]**2 + pos[i+1]**2 + pos[i+2]**2);
    const r = 1.0 + (Math.random() - 0.5) * jitter;
    pos[i]   = (pos[i]   / len) * r;
    pos[i+1] = (pos[i+1] / len) * r;
    pos[i+2] = (pos[i+2] / len) * r;
  }
  geo.computeVertexNormals();
  return geo;
}

computeVertexNormals matters as much as the jitter. With it, every face has its own flat normal computed from its three vertices, which is what makes the cel shader produce visible polygonal facets rather than smoothly interpolated shading.

Clusters of squashed blobs

Each cloud is a cluster, not a single blob. The cluster generator picks a random clusterSize, then scatters 3-15 blobs around the cluster centre with non-uniform scales:

blobs.push({
  ox: Math.cos(angle) * radius + (Math.random() - 0.5) * spread * 0.5,
  oy: (Math.random() - 0.5) * clusterSize * 0.3,
  oz: Math.sin(angle) * radius * 0.6,
  sx: baseScale * (1.2 + Math.random() * 1.5),  // wide
  sy: baseScale * (0.15 + Math.random() * 0.25), // very flat
  sz: baseScale * (0.8 + Math.random() * 1.0),   // moderate depth
});

The shape comes from the sy-to-sx ratio. Each blob is roughly 5x wider than it is tall. Layering several flat ellipsoids at slightly different positions and rotations produces the stacked, lumpy silhouette that reads as a cloud rather than a sphere.

Cluster size is sampled as 0.3 + Math.random() * Math.random() * 2.5. Multiplying two uniform randoms biases the distribution toward smaller values, so the scene has many small wisps and only a few large cloudbanks.

3-band cel shader

The fragment shader picks one of three colours based on the dot product of the surface normal and a fixed light direction:

float NdotL = dot(normal, uLightDir);
vec3 col;
if (NdotL > 0.3)       col = uHighlight;
else if (NdotL > -0.1) col = uColor;
else                   col = uShadow;

No smooth interpolation. The discontinuities between bands are what give the look its painterly, polygonal feel. A standard Lambert term would smooth the colour across each face and the polyhedral structure would dissolve.

A rim light gets added on top, fading in only where the surface is close to perpendicular to the view direction:

vec3 viewDir = normalize(cameraPosition - vWorldPos);
float rim = 1.0 - max(dot(viewDir, normal), 0.0);
rim = smoothstep(0.5, 1.0, rim);
col += rim * uHighlight * 0.3;

This catches the silhouette edges of the front-facing clouds and prevents them from disappearing into the background.

Fog by distance

Clouds in the back of the scene fade to the background colour using a simple distance-based fog:

float fogFactor = smoothstep(uFogNear, uFogFar, vFogDist);
col = mix(col, uBgColor, fogFactor);
gl_FragColor = vec4(col, uOpacity * (1.0 - fogFactor * 0.5));

vFogDist is computed in the vertex shader as the distance from the camera to the world-space position of each fragment. The smoothstep from uFogNear (20.0) to uFogFar (65.0) gives a smooth falloff. Opacity also drops with distance, so far clouds become both colour-faded and slightly transparent.

Drift and wrap

Each cluster has a speed field that scales with inverse size, so big clouds drift slowly and small ones zip across:

speed: 0.03 + Math.random() * 0.12 / clusterSize,

In the animation loop, the cluster’s horizontal position increases by time * cluster.speed every frame. Clusters that pass the right edge get teleported back to the left:

const halfW = viewWidth * 0.6;
if (dummy.position.x > halfW)  dummy.position.x -= viewWidth * 1.2;
if (dummy.position.x < -halfW) dummy.position.x += viewWidth * 1.2;

The wrap distance is wider than the visible area, so a cluster that wraps reappears far enough off-screen to never be visible during the teleport.

A vertical bob adds a second axis of motion:

const bob = Math.sin(time * 0.5 + cluster.phase) * 0.3;

Each cluster has its own random phase, so they bob out of sync and the scene never feels mechanical.

Mouse parallax

The mouse position is normalised to [-1, 1] and applied as an offset to each instance, scaled by an inverse-depth factor:

const depthFactor = 1.0 / (1.0 + Math.abs(cluster.z) * 0.05);
const mx = mouseCurrent.x * 2.0 * depthFactor;
const my = mouseCurrent.y * 1.0 * depthFactor;

Near clouds (small |z|) move more than far clouds. The result is a soft parallax that gives the scene a sense of depth even though the camera itself never moves.

The mouse target gets lerped each frame so the response is smoothed rather than instant:

mouseCurrent.lerp(mouseTarget, 0.03);

A factor of 0.03 means the cloud field catches up to the cursor over roughly 30 frames.

Per-frame matrix updates

Updating 1000+ matrices every frame is the bottleneck. The loop decomposes the base matrix once per instance, modifies the position, recomposes, and writes back:

for (let i = 0; i < basePositions.length; i++) {
  bp.matrix.decompose(pos, rot, scl);
  dummy.position.set(pos.x + drift + mx, pos.y + bob + my, pos.z);
  dummy.quaternion.copy(rot);
  dummy.scale.copy(scl);
  dummy.updateMatrix();
  instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;

The needsUpdate flag is set once at the end of the loop, not per-instance. Three.js then reuploads the whole instance buffer in a single call.

On a 2020-era laptop this runs at 60 fps without dropping frames. The instance count scales with viewport height, so a tall page renders more clouds without growing the per-cluster work.