About this technique →
quadtree-bayer/sketch.js
// Quadtree Bayer — adaptive quadtree dithering
//
// Samples a source field over the canvas. Regions with variance above
// threshold are subdivided into 4 quadrants. Leaf regions are drawn
// with a Bayer ordered-dither pattern keyed to their mean intensity.

export const PARAMS = {
  source:    { value: "Radial", options: ["Radial", "Noise", "Diagonal", "Spiral", "Bands", "Astro Logo"], label: "Source Field", folder: "Structure" },
  threshold: { value: 0.08, min: 0.005, max: 0.4, step: 0.005, label: "Variance Threshold", folder: "Structure" },
  maxDepth:  { value: 7, min: 3, max: 10, step: 1, label: "Max Depth", folder: "Structure" },
  bayerSize: { value: "4", options: ["2", "4", "8"], label: "Bayer Matrix", folder: "Appearance" },
  ink:       { value: "#1a1a1a", type: "color", label: "Ink", folder: "Appearance" },
  paper:     { value: "#f4f0e6", type: "color", label: "Paper", folder: "Appearance" },
};

export const SHARE = {
  bookmarked: ["source", "threshold", "maxDepth", "bayerSize"],
};

// Bayer matrices
const BAYER = {
  2: [
    [0, 2],
    [3, 1],
  ],
  4: [
    [ 0,  8,  2, 10],
    [12,  4, 14,  6],
    [ 3, 11,  1,  9],
    [15,  7, 13,  5],
  ],
  8: [
    [ 0, 32,  8, 40,  2, 34, 10, 42],
    [48, 16, 56, 24, 50, 18, 58, 26],
    [12, 44,  4, 36, 14, 46,  6, 38],
    [60, 28, 52, 20, 62, 30, 54, 22],
    [ 3, 35, 11, 43,  1, 33,  9, 41],
    [51, 19, 59, 27, 49, 17, 57, 25],
    [15, 47,  7, 39, 13, 45,  5, 37],
    [63, 31, 55, 23, 61, 29, 53, 21],
  ],
};

// Parse hex color string to [r,g,b] 0-255
function hexToRgb255(hex) {
  const h = hex.replace('#', '');
  return [
    parseInt(h.substring(0, 2), 16),
    parseInt(h.substring(2, 4), 16),
    parseInt(h.substring(4, 6), 16),
  ];
}

// Sample source field at pixel (x,y) in canvas (w x h), returns 0-1
function sampleField(source, x, y, w, h, noise) {
  switch (source) {
    case "Radial": {
      const dx = (x - w / 2) / (Math.min(w, h) / 2);
      const dy = (y - h / 2) / (Math.min(w, h) / 2);
      return Math.min(1, Math.sqrt(dx * dx + dy * dy));
    }
    case "Noise": {
      const v = noise.fbm2(x * 0.005, y * 0.005, 4);
      return Math.max(0, Math.min(1, (v + 1) * 0.5));
    }
    case "Diagonal":
      return (x + y) / (w + h);
    case "Spiral": {
      // Logarithmic spiral: distance to nearest point on the spiral curve
      const cx2 = x - w / 2;
      const cy2 = y - h / 2;
      const r2 = Math.sqrt(cx2 * cx2 + cy2 * cy2);
      const scale = Math.min(w, h) / 2;
      if (r2 < 1) return 0;
      // Angle from center
      const theta = Math.atan2(cy2, cx2);
      // Log spiral: r = a * e^(b*theta) → ln(r/a) / b = theta
      const a = 3.0, b = 0.3;
      const spiralTheta = Math.log(r2 / scale / a) / b;
      // Distance to the nearest spiral arm (normalized)
      const thetaMod = ((theta - spiralTheta) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
      const dist = Math.min(thetaMod, Math.PI * 2 - thetaMod) / Math.PI;
      return Math.min(1, dist);
    }
    case "Bands": {
      // Diagonal sine bands — varied tonal output
      const nx2 = x / w, ny2 = y / h;
      const v = Math.sin((nx2 + ny2 * 0.3) * Math.PI * 8) * 0.5 + 0.5;
      return v;
    }
    case "Astro Logo": {
      // Concentric ring + soft center gradient — recognizable circular motif
      const dx2 = (x - w / 2) / (Math.min(w, h) / 2);
      const dy2 = (y - h / 2) / (Math.min(w, h) / 2);
      const r3 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
      // Bright center, dark ring at r~0.5, bright outer ring at r~0.85
      const center = Math.exp(-r3 * r3 * 8);
      const ring   = Math.exp(-(r3 - 0.55) * (r3 - 0.55) * 40);
      const outer  = Math.exp(-(r3 - 0.85) * (r3 - 0.85) * 60);
      return Math.min(1, center * 0.6 + ring * 0.9 + outer * 0.7);
    }
    default:
      return 0;
  }
}

// Recursively build the quadtree, collecting leaf nodes
function buildQuadtree(source, x, y, w, h, depth, maxDepth, threshold, noiseObj, canvasW, canvasH, leaves) {
  // Sample a grid of points within this region to get mean and variance
  const SAMPLES = 4;
  const vals = [];
  for (let sy = 0; sy < SAMPLES; sy++) {
    for (let sx = 0; sx < SAMPLES; sx++) {
      const px = x + (sx + 0.5) * (w / SAMPLES);
      const py = y + (sy + 0.5) * (h / SAMPLES);
      vals.push(sampleField(source, px, py, canvasW, canvasH, noiseObj));
    }
  }

  const n = vals.length;
  const mean = vals.reduce((s, v) => s + v, 0) / n;
  const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / n;

  // Leaf condition: low variance, max depth reached, or region too small
  if (depth >= maxDepth || variance < threshold || w < 2 || h < 2) {
    leaves.push({ x, y, w, h, mean });
    return;
  }

  // Subdivide into 4 quadrants
  const hw = w / 2;
  const hh = h / 2;
  buildQuadtree(source, x,      y,      hw, hh, depth + 1, maxDepth, threshold, noiseObj, canvasW, canvasH, leaves);
  buildQuadtree(source, x + hw, y,      hw, hh, depth + 1, maxDepth, threshold, noiseObj, canvasW, canvasH, leaves);
  buildQuadtree(source, x,      y + hh, hw, hh, depth + 1, maxDepth, threshold, noiseObj, canvasW, canvasH, leaves);
  buildQuadtree(source, x + hw, y + hh, hw, hh, depth + 1, maxDepth, threshold, noiseObj, canvasW, canvasH, leaves);
}

export function sketchSetup(ctx, w, h, tng) {
  return {};
}

export function sketchDraw(ctx, w, h, params, tng) {
  const bayerN = parseInt(params.bayerSize, 10);
  const matrix = BAYER[bayerN] || BAYER[4];
  const matrixMax = bayerN * bayerN;

  const inkRgb = hexToRgb255(params.ink);
  const paperRgb = hexToRgb255(params.paper);

  // Build adaptive quadtree
  const leaves = [];
  buildQuadtree(
    params.source,
    0, 0, w, h,
    0, params.maxDepth, params.threshold,
    tng.noise,
    w, h,
    leaves,
  );

  // Draw each leaf with Bayer dithering
  const img = ctx.createImageData(w, h);
  const data = img.data;

  // Fill with paper color first
  for (let i = 0; i < data.length; i += 4) {
    data[i]     = paperRgb[0];
    data[i + 1] = paperRgb[1];
    data[i + 2] = paperRgb[2];
    data[i + 3] = 255;
  }

  // For each leaf, apply Bayer dither
  for (const { x, y, w: lw, h: lh, mean } of leaves) {
    const x0 = Math.round(x);
    const y0 = Math.round(y);
    const x1 = Math.min(w, Math.round(x + lw));
    const y1 = Math.min(h, Math.round(y + lh));

    for (let py = y0; py < y1; py++) {
      for (let px = x0; px < x1; px++) {
        const threshold = (matrix[py % bayerN][px % bayerN] + 0.5) / matrixMax;
        const isInk = mean > threshold;
        const rgb = isInk ? inkRgb : paperRgb;
        const idx = (py * w + px) * 4;
        data[idx]     = rgb[0];
        data[idx + 1] = rgb[1];
        data[idx + 2] = rgb[2];
        data[idx + 3] = 255;
      }
    }
  }

  ctx.putImageData(img, 0, 0);
}