Learn Creative Coding (#66) - Procedural Mesh Generation

in StemSocialyesterday

Learn Creative Coding (#66) - Procedural Mesh Generation

cc-banner

Last episode we filled 3D space with particles -- points drifting through noise fields, emitters spawning and recycling, InstancedMesh giving us shaped particles with real geometry. Half a million stars in a spiral galaxy. All of that used existing primitives as the building blocks (tetrahedra, point sprites, spheres).

But what about the meshes themselves? In episode 63 we built terrain from a flat grid, displacing vertices with noise. That was one specific technique -- deform a plane. This episode goes further. We're generating complex 3D forms entirely from code. Not starting from any primitive at all in some cases. Terrain with erosion-sculpted islands, displacement on arbitrary meshes turning spheres into asteroids, marching cubes extracting organic surfaces from volumetric data, crystal growth from recursive extrusion, and tubular geometry wrapping circles along 3D curves.

These are the techniques behind procedural worlds in games, organic creature generators, generative sculpture, and mathematical art. And they all boil down to the same idea from ep063: fill a Float32Array with computed positions, connect them with triangles, hand it to Three.js. The creativity is in the computation.

Terrain generation: islands from noise

We made terrain in ep063 with layered noise. Let's go deeper. A flat noise heightmap creates rolling hills, but real landscapes have features -- cliffs, plateaus, coastlines, erosion channels. We can sculpt these with mathematical operations applied after the initial noise pass.

The island trick: multiply your noise heightmap by a radial falloff. Points near the center keep their height, points near the edge get pushed down below zero. The result is an island floating in empty space with natural-looking coastlines:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080c12);

const camera = new THREE.PerspectiveCamera(
  60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 8, 12);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// noise function (same layered approach from ep012/ep063)
function noise2D(x, y) {
  const n = Math.sin(x * 1.3 + y * 0.7) * Math.cos(y * 1.1 - x * 0.9) *
            Math.sin(x * 0.5 + y * 1.4) * 2;
  return n;
}

function createIslandTerrain(segments, size, heightScale) {
  const seg1 = segments + 1;
  const vertCount = seg1 * seg1;
  const positions = new Float32Array(vertCount * 3);
  const colors = new Float32Array(vertCount * 3);

  let vi = 0;
  for (let iz = 0; iz <= segments; iz++) {
    for (let ix = 0; ix <= segments; ix++) {
      const x = (ix / segments - 0.5) * size;
      const z = (iz / segments - 0.5) * size;

      // layered noise
      let h = 0;
      h += noise2D(x * 0.2, z * 0.2) * 1.0;
      h += noise2D(x * 0.5, z * 0.5) * 0.5;
      h += noise2D(x * 1.1, z * 1.1) * 0.25;
      h += noise2D(x * 2.3, z * 2.3) * 0.1;

      // radial falloff: distance from center normalized to [0, 1]
      const dx = x / (size * 0.5);
      const dz = z / (size * 0.5);
      const dist = Math.sqrt(dx * dx + dz * dz);

      // smooth falloff that creates cliff edges
      const falloff = 1.0 - Math.pow(Math.min(dist, 1.0), 2.5);

      h = h * falloff * heightScale;

      // below sea level becomes flat water
      const seaLevel = -0.3;
      const finalH = Math.max(h, seaLevel);

      positions[vi * 3] = x;
      positions[vi * 3 + 1] = finalH;
      positions[vi * 3 + 2] = z;

      // color by height and slope
      const t = (finalH - seaLevel) / (heightScale * 1.2 - seaLevel);
      if (finalH <= seaLevel + 0.05) {
        colors[vi * 3] = 0.05; colors[vi * 3 + 1] = 0.15; colors[vi * 3 + 2] = 0.4;
      } else if (t < 0.1) {
        colors[vi * 3] = 0.65; colors[vi * 3 + 1] = 0.6; colors[vi * 3 + 2] = 0.35;
      } else if (t < 0.5) {
        colors[vi * 3] = 0.12; colors[vi * 3 + 1] = 0.4; colors[vi * 3 + 2] = 0.1;
      } else if (t < 0.75) {
        colors[vi * 3] = 0.35; colors[vi * 3 + 1] = 0.3; colors[vi * 3 + 2] = 0.25;
      } else {
        colors[vi * 3] = 0.85; colors[vi * 3 + 1] = 0.88; colors[vi * 3 + 2] = 0.9;
      }

      vi++;
    }
  }

  // index buffer
  const indices = [];
  for (let iz = 0; iz < segments; iz++) {
    for (let ix = 0; ix < segments; ix++) {
      const a = iz * seg1 + ix;
      const b = a + 1;
      const c = a + seg1;
      const d = c + 1;
      indices.push(a, c, b);
      indices.push(b, c, d);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  geo.setIndex(indices);
  geo.computeVertexNormals();
  return geo;
}

const island = new THREE.Mesh(
  createIslandTerrain(120, 16, 3.0),
  new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.8 })
);
scene.add(island);

The Math.pow(dist, 2.5) falloff is what creates the cliff-edge feeling. Lower exponents give gentle slopes to the coastline, higher exponents make it drop off sharply. At 2.5 you get a nice middle ground -- the terrain rolls gently in the center but falls away more steeply near the edge.

The Math.max(h, seaLevel) clamp creates a flat water plane. Any terrain that drops below sea level gets clamped to that height, giving you a flat blue surface around the island. If you wanted actual water you'd add a separate semi-transparent plane at y = seaLevel, but the vertex-colored approximation already reads well.

Displacement on arbitrary meshes

In ep063 we displaced a flat grid. In ep064 we displaced a sphere in a vertex shader. But you can displace ANY mesh along its normals using the same idea -- just in JavaScript instead of GLSL. Take a sphere, push each vertex outward or inward along its normal based on noise, and you get an asteroid. Take a torus, do the same, and you get an organic ring.

function displaceAlongNormals(geometry, noiseScale, amplitude) {
  const pos = geometry.attributes.position.array;
  const norm = geometry.attributes.normal.array;
  const count = pos.length / 3;

  for (let i = 0; i < count; i++) {
    const i3 = i * 3;
    const x = pos[i3], y = pos[i3 + 1], z = pos[i3 + 2];

    // layered 3D noise at vertex position
    let n = 0;
    n += noise3D(x * noiseScale, y * noiseScale, z * noiseScale) * 0.6;
    n += noise3D(x * noiseScale * 2, y * noiseScale * 2, z * noiseScale * 2) * 0.3;
    n += noise3D(x * noiseScale * 4, y * noiseScale * 4, z * noiseScale * 4) * 0.1;

    // center around zero so we get both bumps and dents
    n = (n - 0.5) * 2.0;

    // displace along normal
    pos[i3] += norm[i3] * n * amplitude;
    pos[i3 + 1] += norm[i3 + 1] * n * amplitude;
    pos[i3 + 2] += norm[i3 + 2] * n * amplitude;
  }

  geometry.computeVertexNormals();
  return geometry;
}

// noise3D - same hash-based noise from ep065
function hash3D(x, y, z) {
  let px = x * 443.897 % 1;
  let py = y * 441.423 % 1;
  let pz = z * 437.195 % 1;
  if (px < 0) px += 1;
  if (py < 0) py += 1;
  if (pz < 0) pz += 1;
  const dot = px * (py + 19.19) + py * (pz + 19.19) + pz * (px + 19.19);
  return ((px + py) * pz + dot) % 1;
}

function noise3D(x, y, z) {
  const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
  const fx = x - ix, fy = y - iy, fz = z - iz;
  const sx = fx * fx * (3 - 2 * fx);
  const sy = fy * fy * (3 - 2 * fy);
  const sz = fz * fz * (3 - 2 * fz);

  const a = hash3D(ix, iy, iz);
  const b = hash3D(ix + 1, iy, iz);
  const c = hash3D(ix, iy + 1, iz);
  const d = hash3D(ix + 1, iy + 1, iz);
  const e = hash3D(ix, iy, iz + 1);
  const f = hash3D(ix + 1, iy, iz + 1);
  const g = hash3D(ix, iy + 1, iz + 1);
  const h = hash3D(ix + 1, iy + 1, iz + 1);

  const ab = a + (b - a) * sx;
  const cd = c + (d - c) * sx;
  const ef = e + (f - e) * sx;
  const gh = g + (h - g) * sx;
  const abcd = ab + (cd - ab) * sy;
  const efgh = ef + (gh - ef) * sy;

  return abcd + (efgh - abcd) * sz;
}

// asteroid: displaced sphere
const asteroidGeo = new THREE.SphereGeometry(1.5, 64, 64);
displaceAlongNormals(asteroidGeo, 1.8, 0.4);

const asteroid = new THREE.Mesh(
  asteroidGeo,
  new THREE.MeshStandardMaterial({ color: 0x665544, roughness: 0.9, flatShading: true })
);
asteroid.position.set(-4, 2, -2);
scene.add(asteroid);

// organic ring: displaced torus
const ringGeo = new THREE.TorusGeometry(2, 0.5, 32, 64);
displaceAlongNormals(ringGeo, 2.2, 0.15);

const ring = new THREE.Mesh(
  ringGeo,
  new THREE.MeshNormalMaterial()
);
ring.position.set(4, 2, -2);
scene.add(ring);

The beautiful thing about this approach: it works on ANY geometry with normals. BoxGeometry, CylinderGeometry, custom parametric surfaces from ep063, imported models. The normals tell you which direction is "outward" at each vertex, noise tells you how much to push. The result is always an organic deformation of the original shape -- you can still recognise what it was, but it's been given character, texture, life.

flatShading: true on the asteroid material makes each triangle face visible, which for a rocky asteroid is actually perfect -- it looks like faceted rock instead of smooth plastic. For the organic ring, NormalMaterial shows the curvature beautifully without needing lights.

Marching cubes: surfaces from volumetric data

This is the classic algorithm for extracting a mesh from a 3D scalar field. Imagine you have a function that returns a density value for any point in 3D space. Positive values are "inside" the surface, negative values are "outside". Marching cubes finds where the function crosses zero and generates triangles along that boundary.

The applications are huge: metaballs (blobby organic forms), isosurfaces of 3D noise (cave systems, alien terrain), implicit surfaces from mathematical equations. Any function f(x, y, z) = 0 can be visualized as a mesh.

The algorithm works by dividing space into a regular grid of cubes. For each cube, it checks which of the 8 corners are inside vs outside the surface. There are 256 possible configurations (2^8). A lookup table maps each configuration to a set of triangles that approximates the surface crossing through that cube.

// simplified marching cubes (the full lookup table is 256 entries)
// using Three.js's MarchingCubes helper for the actual implementation

import { MarchingCubes } from 'three/addons/objects/MarchingCubes.js';

const resolution = 48;
const mc = new MarchingCubes(
  resolution,
  new THREE.MeshPhysicalMaterial({
    color: 0x88aacc,
    roughness: 0.2,
    metalness: 0.3,
    clearcoat: 0.4
  }),
  false,  // enableUvs
  false,  // enableColors
  50000   // maxPolyCount
);
mc.isolation = 80;
mc.scale.set(3, 3, 3);
scene.add(mc);

function updateMetaballs(time) {
  mc.reset();

  // several moving metaballs
  const balls = [
    { x: Math.sin(time * 0.7) * 0.3, y: Math.cos(time * 0.5) * 0.2, z: Math.sin(time * 0.3) * 0.25 },
    { x: Math.cos(time * 0.4) * 0.25, y: Math.sin(time * 0.8) * 0.3, z: Math.cos(time * 0.6) * 0.2 },
    { x: Math.sin(time * 0.6 + 2) * 0.2, y: Math.cos(time * 0.3 + 1) * 0.25, z: Math.sin(time * 0.9) * 0.3 },
    { x: Math.cos(time * 0.5 + 3) * 0.3, y: Math.sin(time * 0.7 + 2) * 0.2, z: Math.cos(time * 0.4 + 1) * 0.25 },
    { x: 0, y: Math.sin(time * 0.2) * 0.1, z: 0 }  // central anchor
  ];

  for (const ball of balls) {
    mc.addBall(
      ball.x + 0.5,  // normalize to [0,1] range
      ball.y + 0.5,
      ball.z + 0.5,
      0.15,  // strength
      12     // subtract (controls how blobby the falloff is)
    );
  }
}

Each metaball creates a radial density field. Where fields from multiple balls overlap, the density adds up. The isosurface forms where the combined density crosses a threshold. Balls near each other merge smoothly -- that's the signature "blobby" metaball aesthetic. As they move apart, the bridge between them thins into a neck and then snaps, creating two separate blobs. The topology of the mesh changes dynamically.

The MarchingCubes helper in Three.js handles all the lookup table logic and generates the mesh every frame. You just add balls and it produces geometry. Resolution controls the grid density -- higher resolution means smoother surfaces but more triangles. 48 is a good balance for real-time animation.

For a more manual approach, you can write your own marching cubes that samples any arbitrary function:

function sampleField(x, y, z, time) {
  // 3D noise creates organic cave-like structure
  let v = 0;
  v += noise3D(x * 0.8 + time * 0.1, y * 0.8, z * 0.8) * 1.0;
  v += noise3D(x * 1.6, y * 1.6 + time * 0.05, z * 1.6) * 0.5;
  v += noise3D(x * 3.2, y * 3.2, z * 3.2 + time * 0.08) * 0.25;

  // spherical containment
  const dist = Math.sqrt(x * x + y * y + z * z);
  v -= dist * 0.4;

  return v;
}

That field function, when marched, produces an organic shape that's contained within a sphere (because of the distance subtraction) but has noise-driven bumps and cavities. Add time and it evolves. Subtract a plane equation and you cut the shape in half. Add two spherical fields and they merge. The whole thing is pure math driving geometry.

Crystal generation: recursive face extrusion

Crystals have angular, faceted geometry that looks nothing like organic noise shapes. The technique: start with a simple polyhedron (octahedron works well), pick faces at random, extrude them outward, and repeat recursively. Each extrusion creates a new facet that can itself be extruded, building up complex angular structures.

function createCrystal(iterations, extrudeChance, extrudeLength) {
  // start with an octahedron
  let geometry = new THREE.OctahedronGeometry(1, 0);

  for (let iter = 0; iter < iterations; iter++) {
    const pos = geometry.attributes.position.array;
    const faceCount = pos.length / 9;  // 3 vertices per face, 3 components per vertex
    const newPositions = [];

    for (let f = 0; f < faceCount; f++) {
      const fi = f * 9;
      const ax = pos[fi], ay = pos[fi+1], az = pos[fi+2];
      const bx = pos[fi+3], by = pos[fi+4], bz = pos[fi+5];
      const cx = pos[fi+6], cy = pos[fi+7], cz = pos[fi+8];

      if (Math.random() < extrudeChance) {
        // compute face center and normal
        const centerX = (ax + bx + cx) / 3;
        const centerY = (ay + by + cy) / 3;
        const centerZ = (az + bz + cz) / 3;

        // face normal via cross product
        const e1x = bx - ax, e1y = by - ay, e1z = bz - az;
        const e2x = cx - ax, e2y = cy - ay, e2z = cz - az;
        const nx = e1y * e2z - e1z * e2y;
        const ny = e1z * e2x - e1x * e2z;
        const nz = e1x * e2y - e1y * e2x;
        const len = Math.sqrt(nx * nx + ny * ny + nz * nz);

        // extrude point
        const scale = extrudeLength * (0.5 + Math.random() * 0.5);
        const px = centerX + (nx / len) * scale;
        const py = centerY + (ny / len) * scale;
        const pz = centerZ + (nz / len) * scale;

        // replace one triangle with three (fan from extruded point)
        newPositions.push(
          ax, ay, az, bx, by, bz, px, py, pz,
          bx, by, bz, cx, cy, cz, px, py, pz,
          cx, cy, cz, ax, ay, az, px, py, pz
        );
      } else {
        // keep original face
        newPositions.push(ax, ay, az, bx, by, bz, cx, cy, cz);
      }
    }

    const newGeo = new THREE.BufferGeometry();
    newGeo.setAttribute('position',
      new THREE.BufferAttribute(new Float32Array(newPositions), 3)
    );
    newGeo.computeVertexNormals();
    geometry.dispose();
    geometry = newGeo;
  }

  return geometry;
}

const crystal = new THREE.Mesh(
  createCrystal(4, 0.35, 0.4),
  new THREE.MeshPhysicalMaterial({
    color: 0x8844cc,
    roughness: 0.1,
    metalness: 0.2,
    clearcoat: 1.0,
    clearcoatRoughness: 0.1,
    transparent: true,
    opacity: 0.85
  })
);
crystal.position.set(0, 3, 0);
scene.add(crystal);

Each iteration picks ~35% of faces and pushes them outward. The replaced triangle becomes a three-sided pyramid (tetrahedron cap). After 4 iterations, the simple 8-face octahedron has become a complex angular crystal with dozens of facets, some protruding far out, others barely moved.

The randomness creates organic variation -- no two crystals are the same even with identical parameters. Change the seed (by controlling Math.random with a seeded RNG from ep024) and you get a reproducible crystal collection. The MeshPhysicalMaterial with clearcoat and transparency makes it look genuinely crystalline -- light reflects off the facets with sharp highlights while you can see partially through the form.

Lower the extrudeChance for fewer, bigger protrusions (like quartz spikes). Raise it for dense, bumpy surfaces (like pyrite). Multiple iterations compound -- a face extruded in iteration 1 can have its child faces extruded in iteration 2, creating branching structures.

Tubular geometry: meshes along curves

Three.js has TubeGeometry built in, but understanding how it works opens up creative possibilities. The idea: take a 3D curve (any path through space), and at every point along that curve, place a circle perpendicular to the curve direction. Connect the circles into a mesh. The result is a tube, pipe, tentacle, root, or blood vessel that follows any arbitrary path.

// custom 3D curve using noise
class NoiseCurve extends THREE.Curve {
  constructor(seed, length, noiseFreq) {
    super();
    this.seed = seed;
    this.length = length;
    this.freq = noiseFreq;
  }

  getPoint(t) {
    const s = t * this.length;
    const x = noise3D(s * this.freq + this.seed, 0, 0) * 3;
    const y = s;  // primary direction is up
    const z = noise3D(0, 0, s * this.freq + this.seed + 100) * 3;
    return new THREE.Vector3(x, y, z);
  }
}

// create several tentacle-like tubes
for (let i = 0; i < 8; i++) {
  const curve = new NoiseCurve(i * 7.3, 5, 0.4);
  const tubeGeo = new THREE.TubeGeometry(
    curve,
    64,     // tubular segments (along curve)
    0.08 - i * 0.005,  // radius (thinner for each)
    12,     // radial segments (around circle)
    false   // closed
  );

  const tube = new THREE.Mesh(
    tubeGeo,
    new THREE.MeshStandardMaterial({
      color: new THREE.Color().setHSL(0.05 + i * 0.02, 0.6, 0.35),
      roughness: 0.7
    })
  );
  tube.position.set(
    (Math.random() - 0.5) * 2,
    -2,
    (Math.random() - 0.5) * 2
  );
  scene.add(tube);
}

The NoiseCurve class extends Three.js's Curve base class. The only method you need is getPoint(t) where t goes from 0 to 1 and returns a 3D position. Three.js handles computing tangents, generating the circular cross-sections, and building the mesh. The curve can be anything -- noise paths, helixes, bezier curves, mathematical spirals.

Varying the radius along the curve creates organic taper. Instead of a constant radius, pass a function:

// tapered tube: thick at base, thin at tip
class TaperedTube extends THREE.TubeGeometry {
  // Three.js doesn't natively support varying radius in TubeGeometry
  // so we build it manually
}

function createTaperedTube(curve, segments, radiusFn, radialSegs) {
  const positions = [];
  const indices = [];
  const normals = [];

  for (let i = 0; i <= segments; i++) {
    const t = i / segments;
    const point = curve.getPoint(t);
    const tangent = curve.getTangent(t).normalize();

    // build a frame (normal and binormal perpendicular to tangent)
    const up = Math.abs(tangent.y) < 0.99
      ? new THREE.Vector3(0, 1, 0)
      : new THREE.Vector3(1, 0, 0);
    const normal = new THREE.Vector3().crossVectors(tangent, up).normalize();
    const binormal = new THREE.Vector3().crossVectors(tangent, normal).normalize();

    const radius = radiusFn(t);

    for (let j = 0; j <= radialSegs; j++) {
      const angle = (j / radialSegs) * Math.PI * 2;
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);

      const px = point.x + (normal.x * cos + binormal.x * sin) * radius;
      const py = point.y + (normal.y * cos + binormal.y * sin) * radius;
      const pz = point.z + (normal.z * cos + binormal.z * sin) * radius;

      positions.push(px, py, pz);

      // normal points outward from tube center
      const nx = normal.x * cos + binormal.x * sin;
      const ny = normal.y * cos + binormal.y * sin;
      const nz = normal.z * cos + binormal.z * sin;
      normals.push(nx, ny, nz);
    }
  }

  // connect rings into triangles
  const ringSize = radialSegs + 1;
  for (let i = 0; i < segments; i++) {
    for (let j = 0; j < radialSegs; j++) {
      const a = i * ringSize + j;
      const b = a + 1;
      const c = a + ringSize;
      const d = c + 1;
      indices.push(a, c, b);
      indices.push(b, c, d);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
  geo.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3));
  geo.setIndex(indices);
  return geo;
}

// a root that tapers from thick to thin
const rootCurve = new NoiseCurve(42, 6, 0.3);
const rootGeo = createTaperedTube(
  rootCurve, 80,
  (t) => 0.15 * (1 - t * 0.85),  // thick at base (t=0), thin at tip (t=1)
  10
);

const root = new THREE.Mesh(
  rootGeo,
  new THREE.MeshStandardMaterial({ color: 0x4a3520, roughness: 0.9 })
);
scene.add(root);

The Frenet-Serret frame (tangent, normal, binormal) is the mathematical backbone of tubular geometry. At every point along the curve, you need three perpendicular directions: the tangent (which way the curve is going), the normal (which way the curve is turning), and the binormal (perpendicular to both). These three vectors form a local coordinate system that rides along the curve. The circular cross-section is placed in the normal-binormal plane.

The radiusFn(t) callback controls thickness along the curve. Linear taper (1 - t * 0.85) creates a root or tentacle. Sinusoidal (0.1 + Math.sin(t * 10) * 0.03) creates a segmented worm. Step function creates a chain of beads. The same tube structure, radicaly different aesthetics just by changing one function.

Mesh booleans: cutting and merging geometry

Boolean operations (union, intersection, subtraction) let you combine meshes in powerful ways. Cut a cylindrical hole through a cube. Merge two overlapping spheres into one smooth form. Subtract a complex shape from a block to carve sculptures.

// three-bvh-csg library for Boolean operations
import { Evaluator, Brush } from 'three-bvh-csg';

const evaluator = new Evaluator();

// create brushes (the operands)
const boxBrush = new Brush(new THREE.BoxGeometry(2, 2, 2));
boxBrush.material = new THREE.MeshStandardMaterial({ color: 0xccaa88, roughness: 0.5 });

const sphereBrush = new Brush(new THREE.SphereGeometry(1.3, 32, 32));
const cylBrushX = new Brush(new THREE.CylinderGeometry(0.5, 0.5, 3, 24));
const cylBrushY = new Brush(new THREE.CylinderGeometry(0.5, 0.5, 3, 24));
cylBrushY.rotation.x = Math.PI / 2;
cylBrushY.updateMatrixWorld();
const cylBrushZ = new Brush(new THREE.CylinderGeometry(0.5, 0.5, 3, 24));
cylBrushZ.rotation.z = Math.PI / 2;
cylBrushZ.updateMatrixWorld();

// intersection of box and sphere (rounded cube)
let result = evaluator.evaluate(boxBrush, sphereBrush, Evaluator.INTERSECT);

// subtract cylinders through all three axes
result = evaluator.evaluate(result, cylBrushX, Evaluator.SUBTRACT);
result = evaluator.evaluate(result, cylBrushY, Evaluator.SUBTRACT);
result = evaluator.evaluate(result, cylBrushZ, Evaluator.SUBTRACT);

result.material = new THREE.MeshStandardMaterial({
  color: 0xddbb77,
  roughness: 0.4,
  metalness: 0.1
});
scene.add(result);

The result: a rounded cube (box intersected with sphere gives the corners soft curves) with three cylindrical tunnels cut through it on each axis. Like a sci-fi artifact or an ancient puzzle box. Each boolean operation produces a new mesh with proper vertex positions, normals, and clean topology.

Booleans are computationally expensive compared to the other techniques in this episode. Each operation walks both meshes, finds intersections, splits triangles, and stitches the result. For real-time creative coding you'd precompute the booleans and then animate the result, rather than running boolean ops every frame. But for generating static sculpture pieces or architectural elements, they're incredibly powerful.

Saving generated meshes

Once you've procedurally generated something beautiful, you might want to export it for 3D printing, rendering in another engine, or sharing. Three.js has exporters for common formats:

import { STLExporter } from 'three/addons/exporters/STLExporter.js';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';

// export to STL (for 3D printing)
const stlExporter = new STLExporter();
const stlString = stlExporter.parse(crystal);
// create download link
const stlBlob = new Blob([stlString], { type: 'text/plain' });
const stlUrl = URL.createObjectURL(stlBlob);
const stlLink = document.createElement('a');
stlLink.href = stlUrl;
stlLink.download = 'crystal.stl';
stlLink.click();

// export to GLTF (for game engines, web viewers)
const gltfExporter = new GLTFExporter();
gltfExporter.parse(scene, (gltf) => {
  const output = JSON.stringify(gltf, null, 2);
  const blob = new Blob([output], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'scene.gltf';
  link.click();
}, { binary: false });

STL is pure geometry (triangles only, no color or material). Perfect for 3D printing where the slicer software adds its own settings. OBJ includes vertex colors and normals but no scene hierarchy. GLTF is the modern standard -- it includes materials, scene graph, animations, textures, everything. If you're exporting for web viewers or game engines, GLTF is what you want.

The crystal generator from earlier in this episode could literally power a 3D printing pipeline. Generate a crystal, export to STL, print it. Every seed gives a unique physical object. Generative sculpture made physical.

Creative exercise: floating island scene

Alright, lets bring it all together. A floating island with terrain, crystal formations, and hanging roots -- all procedurally generated from a single seed:

function createFloatingIsland(seed) {
  const group = new THREE.Group();

  // seeded random (simple LCG from ep024)
  let s = seed;
  function rand() {
    s = (s * 1664525 + 1013904223) & 0xffffffff;
    return (s >>> 0) / 4294967296;
  }

  // 1. island terrain (top)
  const islandGeo = createIslandTerrain(80, 6, 1.5);
  const islandMesh = new THREE.Mesh(
    islandGeo,
    new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.8 })
  );
  group.add(islandMesh);

  // 2. underside (mirrored terrain, darker)
  const underGeo = createIslandTerrain(60, 6, 1.2);
  const underPos = underGeo.attributes.position.array;
  for (let i = 1; i < underPos.length; i += 3) {
    underPos[i] = -Math.abs(underPos[i]) - 0.5;  // flip and push down
  }
  underGeo.computeVertexNormals();
  const underMesh = new THREE.Mesh(
    underGeo,
    new THREE.MeshStandardMaterial({ color: 0x3a2a1a, roughness: 0.95 })
  );
  group.add(underMesh);

  // 3. crystals on top
  for (let i = 0; i < 5; i++) {
    const cx = (rand() - 0.5) * 3;
    const cz = (rand() - 0.5) * 3;
    const crystalGeo = createCrystal(3, 0.3, 0.25 + rand() * 0.2);
    const crystalMesh = new THREE.Mesh(
      crystalGeo,
      new THREE.MeshPhysicalMaterial({
        color: new THREE.Color().setHSL(0.7 + rand() * 0.2, 0.6, 0.4),
        roughness: 0.1,
        clearcoat: 0.8,
        transparent: true,
        opacity: 0.8
      })
    );
    const scale = 0.2 + rand() * 0.3;
    crystalMesh.scale.set(scale, scale * 1.5, scale);
    crystalMesh.position.set(cx, 0.5 + rand() * 0.5, cz);
    crystalMesh.rotation.set(rand() * 0.3, rand() * Math.PI, rand() * 0.3);
    group.add(crystalMesh);
  }

  // 4. hanging roots underneath
  for (let i = 0; i < 6; i++) {
    const rx = (rand() - 0.5) * 3;
    const rz = (rand() - 0.5) * 3;

    const rootSeed = seed + i * 13.7;
    const rootCurve = new NoiseCurve(rootSeed, 2 + rand() * 2, 0.5);
    const rootGeo = createTaperedTube(
      rootCurve, 40,
      (t) => (0.06 + rand() * 0.04) * (1 - t * 0.9),
      8
    );

    const rootMesh = new THREE.Mesh(
      rootGeo,
      new THREE.MeshStandardMaterial({
        color: new THREE.Color().setHSL(0.08, 0.5, 0.2 + rand() * 0.1),
        roughness: 0.85
      })
    );
    rootMesh.position.set(rx, -0.5, rz);
    rootMesh.rotation.x = Math.PI;  // roots hang downward
    group.add(rootMesh);
  }

  return group;
}

const island = createFloatingIsland(12345);
island.position.y = 2;
scene.add(island);

// add lights
scene.add(new THREE.AmbientLight(0x222244, 1.2));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(5, 8, 3);
scene.add(sun);

// slow rotation
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();
  island.rotation.y = t * 0.05;

  controls.update();
  renderer.render(scene, camera);
}
animate();

One seed number produces an entire floating island with unique terrain shape, unique crystal formations (each with their own extruded geometry), unique hanging roots following noise-driven curves. Change the seed and everything regenerates differently. The seeded RNG from ep024 ensures reproducibility -- seed 12345 always produces the same island.

This is the generative art mindset applied to 3D sculpture. The artist doesn't sculpt a specific form -- they build a system that generates forms. Curate by exploring seeds. Find the ones that sing. Export them for printing or exhibition. The code is the art, the meshes are instances of it. Pretty cool if you ask me :-)

Performance notes

Procedural mesh generation happens once (or rarely) -- you compute the geometry, then render it as static meshes. This is fundamentally different from the per-frame particle updates in ep065. Once a mesh is built and uploaded to the GPU, rendering it costs almost nothing regardless of how complex the generation was.

Where to watch out:

  • computeVertexNormals() is O(n) where n is the number of triangles. For very complex meshes (100k+ triangles), it can take a noticeable moment. But it only runs once during generation.
  • Boolean operations are expensive. Each operation is roughly O(n * m) where n and m are the triangle counts of the two meshes. Chain five operations and it compounds. Precompute, don't do this per frame.
  • Crystal extrusion with high iteration counts explodes the face count. Each iteration can triple the face count (each extruded face becomes three). 4 iterations starting from 8 faces: worst case 8 * 3^4 = 648 faces. Starting from a subdivided mesh with 128 faces: could hit 100k+. Keep the starting geometry simple.
  • TubeGeometry with very high segment counts generates lots of triangles. 64 tubular segments * 12 radial segments = ~1,500 triangles per tube. 50 tubes = 75k triangles, which is still very comfortable for any GPU.

The general principle: generate once, render many frames. Procedural generation is a one-time cost that produces assets indistinguishable from hand-modeled ones. And because the generation is parametric, one piece of code can produce infinite variations.

What's ahead

We've covered the main procedural mesh techniques: terrain sculpting, noise displacement, marching cubes for implicit surfaces, recursive extrusion for crystals, and tube geometry along curves. These are the building blocks for procedural worlds.

Next episode we'll animate these meshes -- not just rotating them, but deforming geometry over time, morph targets, skeletal-like motion without bones, and creative approaches to making 3D forms come alive with motion. The meshes we build today become the actors in tomorrow's animations.

't Komt erop neer...

  • Island terrain from noise: layer multiple noise octaves for the heightmap, multiply by a radial falloff (distance from center raised to a power) to carve coastlines, clamp below sea level for water. The falloff exponent controls cliff steepness -- higher values give sharper edges
  • Normal displacement works on any mesh with normals. Sample 3D noise at vertex position, multiply by normal direction, add to position. Spheres become asteroids, tori become organic rings. flatShading: true for rocky faceted look, smooth normals for organic feel
  • Marching cubes extracts a triangle mesh from a 3D scalar field. The algorithm checks which cube corners are inside/outside and generates triangles along the boundary. Metaballs, noise isosurfaces, implicit mathematical surfaces -- any f(x,y,z) = threshold becomes renderable geometry
  • Crystal generation via recursive face extrusion: pick random faces, push them outward along their normal to form a pyramid cap, repeat. Each iteration adds facets and angular complexity. Seeded RNG (ep024) makes crystals reproducible
  • Tubular geometry places circular cross-sections along a 3D curve using the Frenet-Serret frame (tangent, normal, binormal). Vary the radius with a callback function for tapering -- roots, tentacles, worms, blood vessels. Three.js's TubeGeometry handles this, or build it manually for more control
  • Mesh booleans (union, intersection, subtraction) combine geometries. Intersect a box with a sphere for rounded cubes, subtract cylinders for tunnels. Expensive to compute but produces clean manifold meshes. Use for static sculptural pieces, not per-frame operations
  • Export procedural meshes with STLExporter (3D printing), OBJExporter (simple interchange), or GLTFExporter (materials + scene graph). Generated meshes become physical objects or game assets. The code is the artist, the meshes are the artworks
  • Floating island exercise combines everything: terrain for the top surface, inverted terrain for the underside, crystals from recursive extrusion, roots from tapered tube geometry. One seed produces a complete unique scene. Generative art in three dimensions

Sallukes! Thanks for reading.

X

@femdev