About this technique →
gyroid-tpms/sketch.js
// Gyroid TPMS — Schoen's Triply Periodic Minimal Surface (1970)
//
// Field equation: sin(k·x)·cos(k·y) + sin(k·y)·cos(k·z) + sin(k·z)·cos(k·x) = 0
//
// The Gyroid infinitely tiles 3D space with a chiral soap-film geometry found in
// butterfly wing scales, beetle exoskeletons, and block copolymers.
// Meshed via Naive Surface Nets (Mikola Lysenko) — ~80 lines, smooth output,
// no lookup tables needed.

import * as THREE from 'three';

export const WARMUP = { framesBeforeReady: 60 };

export const PARAMS = {
  resolution:  { value: 50, min: 20, max: 80, step: 2, label: "Grid Resolution", folder: "Structure", rebuildOnChange: true },
  cellSize:    { value: 4.5, min: 1.5, max: 12, step: 0.1, label: "Cell Size", folder: "Structure" },
  thickness:   { value: 0.0, min: -0.4, max: 0.4, step: 0.01, label: "Iso Offset", folder: "Structure" },
  hue:         { value: 200, min: 0, max: 360, step: 1, label: "Hue", folder: "Appearance" },
  rotateSpeed: { value: 0.15, min: 0, max: 1, step: 0.01, label: "Rotate Speed", folder: "Behavior" },
};

// --- Naive Surface Nets extractor ---
function naiveSurfaceNets(field, dims, bounds, isovalue) {
  const [nx, ny, nz] = dims;
  const idx = (x, y, z) => x + nx * (y + ny * z);
  const cellVertex = new Int32Array(nx * ny * nz).fill(-1);

  const cornerOffsets = [
    [0,0,0],[1,0,0],[1,1,0],[0,1,0],
    [0,0,1],[1,0,1],[1,1,1],[0,1,1],
  ];
  const edgeList = [
    [0,1],[1,2],[2,3],[3,0],
    [4,5],[5,6],[6,7],[7,4],
    [0,4],[1,5],[2,6],[3,7],
  ];

  const positions = [];
  const indices = [];

  // Pass A — one vertex per cell that straddles the isovalue
  for (let z = 0; z < nz - 1; z++)
  for (let y = 0; y < ny - 1; y++)
  for (let x = 0; x < nx - 1; x++) {
    const c = [
      field[idx(x,   y,   z  )], field[idx(x+1, y,   z  )],
      field[idx(x+1, y+1, z  )], field[idx(x,   y+1, z  )],
      field[idx(x,   y,   z+1)], field[idx(x+1, y,   z+1)],
      field[idx(x+1, y+1, z+1)], field[idx(x,   y+1, z+1)],
    ];
    let sx = 0, sy = 0, sz = 0, n = 0;
    for (const [a, b] of edgeList) {
      const va = c[a], vb = c[b];
      if ((va < isovalue) !== (vb < isovalue)) {
        const t = (isovalue - va) / (vb - va);
        const oa = cornerOffsets[a], ob = cornerOffsets[b];
        sx += oa[0] + t * (ob[0] - oa[0]);
        sy += oa[1] + t * (ob[1] - oa[1]);
        sz += oa[2] + t * (ob[2] - oa[2]);
        n++;
      }
    }
    if (n > 0) {
      const cx = (x + sx / n) / (nx - 1);
      const cy = (y + sy / n) / (ny - 1);
      const cz = (z + sz / n) / (nz - 1);
      cellVertex[idx(x, y, z)] = positions.length / 3;
      positions.push(
        bounds.min[0] + cx * (bounds.max[0] - bounds.min[0]),
        bounds.min[1] + cy * (bounds.max[1] - bounds.min[1]),
        bounds.min[2] + cz * (bounds.max[2] - bounds.min[2]),
      );
    }
  }

  // Pass B — quads across edges with sign changes
  for (let z = 1; z < nz - 1; z++)
  for (let y = 1; y < ny - 1; y++)
  for (let x = 1; x < nx - 1; x++) {
    const v0 = field[idx(x, y, z)];

    const vx = field[idx(x+1, y, z)];
    if ((v0 < isovalue) !== (vx < isovalue)) {
      const a = cellVertex[idx(x, y-1, z-1)], b = cellVertex[idx(x, y,   z-1)];
      const cc = cellVertex[idx(x, y,   z  )], d = cellVertex[idx(x, y-1, z  )];
      if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
        if (v0 < isovalue) indices.push(a, b, cc, a, cc, d);
        else               indices.push(a, cc, b, a, d, cc);
      }
    }
    const vy = field[idx(x, y+1, z)];
    if ((v0 < isovalue) !== (vy < isovalue)) {
      const a = cellVertex[idx(x-1, y, z-1)], b = cellVertex[idx(x,   y, z-1)];
      const cc = cellVertex[idx(x,   y, z  )], d = cellVertex[idx(x-1, y, z  )];
      if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
        if (v0 < isovalue) indices.push(a, d, cc, a, cc, b);
        else               indices.push(a, b, cc, a, cc, d);
      }
    }
    const vz = field[idx(x, y, z+1)];
    if ((v0 < isovalue) !== (vz < isovalue)) {
      const a = cellVertex[idx(x-1, y-1, z)], b = cellVertex[idx(x,   y-1, z)];
      const cc = cellVertex[idx(x,   y,   z)], d = cellVertex[idx(x-1, y,   z)];
      if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
        if (v0 < isovalue) indices.push(a, b, cc, a, cc, d);
        else               indices.push(a, cc, b, a, d, cc);
      }
    }
  }

  return {
    positions: new Float32Array(positions),
    indices: new Uint32Array(indices),
  };
}

function buildGyroidMesh(THREE, params) {
  const res = params.resolution | 0;
  const scale = params.cellSize;          // period in radians across the grid
  const isovalue = params.thickness;

  const dims = [res, res, res];
  const half = Math.PI * scale / 2;
  const bounds = { min: [-half, -half, -half], max: [half, half, half] };

  const field = new Float32Array(res * res * res);
  const dx = (bounds.max[0] - bounds.min[0]) / (res - 1);
  const dy = (bounds.max[1] - bounds.min[1]) / (res - 1);
  const dz = (bounds.max[2] - bounds.min[2]) / (res - 1);

  let i = 0;
  for (let iz = 0; iz < res; iz++) {
    const wz = bounds.min[2] + iz * dz;
    const sinZ = Math.sin(wz), cosZ = Math.cos(wz);
    for (let iy = 0; iy < res; iy++) {
      const wy = bounds.min[1] + iy * dy;
      const sinY = Math.sin(wy), cosY = Math.cos(wy);
      for (let ix = 0; ix < res; ix++) {
        const wx = bounds.min[0] + ix * dx;
        field[i++] = Math.sin(wx) * cosY + sinY * cosZ + sinZ * Math.cos(wx);
      }
    }
  }

  const { positions, indices } = naiveSurfaceNets(field, dims, bounds, isovalue);

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

export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
  scene.background = new THREE.Color(0x07080a);
  camera.position.set(0, 0, 8);
  camera.lookAt(0, 0, 0);

  const ambient = new THREE.AmbientLight(0xffffff, 0.4);
  scene.add(ambient);
  const dirA = new THREE.DirectionalLight(0xffffff, 1.4);
  dirA.position.set(5, 5, 5);
  scene.add(dirA);
  const dirB = new THREE.DirectionalLight(0x8888ff, 0.4);
  dirB.position.set(-5, -3, -5);
  scene.add(dirB);

  const mat = new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(params.hue / 360, 0.7, 0.55),
    roughness: 0.35,
    metalness: 0.2,
    side: THREE.DoubleSide,
  });

  const geo = buildGyroidMesh(THREE, params);
  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  const prev = { cellSize: params.cellSize, thickness: params.thickness };

  return { mesh, mat, geo, prev };
}

export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
  const { mesh, mat, prev } = state;

  mat.color.setHSL(params.hue / 360, 0.7, 0.55);

  // Rebuild mesh if cellSize or thickness changed (resolution triggers full rebuild via rebuildOnChange)
  if (params.cellSize !== prev.cellSize || params.thickness !== prev.thickness) {
    prev.cellSize = params.cellSize;
    prev.thickness = params.thickness;

    const oldGeo = state.geo;
    const newGeo = buildGyroidMesh(THREE, params);
    mesh.geometry = newGeo;
    state.geo = newGeo;
    oldGeo.dispose();
  }

  // Gentle auto-rotation to reveal tunnel structure
  mesh.rotation.y = time * params.rotateSpeed;
  mesh.rotation.x = time * params.rotateSpeed * 0.4;
}