Learn Creative Coding (#63) - Procedural Geometry in Three.js

in StemSocial6 hours ago

Learn Creative Coding (#63) - Procedural Geometry in Three.js

cc-banner

Last episode we got our feet wet with Three.js -- scene, camera, renderer, a spinning cube, some built-in primitives. And that was fun. But built-in geometry can only take you so far. A BoxGeometry is always a box. A SphereGeometry is always a sphere. If you want shapes that don't exist in any library -- terrain from noise, organic surfaces, parametric math sculptures, anything truly YOUR geometry -- you need to build meshes from scratch.

That's what BufferGeometry is for. It's the low-level API underneath every Three.js primitive. When you create a SphereGeometry(1, 32, 32), Three.js internally generates arrays of vertex positions, normals, and UVs and stuffs them into a BufferGeometry. Today we skip the convenience wrappers and work with those arrays directly. Vertices as numbers, triangles as index lists, normals computed from cross products.

Sounds intimidating, maybe. But it's actually the same idea as pixel manipulation from episode 10 -- instead of a flat array of RGBA values representing a 2D image, we have flat arrays of XYZ values representing a 3D mesh. Same pattern: allocate a typed array, fill it with computed values, hand it to the renderer. The creative power comes from what you compute.

By the end of this episode you'll be generating terrain from Perlin noise (hello again, ep012), coloring vertices by height, animating mesh positions in real time, and building parametric surfaces from math equations. Custom geometry is where Three.js creative coding really gets interesting :-)

BufferGeometry: vertices in flat arrays

Every mesh in Three.js is ultimately a BufferGeometry. It stores vertex data in typed arrays -- Float32Array for positions, normals, and colors. Each vertex has three components (x, y, z), packed sequentially:

import * as THREE from 'three';

// 3 vertices = 9 floats (x,y,z for each)
const positions = new Float32Array([
  -1, 0, 0,   // vertex 0
   1, 0, 0,   // vertex 1
   0, 1, 0    // vertex 2
]);

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position',
  new THREE.BufferAttribute(positions, 3)  // 3 components per vertex
);

const material = new THREE.MeshBasicMaterial({
  color: 0x44aacc,
  side: THREE.DoubleSide
});
const triangle = new THREE.Mesh(geometry, material);
scene.add(triangle);

That's a single triangle. Three vertices, nine numbers, one mesh. The 3 in BufferAttribute(positions, 3) tells Three.js "every three consecutive floats form one vertex position." This is the same idea as ImageData where every four consecutive bytes form one pixel (RGBA). Different stride, same concept.

DoubleSide on the material is important for flat geometry. Without it, the triangle is only visible from one side -- the front face, determined by vertex winding order (counterclockwise = front). Since we're building custom geometry, we might not always get the winding right, and DoubleSide saves us from staring at an invisible back face wondering why nothing renders.

Building a grid: your first procedural mesh

A single triangle isn't very useful. Let's build a plane -- a grid of vertices connected by triangles. This is the foundation for terrain, water surfaces, fabric simulation, anything that's essentialy a deformed sheet.

The idea: create a regular grid of width * height vertices, then connect them into triangles using an index buffer. Every four adjacent vertices form a quad, and every quad splits into two triangles.

function createPlaneGeometry(segW, segH, sizeW, sizeH) {
  const vertCount = (segW + 1) * (segH + 1);
  const positions = new Float32Array(vertCount * 3);
  const normals = new Float32Array(vertCount * 3);
  const uvs = new Float32Array(vertCount * 2);

  // generate vertex positions
  let vi = 0;  // vertex index
  for (let iy = 0; iy <= segH; iy++) {
    for (let ix = 0; ix <= segW; ix++) {
      const x = (ix / segW - 0.5) * sizeW;
      const z = (iy / segH - 0.5) * sizeH;

      positions[vi * 3 + 0] = x;
      positions[vi * 3 + 1] = 0;  // flat for now
      positions[vi * 3 + 2] = z;

      normals[vi * 3 + 0] = 0;
      normals[vi * 3 + 1] = 1;  // pointing up
      normals[vi * 3 + 2] = 0;

      uvs[vi * 2 + 0] = ix / segW;
      uvs[vi * 2 + 1] = iy / segH;

      vi++;
    }
  }

  // generate triangle indices
  const indices = [];
  for (let iy = 0; iy < segH; iy++) {
    for (let ix = 0; ix < segW; ix++) {
      const a = iy * (segW + 1) + ix;
      const b = a + 1;
      const c = a + (segW + 1);
      const d = c + 1;

      // two triangles per quad
      indices.push(a, c, b);
      indices.push(b, c, d);
    }
  }

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
  geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
  geometry.setIndex(indices);

  return geometry;
}

The index buffer is the key concept here. Instead of listing every triangle's three vertex positions separately (which would duplicate shared vertices), we list vertex positions once and then reference them by index. Vertex a at the top-left of a quad, b at top-right, c at bottom-left, d at bottom-right. Two triangles: (a, c, b) and (b, c, d). The winding order (counterclockwise when viewed from outside) determines which side is the "front."

With this function, createPlaneGeometry(50, 50, 10, 10) gives you a 10x10 unit plane made of 50x50 quads (2,601 vertices, 5,000 triangles). Flat and boring by itself. But now we can deform it.

Terrain from noise

Remember Perlin noise from episode 12? We used it for 2D textures and particle displacement. Now we use it for terrain. Set each vertex's Y position based on noise(x, z) and the flat grid becomes a landscape:

// simple noise function (same as ep012 or use a library)
function noise2D(x, y) {
  // for a real project, use the Perlin implementation from ep012
  // or import simplex-noise from npm
  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 createTerrain(segW, segH, size, heightScale) {
  const geo = createPlaneGeometry(segW, segH, size, size);
  const pos = geo.attributes.position.array;

  // displace Y based on layered noise
  for (let i = 0; i < pos.length; i += 3) {
    const x = pos[i];
    const z = pos[i + 2];

    // layered noise (octaves)
    let h = 0;
    h += noise2D(x * 0.3, z * 0.3) * 1.0;
    h += noise2D(x * 0.7, z * 0.7) * 0.5;
    h += noise2D(x * 1.5, z * 1.5) * 0.25;

    pos[i + 1] = h * heightScale;
  }

  // recompute normals since the surface is no longer flat
  geo.computeVertexNormals();

  return geo;
}

Three noise octaves at different frequencies and amplitudes. The first octave (* 0.3) creates broad hills and valleys. The second (* 0.7) adds medium-scale bumps. The third (* 1.5) adds fine detail. This is the same fractal Brownian motion technique from ep012 -- each octave doubles the frequency and halves the amplitude. The result is terrain that has both large-scale structure (mountains, valleys) and small-scale texture (ridges, bumps).

geometry.computeVertexNormals() is crucial. When the surface was flat, all normals pointed straight up. Now that we've displaced vertices, the normals need to match the actual surface orientation at each point. computeVertexNormals() does this automatically by averaging the face normals of all triangles that share each vertex. Without it, lighting won't work correctly -- the surface would be lit as if it were still flat.

Vertex colors: painting the mesh

Instead of applying a uniform material color, we can assign a different color to each vertex. Three.js interpolates between vertex colors across each triangle face, giving smooth gradients. For terrain, the obvious choice: color by height.

function colorTerrainByHeight(geometry) {
  const pos = geometry.attributes.position.array;
  const vertCount = pos.length / 3;
  const colors = new Float32Array(vertCount * 3);

  // find height range
  let minY = Infinity, maxY = -Infinity;
  for (let i = 1; i < pos.length; i += 3) {
    minY = Math.min(minY, pos[i]);
    maxY = Math.max(maxY, pos[i]);
  }

  for (let i = 0; i < vertCount; i++) {
    const y = pos[i * 3 + 1];
    const t = (y - minY) / (maxY - minY);  // 0 to 1

    let r, g, b;
    if (t < 0.25) {
      // deep blue water
      r = 0.05; g = 0.15; b = 0.4 + t * 1.2;
    } else if (t < 0.35) {
      // sandy beach
      r = 0.7; g = 0.65; b = 0.3;
    } else if (t < 0.65) {
      // green terrain
      const gt = (t - 0.35) / 0.3;
      r = 0.1 + gt * 0.1;
      g = 0.45 - gt * 0.15;
      b = 0.08;
    } else if (t < 0.85) {
      // rocky grey
      const rt = (t - 0.65) / 0.2;
      r = 0.3 + rt * 0.2;
      g = 0.3 + rt * 0.15;
      b = 0.28 + rt * 0.12;
    } else {
      // snow caps
      const st = (t - 0.85) / 0.15;
      r = 0.7 + st * 0.3;
      g = 0.75 + st * 0.25;
      b = 0.8 + st * 0.2;
    }

    colors[i * 3 + 0] = r;
    colors[i * 3 + 1] = g;
    colors[i * 3 + 2] = b;
  }

  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}

Five height bands: deep blue water, sandy beach, green vegetation, rocky grey, and white snow caps. The transitions blend smoothly because vertex colors interpolate across triangle faces. No textures needed -- the color IS the geometry.

To actually see vertex colors, you need to tell the material to use them:

const material = new THREE.MeshStandardMaterial({
  vertexColors: true,
  roughness: 0.8,
  metalness: 0.1,
  flatShading: false
});

vertexColors: true makes the material read colors from the geometry's color attribute instead of using a uniform color property. Combined with the height-based coloring and proper lighting, you get a surprisingly convincing landscape. Try flatShading: true for a low-poly aesthetic -- each triangle gets a flat color instead of smooth interpolation, giving the terrain a faceted crystalline look.

Dynamic geometry: making it breathe

Here's where it gets fun. You can modify vertex positions every frame and the mesh updates in real time. Set needsUpdate = true on the position attribute and Three.js re-uploads the data to the GPU.

const terrain = createTerrain(80, 80, 12, 1.5);
colorTerrainByHeight(terrain);
const mesh = new THREE.Mesh(terrain, material);
scene.add(mesh);

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const t = clock.getElapsedTime();
  const pos = terrain.attributes.position.array;

  // animate vertex heights
  for (let i = 0; i < pos.length; i += 3) {
    const x = pos[i];
    const z = pos[i + 2];

    let h = 0;
    h += noise2D(x * 0.3 + t * 0.1, z * 0.3) * 1.0;
    h += noise2D(x * 0.7 + t * 0.15, z * 0.7) * 0.5;
    h += noise2D(x * 1.5 + t * 0.2, z * 1.5) * 0.25;

    pos[i + 1] = h * 1.5;
  }

  // tell Three.js the positions changed
  terrain.attributes.position.needsUpdate = true;
  terrain.computeVertexNormals();

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

By adding t * 0.1 to the noise input, the noise field scrolls over time. The terrain shifts and flows like ocean waves or breathing earth. Each octave scrolls at a slightly different speed (0.1, 0.15, 0.2), so the large-scale hills move slowly while the fine detail ripples faster. The visual effect is genuinely hypnotic -- a living landscape that never repeats.

computeVertexNormals() every frame is necessary because the surface shape changes. If you skip it, the lighting stays frozen from the initial shape while the geometry moves underneath. It's a bit expensive for very high-resolution meshes but for 80x80 (6,561 vertices) it's negligible.

One performance note: if you also want the colors to update dynamically (terrain turns blue as it sinks, green as it rises), you'd update the color array and set terrain.attributes.color.needsUpdate = true as well. Same pattern, same needsUpdate flag.

Parametric surfaces: math as mesh

A parametric surface expresses (x, y, z) as functions of two parameters (u, v). You sweep u and v across a range and compute 3D coordinates for each combination. This is how mathematicians define surfaces like the torus, the Mobius strip, and the Klein bottle.

function createParametricSurface(fn, uSegs, vSegs) {
  const vertCount = (uSegs + 1) * (vSegs + 1);
  const positions = new Float32Array(vertCount * 3);

  let vi = 0;
  for (let iv = 0; iv <= vSegs; iv++) {
    const v = iv / vSegs;
    for (let iu = 0; iu <= uSegs; iu++) {
      const u = iu / uSegs;
      const p = fn(u, v);

      positions[vi * 3 + 0] = p.x;
      positions[vi * 3 + 1] = p.y;
      positions[vi * 3 + 2] = p.z;
      vi++;
    }
  }

  // index buffer (same grid pattern as the plane)
  const indices = [];
  for (let iv = 0; iv < vSegs; iv++) {
    for (let iu = 0; iu < uSegs; iu++) {
      const a = iv * (uSegs + 1) + iu;
      const b = a + 1;
      const c = a + (uSegs + 1);
      const d = c + 1;
      indices.push(a, c, b);
      indices.push(b, c, d);
    }
  }

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();

  return geometry;
}

The fn(u, v) takes two parameters in [0, 1] and returns {x, y, z}. The rest is the same grid-and-index pattern as the plane. Now we can define any mathematical surface as a function:

// torus
function torusFn(u, v) {
  const R = 2.0;  // major radius
  const r = 0.6;  // tube radius
  const theta = u * Math.PI * 2;
  const phi = v * Math.PI * 2;

  return {
    x: (R + r * Math.cos(phi)) * Math.cos(theta),
    y: r * Math.sin(phi),
    z: (R + r * Math.cos(phi)) * Math.sin(theta)
  };
}

// mobius strip
function mobiusFn(u, v) {
  const theta = u * Math.PI * 2;
  const w = v - 0.5;  // -0.5 to 0.5

  return {
    x: (1 + w * 0.5 * Math.cos(theta / 2)) * Math.cos(theta),
    y: w * 0.5 * Math.sin(theta / 2),
    z: (1 + w * 0.5 * Math.cos(theta / 2)) * Math.sin(theta)
  };
}

// figure-eight (lemniscate) surface
function figureEightFn(u, v) {
  const a = 2;
  const theta = u * Math.PI * 2;
  const phi = v * Math.PI * 2;

  return {
    x: (a + Math.cos(phi / 2) * Math.sin(theta) -
         Math.sin(phi / 2) * Math.sin(2 * theta)) * Math.cos(phi),
    y: (a + Math.cos(phi / 2) * Math.sin(theta) -
         Math.sin(phi / 2) * Math.sin(2 * theta)) * Math.sin(phi),
    z: Math.sin(phi / 2) * Math.sin(theta) +
       Math.cos(phi / 2) * Math.sin(2 * theta)
  };
}

const torus = new THREE.Mesh(
  createParametricSurface(torusFn, 64, 32),
  new THREE.MeshNormalMaterial({ side: THREE.DoubleSide })
);
scene.add(torus);

MeshNormalMaterial is perfect for parametric surfaces because it colors based on surface direction -- no lights needed. The rainbow-oil-slick effect reveals the surface curvature beautifully. You can see exactly how the Mobius strip twists (the normals flip halfway around), how the torus curves in two directions simultaneously, how the figure-eight surface self-intersects.

The Mobius strip is fun because it only has ONE side. If you try to color it with front/back face culling, you'll see the inside and outside swap as the strip twists. DoubleSide is mandatory here.

Lathe geometry: pottery from profiles

Lathe geometry rotates a 2D profile curve around an axis to create a 3D solid of revolution. Vases, bottles, chess pieces, wine glasses -- anything rotationally symmetric.

function createLatheGeometry(profile, segments) {
  const vertCount = profile.length * (segments + 1);
  const positions = new Float32Array(vertCount * 3);

  let vi = 0;
  for (let seg = 0; seg <= segments; seg++) {
    const theta = (seg / segments) * Math.PI * 2;
    const cosT = Math.cos(theta);
    const sinT = Math.sin(theta);

    for (let pi = 0; pi < profile.length; pi++) {
      const px = profile[pi].x;  // distance from axis
      const py = profile[pi].y;  // height

      positions[vi * 3 + 0] = px * cosT;
      positions[vi * 3 + 1] = py;
      positions[vi * 3 + 2] = px * sinT;
      vi++;
    }
  }

  // indices
  const indices = [];
  for (let seg = 0; seg < segments; seg++) {
    for (let pi = 0; pi < profile.length - 1; pi++) {
      const a = seg * profile.length + pi;
      const b = a + 1;
      const c = a + profile.length;
      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.setIndex(indices);
  geo.computeVertexNormals();
  return geo;
}

// vase profile
const vaseProfile = [
  { x: 0.0, y: 0.0 },
  { x: 0.8, y: 0.2 },
  { x: 1.0, y: 0.6 },
  { x: 0.9, y: 1.2 },
  { x: 0.6, y: 1.8 },
  { x: 0.5, y: 2.2 },
  { x: 0.7, y: 2.6 },
  { x: 0.9, y: 2.8 },
  { x: 0.8, y: 3.0 }
];

const vase = new THREE.Mesh(
  createLatheGeometry(vaseProfile, 48),
  new THREE.MeshStandardMaterial({
    color: 0xcc8855,
    roughness: 0.6,
    metalness: 0.2
  })
);
scene.add(vase);

The profile is just a list of (radius, height) pairs. Each pair becomes a ring of vertices when rotated around the Y axis. More profile points = smoother silhouette. More segments = smoother round surface. The vase profile above has a wide belly, a narrow waist, and a flared rim -- classic ceramic shape. Change the profile array and you get completely different objects. A straight vertical line with end caps makes a cylinder. A semicircle makes a sphere. An S-curve makes an hourglass.

For creative coding, you can generate the profile programmatically -- use noise or trig to create organic, randomly shaped vessels. Each seed gives a unique form. Run a gallery of 20 random vases and you've got a generative ceramics collection :-)

Creative exercise: living terrain

Alright, lets put it all together. A generative terrain with noise-based height, vertex colors by height, dynamic animation, fog, and orbit controls. The terrain breathes and shifts like something alive:

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

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e14);
scene.fog = new THREE.Fog(0x0a0e14, 10, 30);

const camera = new THREE.PerspectiveCamera(
  60, window.innerWidth / window.innerHeight, 0.1, 50
);
camera.position.set(0, 6, 10);
camera.lookAt(0, 0, 0);

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;

// lighting
scene.add(new THREE.AmbientLight(0x222233, 1.5));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(4, 8, 3);
scene.add(sun);
const fill = new THREE.DirectionalLight(0x4466aa, 0.6);
fill.position.set(-3, 4, -5);
scene.add(fill);

// terrain setup
const SEG = 100;
const SIZE = 16;
const geo = createPlaneGeometry(SEG, SEG, SIZE, SIZE);
const pos = geo.attributes.position.array;
const vertCount = pos.length / 3;
const colors = new Float32Array(vertCount * 3);
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));

const mat = new THREE.MeshStandardMaterial({
  vertexColors: true,
  roughness: 0.75,
  metalness: 0.05,
  side: THREE.DoubleSide
});
const terrain = new THREE.Mesh(geo, mat);
scene.add(terrain);

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();

  // update heights and colors
  for (let i = 0; i < vertCount; i++) {
    const x = pos[i * 3];
    const z = pos[i * 3 + 2];

    let h = 0;
    h += noise2D(x * 0.25 + t * 0.08, z * 0.25 + t * 0.05) * 1.5;
    h += noise2D(x * 0.6 + t * 0.12, z * 0.6 - t * 0.08) * 0.6;
    h += noise2D(x * 1.3 + t * 0.2, z * 1.3) * 0.2;

    pos[i * 3 + 1] = h;

    // color by height
    const nt = (h + 2) / 4;  // rough normalize
    if (nt < 0.3) {
      colors[i * 3] = 0.05;
      colors[i * 3 + 1] = 0.12 + nt;
      colors[i * 3 + 2] = 0.35 + nt * 0.5;
    } else if (nt < 0.6) {
      colors[i * 3] = 0.1;
      colors[i * 3 + 1] = 0.35 + (nt - 0.3) * 0.5;
      colors[i * 3 + 2] = 0.08;
    } else {
      const s = (nt - 0.6) / 0.4;
      colors[i * 3] = 0.5 + s * 0.5;
      colors[i * 3 + 1] = 0.5 + s * 0.4;
      colors[i * 3 + 2] = 0.45 + s * 0.5;
    }
  }

  geo.attributes.position.needsUpdate = true;
  geo.attributes.color.needsUpdate = true;
  geo.computeVertexNormals();

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

animate();

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

Orbit around the terrain. Watch the valleys fill with deep blue as they sink, the hills turn green as they rise, and the peaks lighten to pale grey-white. The fog dissolves distant edges into the dark background. The two-tone lighting (warm sun from one side, cool fill from the other) creates the same cinematic depth we used in episode 62. The whole thing moves slowly, breathing, shifting -- like a geological process compressed into seconds.

This is procedural geometry in action. No model files, no 3D editor, no textures. Just math, arrays, and a render loop. The terrain doesn't exist anywhere until your code generates it, and it's different every time you change the noise parameters.

How normals actually work

I keep saying "compute normals" without explaining what that means. If you're planning to build more complex geometry (and you should -- this is where the creative coding gets really good), understanding normals is essential.

A normal vector is a direction perpendicular to a surface at a given point. It tells the renderer which way the surface is facing. When light hits a surface, the angle between the light direction and the normal determines how bright that point appears. Face directly toward the light? Maximum brightness. Face perpendicular to the light? Zero brightness. Face away? Also zero (no negative light).

For a single triangle, the normal is the cross product of two edge vectors:

function triangleNormal(a, b, c) {
  // edge vectors
  const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
  const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;

  // cross product
  const nx = e1y * e2z - e1z * e2y;
  const ny = e1z * e2x - e1x * e2z;
  const nz = e1x * e2y - e1y * e2x;

  // normalize to unit length
  const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
  return { x: nx / len, y: ny / len, z: nz / len };
}

When computeVertexNormals() runs, it computes the face normal for every triangle, then for each vertex it averages the normals of all triangles sharing that vertex. This gives smooth shading -- the normal at a vertex interpolates between the faces around it, so light transitions smoothly across triangle boundaries instead of having hard edges.

If you WANT hard edges (flat shading), use flatShading: true on the material. This tells the renderer to use the face normal directly for each pixel within a triangle, ignoring vertex interpolation. The faceted, low-poly aesthetic.

What's next

We built geometry from scratch. Vertex positions, index buffers, normals, vertex colors. We generated terrain from noise, built parametric surfaces from equations, created lathe solids from profiles, and animated everything in real time.

But everything so far uses the same MeshStandardMaterial -- the built-in PBR shader. What if you want to write your OWN shaders for 3D meshes? Apply the GLSL knowledge from episodes 21-45 directly to Three.js objects? That's ShaderMaterial -- and it's where the 2D shader skills from Phase 2 meet the 3D scene graph. Custom vertex displacement, custom fragment coloring, uniforms driven by JavaScript. All the power of raw GLSL wrapped in Three.js's scene management.

't Komt erop neer...

  • BufferGeometry stores vertex data in typed arrays -- Float32Array for positions, normals, colors, UVs. Each attribute has a stride (3 for xyz, 2 for uv). Same concept as ImageData pixel arrays from episode 10 but for 3D meshes
  • Index buffers let you define triangles by referencing shared vertices instead of duplicating them. A grid of quads splits into triangles: each quad becomes two triangles (a,c,b) and (b,c,d). Winding order (counterclockwise) determines front face
  • Terrain from noise: create a flat grid, set each vertex's Y position from layered noise (fractal Brownian motion from ep012). Three octaves at different frequencies give large hills, medium bumps, and fine detail. computeVertexNormals() recalculates normals to match the deformed surface
  • Vertex colors assign per-vertex RGB values that interpolate across triangle faces. Color by height for terrain: blue water, sandy beach, green vegetation, grey rock, white snow. Set vertexColors: true on the material to use them
  • Dynamic geometry: modify the position array each frame and set attributes.position.needsUpdate = true to re-upload to GPU. Add time to the noise input for animated terrain that breathes and shifts. Recompute normals every frame for correct lighting
  • Parametric surfaces define (x,y,z) as functions of (u,v). The same grid-and-index pattern works for any mathematical surface -- torus, Mobius strip, Klein bottle, anything you can express as equations. MeshNormalMaterial colors by surface direction, no lights needed
  • Lathe geometry rotates a 2D profile curve around the Y axis. Define a profile as (radius, height) pairs, sweep them through 360 degrees. Vases, bottles, chess pieces -- anything rotationally symmetric. Randomize the profile for generative ceramics
  • Normals are perpendicular vectors that tell the renderer which way a surface faces. Computed from the cross product of triangle edge vectors. Vertex normals average the face normals of surrounding triangles for smooth shading. flatShading: true skips the averaging for a faceted low-poly look

Sallukes! Thanks for reading.

X

@femdev