About this technique →
differential-growth/sketch.js
// Differential Growth
//
// A closed polyline whose nodes:
//   - repel other nearby nodes (keeps them from crowding)
//   - attract toward the midpoint of their two immediate neighbors (spring force)
//   - subdivide when an edge grows too long (inserts a midpoint node)
//
// Result: organic crinkly contours like coral, lichen, or growing tissue.
// Anders Hoff's hyphae aesthetic.

export const WARMUP = { framesBeforeReady: 600 };

export const PARAMS = {
  repulsion:  { value: 18, min: 4, max: 60, step: 0.5, label: "Repulsion Radius", folder: "Behavior" },
  maxEdge:    { value: 14, min: 4, max: 40, step: 0.5, label: "Max Edge Length", folder: "Behavior" },
  step:       { value: 0.6, min: 0.1, max: 2.0, step: 0.05, label: "Step Size", folder: "Behavior" },
  attraction: { value: 0.12, min: 0, max: 0.5, step: 0.005, label: "Neighbor Attraction", folder: "Behavior" },
  maxNodes:   { value: 2500, min: 100, max: 6000, step: 50, label: "Max Nodes", folder: "Structure" },
  stroke:     { value: "#d97757", type: "color", label: "Stroke", folder: "Appearance" },
  bg:         { value: "#0a0a0a", type: "color", label: "Background", folder: "Appearance" },
  strokeW:    { value: 1.5, min: 0.5, max: 4, step: 0.1, label: "Stroke Width", folder: "Appearance" },
};

export const SHARE = {
  bookmarked: ["repulsion", "maxEdge", "step", "attraction", "maxNodes", "stroke", "bg", "strokeW"],
};

let state;

// ── Spatial hash grid for O(1) neighbor lookup ──────────────────────────────

class SpatialGrid {
  constructor(cellSize) {
    this.cellSize = cellSize;
    this.cells = new Map();
  }

  _key(x, y) {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    return `${cx},${cy}`;
  }

  clear() {
    this.cells.clear();
  }

  insert(node) {
    const key = this._key(node.x, node.y);
    let cell = this.cells.get(key);
    if (!cell) { cell = []; this.cells.set(key, cell); }
    cell.push(node);
  }

  query(x, y, radius) {
    const r = radius;
    const cs = this.cellSize;
    const minCx = Math.floor((x - r) / cs);
    const maxCx = Math.floor((x + r) / cs);
    const minCy = Math.floor((y - r) / cs);
    const maxCy = Math.floor((y + r) / cs);

    const result = [];
    for (let cx = minCx; cx <= maxCx; cx++) {
      for (let cy = minCy; cy <= maxCy; cy++) {
        const cell = this.cells.get(`${cx},${cy}`);
        if (cell) {
          for (const node of cell) result.push(node);
        }
      }
    }
    return result;
  }
}

// ── Initialization ───────────────────────────────────────────────────────────

function buildInitialCircle(cx, cy, radius, count) {
  const nodes = [];
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * Math.PI * 2;
    nodes.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
  }
  return nodes;
}

export function sketchSetup(ctx, w, h, tng, params) {
  const nodes = buildInitialCircle(w / 2, h / 2, 30, 12);
  const grid  = new SpatialGrid(params.repulsion * 2);
  state = { nodes, grid, w, h };
  return state;
}

// ── Per-frame growth step ────────────────────────────────────────────────────

function growthStep(nodes, grid, params) {
  const n       = nodes.length;
  const repR    = params.repulsion;
  const repR2   = repR * repR;
  const stepSz  = params.step;
  const attract = params.attraction;

  // Rebuild spatial grid
  grid.cellSize = repR * 2;
  grid.clear();
  for (const node of nodes) grid.insert(node);

  // Compute forces and displace
  for (let i = 0; i < n; i++) {
    const node = nodes[i];
    const prev = nodes[(i - 1 + n) % n];
    const next = nodes[(i + 1)     % n];

    // Repulsion: push away from all nearby nodes
    let rx = 0, ry = 0;
    const neighbors = grid.query(node.x, node.y, repR);
    for (const other of neighbors) {
      if (other === node) continue;
      const dx = node.x - other.x;
      const dy = node.y - other.y;
      const d2 = dx * dx + dy * dy;
      if (d2 < repR2 && d2 > 0) {
        const d = Math.sqrt(d2);
        rx += (dx / d) * (1 - d / repR);
        ry += (dy / d) * (1 - d / repR);
      }
    }

    // Attraction: pull toward midpoint of neighbors (spring toward neighbors)
    const midX = (prev.x + next.x) * 0.5;
    const midY = (prev.y + next.y) * 0.5;
    const ax = (midX - node.x) * attract;
    const ay = (midY - node.y) * attract;

    node.x += (rx + ax) * stepSz;
    node.y += (ry + ay) * stepSz;
  }
}

// Insert midpoints where edges are too long
function subdivide(nodes, maxEdge) {
  const result = [];
  const n = nodes.length;
  for (let i = 0; i < n; i++) {
    const a = nodes[i];
    const b = nodes[(i + 1) % n];
    result.push(a);
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    if (dx * dx + dy * dy > maxEdge * maxEdge) {
      result.push({ x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5 });
    }
  }
  return result;
}

// ── Drawing ──────────────────────────────────────────────────────────────────

export function sketchDraw(ctx, w, h, params, tng) {
  const { nodes, grid } = state;

  // Growth step each frame
  if (nodes.length < params.maxNodes) {
    growthStep(nodes, grid, params);
    const grown = subdivide(nodes, params.maxEdge);
    // Cap at maxNodes
    if (grown.length <= params.maxNodes) {
      state.nodes.length = 0;
      for (const n of grown) state.nodes.push(n);
    } else {
      // Only accept subdivisions up to the cap
      let added = 0;
      for (let i = 0; i < grown.length && state.nodes.length < params.maxNodes; i++) {
        state.nodes[i] = grown[i];
        added++;
      }
      state.nodes.length = added;
    }
  } else {
    // Still run displacement even at max nodes (no subdivision)
    growthStep(nodes, grid, params);
  }

  // Draw background
  ctx.fillStyle = params.bg;
  ctx.fillRect(0, 0, w, h);

  // Draw closed polyline
  const pts = state.nodes;
  if (pts.length < 2) return;

  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  for (let i = 1; i < pts.length; i++) {
    ctx.lineTo(pts[i].x, pts[i].y);
  }
  ctx.closePath();

  ctx.strokeStyle = params.stroke;
  ctx.lineWidth   = params.strokeW;
  ctx.stroke();
}