// 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);
}