# Mathematical Art: Methods

An overview of the ways an image can be produced from equations, rules, and simulations. Each method is described at a high level, with the governing mathematics, what it tends to look like, and where it is used in this collection.

Every equation is given twice: as typeset mathematics and as the plain code you would actually write. Notation: \(p = (x, y)\) is a point on the canvas, \(t\) a parameter or time, \(k\) an integer index, \(N\) a count. Images are produced either by **plotting** (evaluate a rule, place marks) or by **evaluating a field** (compute a value at every pixel).

---

## 1. Parametric and trigonometric shape families

**Idea.** Draw thousands of simple primitives whose positions, sizes, and colours are closed-form trigonometric functions of an index. No single primitive matters; the eye reads the *envelope* of the swarm.

**Primitive swarms.** For \(k = 1 \ldots N\), draw a circle with

\[
c_k = \Big( \sin\tfrac{a\pi k}{N},\; \cos^{p}\tfrac{b\pi k}{N} \Big), \qquad
r_k = \rho \, \sin^{2}\tfrac{d\pi k}{N}
\]

```js
for (let k = 1; k <= N; k++) {
  const cx = Math.sin((a * Math.PI * k) / N);
  const cy = Math.pow(Math.cos((b * Math.PI * k) / N), p);
  const r = rho * Math.pow(Math.sin((d * Math.PI * k) / N), 2);
  circle(cx, cy, r);
}
```

Integer frequencies \(a, b, d\) make the family close on itself, producing symmetric moiré. Odd powers sharpen curves toward the axes; even powers keep a quantity non-negative (useful for radii).

**Lissajous and harmonograph.**

\[
x = A \sin(a t + \delta), \qquad y = B \sin(b t), \qquad \text{damped: } \times\, e^{-\lambda t}
\]

```js
x = A * Math.sin(a * t + delta) * Math.exp(-lambda * t);
y = B * Math.sin(b * t) * Math.exp(-lambda * t);
```

The ratio \(a : b\) sets the knot pattern. The decay gives the pendulum-drawn look of a harmonograph.

**Rose curves and polar art.**

\[
r = \cos(k\theta), \qquad k = p/q \text{ rational closes after } q \text{ turns}
\]

```js
r = Math.cos(k * theta);
x = r * Math.cos(theta);
y = r * Math.sin(theta);
```

**Epicycles (Fourier drawing).** Any closed curve \(z(t)\) is a sum of rotating vectors:

\[
z(t) = \sum_{n} c_n \, e^{2\pi i n t}, \qquad c_n = \int_0^1 z(t)\, e^{-2\pi i n t}\, dt
\]

```js
// c[n] = { re, im } from a DFT of the sampled outline
let x = 0, y = 0;
for (const [n, c] of coefficients) {
  const phase = 2 * Math.PI * n * t;
  x += c.re * Math.cos(phase) - c.im * Math.sin(phase);
  y += c.re * Math.sin(phase) + c.im * Math.cos(phase);
}
```

Sample an outline, take the discrete Fourier transform, and animate the chain of circles.

**Hypotrochoids (spirograph).**

\[
x = (R - r)\cos t + d \cos\!\Big(\tfrac{R - r}{r}\, t\Big), \qquad
y = (R - r)\sin t - d \sin\!\Big(\tfrac{R - r}{r}\, t\Big)
\]

```js
const q = (R - r) / r;
x = (R - r) * Math.cos(t) + d * Math.cos(q * t);
y = (R - r) * Math.sin(t) - d * Math.sin(q * t);
```

**Phyllotaxis.** The arrangement of seeds and petals. Point \(n\) sits at

\[
\theta_n = n \cdot 137.508^{\circ}, \qquad r_n = c\sqrt{n}
\]

```js
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); // 137.508°
for (let n = 0; n < count; n++) {
  const theta = n * GOLDEN_ANGLE;
  const r = c * Math.sqrt(n);
  petal(cx + r * Math.cos(theta), cy + r * Math.sin(theta));
}
```

The golden angle \(360^\circ (1 - 1/\varphi)\) is the irrational rotation that packs most evenly, and the \(\sqrt{n}\) radius keeps density constant. Fibonacci spirals appear on their own.

**Looks like.** Op-art, string art, flower heads, sunflower centres, animals traced by the envelope of many segments.

---

## 2. Integer sequences and number theory

**Idea.** Interpret a sequence of integers as drawing instructions and let its structure become visible.

**Recamán sequence.**

\[
a_0 = 0, \qquad a_n = \begin{cases} a_{n-1} - n & \text{if positive and unvisited} \\ a_{n-1} + n & \text{otherwise} \end{cases}
\]

```js
let a = 0;
const seen = new Set([0]);
for (let n = 1; n < N; n++) {
  const back = a - n;
  a = back > 0 && !seen.has(back) ? back : a + n;
  seen.add(a);
  semicircle(prev, a, n % 2 ? "above" : "below");
}
```

Draw each jump as a semicircle, alternating above and below the axis.

**Ulam spiral.** Write the integers along a square spiral and mark the primes. Diagonal streaks appear because many diagonals are quadratic polynomials \(4n^2 + bn + c\) that are unusually prime-rich.

**Modular multiplication circles.** Place \(N\) points on a circle and connect each to its multiple:

\[
i \;\longrightarrow\; (m \cdot i) \bmod N
\]

```js
for (let i = 0; i < N; i++) {
  const j = (m * i) % N;
  line(pointOnCircle(i / N), pointOnCircle(j / N));
}
```

Sweeping \(m\) continuously traces a cardioid at \(m = 2\), a nephroid at \(m = 3\), and higher epicycloids beyond.

**Collatz orbits.**

\[
n \;\longrightarrow\; \begin{cases} n / 2 & n \text{ even} \\ 3n + 1 & n \text{ odd} \end{cases}
\]

```js
while (n !== 1) {
  turn(n % 2 === 0 ? -angle : +angle);
  forward(step);
  n = n % 2 === 0 ? n / 2 : 3 * n + 1;
}
```

Draw each orbit as a path turning one way on odd steps and the other on even ones; overlay thousands of starting values at low opacity.

**Binary-word fractals.** Take a self-similar sequence (Fibonacci word, Thue–Morse) and turn it into turtle moves: turn only on certain symbols. The self-similarity of the sequence becomes the self-similarity of the curve.

**Looks like.** Nested arcs, coral textures, cardioids, prime constellations.

---

## 3. Fractals and iterated systems

**Idea.** Repeat a simple rule; complexity comes from iteration rather than from the rule.

### 3.1 Escape-time fractals

Iterate a complex map and colour each pixel by how quickly its orbit escapes.

\[
z_{n+1} = z_n^2 + c, \qquad \text{smooth count: } n + 1 - \log_2 \log |z_n|
\]

```js
let zr = 0, zi = 0, n = 0;          // Mandelbrot: z0 = 0, c = pixel
while (zr * zr + zi * zi < 4 && n < maxIter) {
  const t = zr * zr - zi * zi + cr;
  zi = 2 * zr * zi + ci;
  zr = t;
  n++;
}
const smooth = n + 1 - Math.log2(Math.log(Math.hypot(zr, zi)));
```

*Mandelbrot*: \(z_0 = 0\), vary \(c\) over the plane. *Julia*: fix \(c\), vary \(z_0\).

For fine boundary detail, the **exterior distance estimate** is better than the iteration count. Track the derivative alongside the orbit:

\[
z'_{n+1} = 2 z_n z'_n, \qquad d \approx \frac{|z_n| \ln |z_n|}{|z'_n|}
\]

```js
// alongside the loop above, starting with dz = 1 (Julia) or 0 (Mandelbrot)
const ndr = 2 * (zr * dr - zi * di);
const ndi = 2 * (zr * di + zi * dr);
dr = ndr; di = ndi;
// after escape:
const mag = Math.hypot(zr, zi);
const dist = (mag * Math.log(mag)) / Math.hypot(dr, di);
```

This gives filaments of uniform width at any zoom. Choosing \(c\) at a Misiurewicz point (where the orbit of 0 is pre-periodic) gives dendritic, frost-like Julia sets.

### 3.2 Newton fractals

\[
z \;\leftarrow\; z - \frac{P(z)}{P'(z)}
\]

```js
for (let n = 0; n < maxIter; n++) {
  z = sub(z, div(P(z), dP(z)));
  const k = nearestRoot(z);
  if (k >= 0) { colour = ROOT_COLOURS[k]; shade = n; break; }
}
```

Colour each pixel by the root it converges to, shaded by how many steps it took. Basin boundaries are fractal.

### 3.3 Iterated function systems

Choose one of \(m\) affine maps at random with probability \(p_i\) and apply it; plot every visited point (the chaos game).

\[
w_i(x, y) = \begin{pmatrix} a_i & b_i \\ c_i & d_i \end{pmatrix}\begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} e_i \\ f_i \end{pmatrix}, \qquad A = \bigcup_i w_i(A)
\]

```js
let x = 0, y = 0;
for (let i = 0; i < points; i++) {
  const m = pickMap(maps, rng.next());       // weighted by m.p
  const nx = m.a * x + m.b * y + m.e;
  const ny = m.c * x + m.d * y + m.f;
  x = nx; y = ny;
  density[toPixel(x, y)] += 1;
}
```

The attractor is the unique set \(A\) that is the union of its own images. The Barnsley fern:

| map | a | b | c | d | e | f | p |
|---|---|---|---|---|---|---|---|
| stem | 0 | 0 | 0 | 0.16 | 0 | 0 | 0.01 |
| main | 0.85 | 0.04 | −0.04 | 0.85 | 0 | 1.60 | 0.85 |
| left | 0.20 | −0.26 | 0.23 | 0.22 | 0 | 1.60 | 0.07 |
| right | −0.15 | 0.28 | 0.26 | 0.24 | 0 | 0.44 | 0.07 |

Small changes to the coefficients give other species (Culcita, Cyclosorus). Because points land unevenly, the visit count must be tone-mapped:

\[
\text{tone} = \frac{\log(1 + d)}{\log(1 + d_{99})} \qquad \text{or} \qquad \alpha = 1 - e^{-d / d_0}
\]

```js
const d99 = percentile(density, 0.99);
tone = Math.log(1 + d) / Math.log(1 + d99);   // or: alpha = 1 - Math.exp(-d / d0)
```

### 3.4 L-systems

String rewriting interpreted as turtle graphics.

```text
axiom:  F
rule:   F → F[+F]F[−F]F
angle:  25°,  4–6 iterations

F  draw forward        +  turn left      [  push turtle state
−  turn right          ]  pop turtle state
```

Brackets push and pop turtle state; stochastic rules choose among alternatives with fixed probabilities. Add **taper** (width × 0.7 per level) and **tropism** (a small constant added to every angle) for gravity and light.

### 3.5 Recursive branching

The same idea without the string: a branch of length \(\ell\) and width \(w\) spawns \(k \in \{2, 3\}\) children.

\[
\ell' = s\,\ell,\; s \in [0.6, 0.76], \qquad w' = 0.62\, w, \qquad
\theta'_i = \theta + \Big(i - \tfrac{k-1}{2}\Big)\sigma + \varepsilon
\]

```js
function branch(x, y, len, angle, width, depth) {
  const x1 = x + Math.cos(angle) * len, y1 = y + Math.sin(angle) * len;
  stroke(x, y, x1, y1, width);
  if (depth === 0) return;
  const k = rng.chance(0.35) ? 3 : 2;
  const spread = rng.range(0.4, 0.75);
  for (let i = 0; i < k; i++) {
    const a = angle + (i - (k - 1) / 2) * spread + rng.gauss() * 0.12;
    branch(x1, y1, len * rng.range(0.6, 0.76), a, width * 0.62, depth - 1);
  }
}
```

Different constants give different species: long thin segments and many levels for a winter tree; short segments, wide spread and heavy width retention (\(0.84\, w\)) for a Joshua tree, with a rosette of 12–18 radiating spikes at each tip.

### 3.6 Strange attractors

Iterate a nonlinear map millions of times and accumulate a histogram of visited points.

\[
\text{Clifford:}\quad x' = \sin(a y) + c \cos(a x),\qquad y' = \sin(b x) + d \cos(b y)
\]
\[
\text{De Jong:}\quad x' = \sin(a y) - \cos(b x),\qquad y' = \sin(c x) - \cos(d y)
\]

```js
// Clifford
const nx = Math.sin(a * y) + c * Math.cos(a * x);
const ny = Math.sin(b * x) + d * Math.cos(b * y);
// De Jong
const nx = Math.sin(a * y) - Math.cos(b * x);
const ny = Math.sin(c * x) - Math.cos(d * y);
```

Continuous systems are integrated instead:

\[
\text{Lorenz:}\quad \dot x = \sigma(y - x),\qquad \dot y = x(\rho - z) - y,\qquad \dot z = xy - \beta z
\]

```js
// σ = 10, ρ = 28, β = 8/3, small dt (or RK4)
x += dt * sigma * (y - x);
y += dt * (x * (rho - z) - y);
z += dt * (x * y - beta * z);
```

Density plotted with a log tone map is what gives these their silk-like look; colour by local speed \(|p_{n+1} - p_n|\).

**Looks like.** Ferns, frost, coral, trees, nebulae, lace.

---

## 4. Noise and stochastic fields

**Idea.** A smooth pseudo-random function of position is the raw material for almost every organic texture.

**Gradient noise.** Assign a random gradient vector \(g\) to each lattice point; at \(p\), blend the dot products of the surrounding corners with a smooth fade.

\[
\text{noise}(p) = \sum_{\text{corners } q_i} \text{fade}(p - q_i)\; \big(g_i \cdot (p - q_i)\big), \qquad
\text{fade}(t) = 6t^5 - 15t^4 + 10t^3
\]

```js
const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
const u = fade(fx), v = fade(fy);
const n = lerp(v,
  lerp(u, dot(g00, [fx, fy]),     dot(g10, [fx - 1, fy])),
  lerp(u, dot(g01, [fx, fy - 1]), dot(g11, [fx - 1, fy - 1])));
```

The result is zero-mean, band-limited, and continuous.

**Fractional Brownian motion (fBm).** Sum octaves at doubling frequency and halving amplitude:

\[
f(p) = \sum_{i=0}^{n-1} a^i \, \text{noise}(2^i R\, p), \qquad a \approx 0.5
\]

```js
function fbm(x, y, octaves) {
  let sum = 0, amp = 1, freq = 1;
  for (let i = 0; i < octaves; i++) {
    sum += amp * noise(x * freq, y * freq);
    [x, y] = rotate(x, y, 0.5);            // small rotation per octave
    amp *= 0.5; freq *= 2;
  }
  return sum;
}
```

A small rotation \(R\) per octave avoids axis-aligned banding. Variants:

\[
\text{ridged: } \sum a^i \big(1 - |\text{noise}|\big)^2 \qquad
\text{turbulence: } \sum a^i\, |\text{noise}| \qquad
\text{quantized: } \lfloor L f \rfloor / L
\]

```js
ridged     += amp * Math.pow(1 - Math.abs(n), 2);   // sharp crests
turbulence += amp * Math.abs(n);                    // fire, marble
stepped     = Math.floor(L * f) / L;                // terraces (add a short riser per step)
```

**Domain warping.** Distort the input before evaluating:

\[
f\big(p + h(p)\big), \qquad \text{classically } \text{fbm}\big(p + \text{fbm}(p + \text{fbm}(p))\big)
\]

```js
const q = [fbm(x, y), fbm(x + 5.2, y + 1.3)];
const r = [fbm(x + 4 * q[0], y + 4 * q[1]), fbm(x + 4 * q[0] + 1.7, y + 4 * q[1] + 9.2)];
const v = fbm(x + 4 * r[0], y + 4 * r[1]);
```

Produces marble, smoke, and drifting curtains. **Anisotropy** (high frequency in \(x\), low in \(y\), evaluated at warped coordinates) reads as vertical light rays.

**1-D profiles.** A landscape silhouette is a function of \(x\) alone, closed into a polygon:

\[
y(x) = \text{base} - \text{amp}\cdot\big(0.45\, \text{fbm}(x) + 0.9\, \text{ridged}(x)^{1.4}\big), \qquad
\text{contrast} \propto e^{-\kappa \cdot \text{depth}}
\]

```js
for (let x = 0; x <= width; x += step) {
  const h = 0.45 * fbm01(x * f) + 0.9 * Math.pow(ridged(x * f * 2.6), 1.4);
  profile.push([x, base - amp * h]);
}
wash(closeToBottom(profile), mix(farColour, nearColour, depth), { opacity: 0.2 + 0.3 * depth });
```

Stacking several with paler colour and more haze as they recede is atmospheric perspective.

**Flow fields.** Sample noise to get an angle, drop particles, integrate, and draw the trails:

\[
\theta(p) = 2\pi\, \text{fbm}(p), \qquad p_{n+1} = p_n + h\,(\cos\theta, \sin\theta)
\]

```js
for (let i = 0; i < steps; i++) {
  const theta = 2 * Math.PI * fbm(x * scale, y * scale);
  x += h * Math.cos(theta);
  y += h * Math.sin(theta);
  path.push([x, y]);
}
```

Evenly spaced streamlines come from a seeding rule: start new lines a distance \(d_\text{sep}\) from existing ones and stop them when they come within \(d_\text{test}\) of another.

**Cellular noise (Worley / Voronoi).**

\[
F_1(p) = \min_i |p - q_i|, \qquad \text{borders: } F_2 - F_1
\]

```js
const d = featurePoints.map((q) => Math.hypot(p[0] - q[0], p[1] - q[1])).sort((a, b) => a - b);
const cells = d[0], borders = d[1] - d[0];
```

Scales, cracked earth, stained glass.

**Looks like.** Clouds, terrain, water, aurora, wood grain, marsh currents.

---

## 5. Distance functions and raymarching

**Idea.** Describe a scene as a function \(f(p)\) returning the signed distance to the nearest surface, then find surfaces by stepping along rays.

**Signed distance functions.**

\[
\text{sphere: } |p| - r, \qquad
\text{box: } \big|\max(|p| - b,\, 0)\big|
\]

```js
const sdSphere = (p, r) => length(p) - r;
const sdBox = (p, b) => length(max(abs(p) - b, 0));
```

Combine with \(\min\) (union), \(\max\) (intersection), \(\max(a, -b)\) (subtraction), and the smooth union:

\[
h = \text{clamp}\Big(\tfrac12 + \tfrac{b - a}{2k},\, 0,\, 1\Big), \qquad
\text{smin}(a, b) = \text{mix}(b, a, h) - k\,h\,(1 - h)
\]

```js
function smin(a, b, k) {
  const h = clamp(0.5 + (0.5 * (b - a)) / k, 0, 1);
  return mix(b, a, h) - k * h * (1 - h);
}
```

Normals are the gradient \(\nabla f\), estimated by central differences. Infinite repetition is free: evaluate at \(\text{mod}(p, c) - c/2\).

**Sphere tracing.** Step forward by the distance itself; it guarantees no surface is skipped.

\[
p_{n+1} = p_n + f(p_n)\, \hat d, \qquad \text{stop when } f(p) < \epsilon
\]

```js
let t = 0;
for (let i = 0; i < 128; i++) {
  const d = scene(add(origin, scale(dir, t)));
  if (d < 0.001) return t;          // hit
  t += d;
  if (t > far) break;
}
```

Soft shadows and ambient occlusion fall out of the same distance queries.

**Heightfield terrain.** Not a true SDF. Step by a fraction of the height above the terrain and add fog with distance:

\[
\Delta t = \kappa\,\big(p_y - h(p_{xz})\big),\; \kappa \approx 0.4 \qquad \text{fog} = 1 - e^{-\beta t}
\]

```js
for (let t = tmin; t < tmax; ) {
  const p = add(origin, scale(dir, t));
  const dh = p.y - terrain(p.x, p.z);     // fbm with few octaves
  if (dh < 0.002 * t) return t;           // tolerance grows with distance
  t += 0.4 * dh;
}
colour = mix(colour, fogColour, 1 - Math.exp(-beta * t));
```

**Looks like.** Sculpted 3D forms, infinite abstract architecture, mountains with proper lighting.

---

## 6. Simulation and emergence

**Idea.** Set up local rules, run them for many steps, and render either the final state or (usually better) the accumulated history.

**Cellular automata.** A grid where each cell's next state depends on its neighbourhood.

```text
Life:   survive with 2 or 3 live neighbours; born with exactly 3
Rule 30 (1-D):  new = left XOR (centre OR right)
```

Render 1-D rules as a time strip; continuous variants use a kernel and a smooth growth function.

**Reaction–diffusion (Gray–Scott).** Two concentrations \(u, v\):

\[
\frac{\partial u}{\partial t} = D_u \nabla^2 u - u v^2 + F(1 - u), \qquad
\frac{\partial v}{\partial t} = D_v \nabla^2 v + u v^2 - (F + k)\, v
\]

```js
// per cell, per step; lap = 5-point Laplacian
const uvv = u * v * v;
u1 = u + dt * (Du * lap(U, i) - uvv + F * (1 - u));
v1 = v + dt * (Dv * lap(V, i) + uvv - (F + k) * v);
```

Typical \(D_u = 1,\; D_v = 0.5\), \(F \in [0.01, 0.08]\), \(k \in [0.045, 0.07]\). Different \((F, k)\) give spots, stripes, mitosis, or worms; letting \(F\) and \(k\) vary across the canvas with noise gives several regimes in one image. Relight the final \(v\) as a height field for coral.

**Physarum (slime mould).** Agents sense a trail field ahead, turn toward the strongest, move, and deposit. The trail decays and blurs each frame.

\[
\theta \leftarrow \theta + \begin{cases} +\phi & T(\text{left}) \text{ strongest} \\ 0 & T(\text{centre}) \text{ strongest} \\ -\phi & T(\text{right}) \text{ strongest} \end{cases}, \qquad
T \leftarrow (1 - \delta)\, \text{blur}(T) + \text{deposits}
\]

```js
const L = sample(trail, x + so * Math.cos(a + sa), y + so * Math.sin(a + sa));
const C = sample(trail, x + so * Math.cos(a),      y + so * Math.sin(a));
const R = sample(trail, x + so * Math.cos(a - sa), y + so * Math.sin(a - sa));
if (C < L && C < R) a += rng.chance(0.5) ? ra : -ra;
else if (L > R) a += ra;
else if (R > L) a -= ra;
x += Math.cos(a) * speed; y += Math.sin(a) * speed;
trail[idx(x, y)] += deposit;
history[idx(x, y)] += deposit;          // render this, not the agents
```

Render the accumulated trail history: the network only appears when time is integrated.

**Differential growth.** A closed polyline whose nodes attract their neighbours, repel all other nodes within a radius, are smoothed toward the midpoint of their neighbours, and split when an edge stretches past a threshold.

\[
\Delta p_i = \alpha\,(p_{i-1} + p_{i+1} - 2p_i)
\;-\; \beta \sum_{|p_j - p_i| < \rho} \frac{p_j - p_i}{|p_j - p_i|}\Big(1 - \tfrac{|p_j - p_i|}{\rho}\Big)
\]

```js
for (const node of nodes) {
  let dx = alpha * (prev.x + next.x - 2 * node.x), dy = alpha * (prev.y + next.y - 2 * node.y);
  for (const other of nearby(node, rho)) {
    const d = dist(node, other), w = 1 - d / rho;
    dx -= beta * w * (other.x - node.x) / d;
    dy -= beta * w * (other.y - node.y) / d;
  }
  node.x += dx; node.y += dy;
}
splitEdgesLongerThan(maxEdge);
```

It buckles into coral and brain-like folds.

**Diffusion-limited aggregation.** Random walkers stick when they touch the cluster.

```js
let x = randomOnCircle(), y = ...;
while (!touchesCluster(x, y)) { x += rng.sign(); y += rng.sign(); }
cluster.add(x, y);
```

Produces dendrites: lightning, frost, coral.

**Branching random walks.** A path that wanders and forks with shrinking length is a cheap model of runoff channels, deltas, and roots.

\[
\theta_{n+1} = \theta_n + \varepsilon,\; \varepsilon \sim \mathcal N(0, \sigma^2), \qquad \ell' = \ell \cdot U(0.45, 0.7), \quad 1\text{–}3 \text{ forks per generation}
\]

```js
function runoff(x, y, angle, len, depth) {
  const path = [[x, y]];
  for (let i = 0; i < 8; i++) {
    angle += rng.gauss() * 0.25;
    x += Math.cos(angle) * (len / 8); y += Math.sin(angle) * (len / 8);
    path.push([x, y]);
  }
  stroke(path);
  if (depth === 0) return;
  for (let i = 0, k = rng.int(1, 3); i < k; i++) {
    const [px, py] = path[rng.int(3, 8)];
    runoff(px, py, angle + rng.range(-0.9, 0.9), len * rng.range(0.45, 0.7), depth - 1);
  }
}
```

**Boids.** Separation, alignment, cohesion; draw trails rather than agents.

**Chladni figures.** Nodal lines of a vibrating plate:

\[
\cos(n\pi x)\cos(m\pi y) - \cos(m\pi x)\cos(n\pi y) = 0
\]

```js
const f = Math.cos(n * Math.PI * x) * Math.cos(m * Math.PI * y)
        - Math.cos(m * Math.PI * x) * Math.cos(n * Math.PI * y);
ink = Math.exp(-Math.abs(f) * sharpness);   // dark where f ≈ 0
```

Plot where the function is near zero, or scatter particles that migrate downhill in \(|f|\).

**Looks like.** Coral, fungal networks, lichen, frost, flocks, deltas.

---

## 7. Tiling, packing, and constraint solving

**Idea.** Fill the plane with pieces under adjacency rules; the order comes from the constraints, the variety from the choices.

**Truchet tiles.** One tile with two quarter-arcs, placed on a grid in a random orientation. Because arcs meet edge midpoints, every placement is continuous, and the result is a labyrinth.

```js
for (const [i, j] of cells) {
  const flip = rng.chance(0.5);
  arc(i, j, flip ? "NW" : "NE"); arc(i, j, flip ? "SE" : "SW");
}
```

Recursive subdivision gives multi-scale versions.

**Wave function collapse.** Each cell holds a set of possible tiles. Repeatedly pick the cell with the lowest entropy, choose one tile, and propagate the adjacency constraints until nothing changes.

\[
H(\text{cell}) = -\sum_i p_i \log p_i
\]

```js
while (uncollapsed.length) {
  const cell = minBy(uncollapsed, entropy);
  cell.options = [weightedPick(cell.options)];
  propagate(cell);                 // remove neighbours' options that no longer fit
}
```

Coherent textures from a small example.

**Aperiodic tilings.** Substitution rules (Penrose kites and darts, rhombs) that never repeat yet show five-fold order.

**Circle packing.** Greedy rejection sampling for random packings; for the exact Apollonian gasket, Descartes' theorem on curvatures \(\kappa = 1/r\):

\[
(\kappa_1 + \kappa_2 + \kappa_3 + \kappa_4)^2 = 2\,(\kappa_1^2 + \kappa_2^2 + \kappa_3^2 + \kappa_4^2)
\qquad\Rightarrow\qquad
\kappa_4 = \kappa_1 + \kappa_2 + \kappa_3 \pm 2\sqrt{\kappa_1\kappa_2 + \kappa_2\kappa_3 + \kappa_3\kappa_1}
\]

```js
const k4 = k1 + k2 + k3 + 2 * Math.sqrt(k1 * k2 + k2 * k3 + k3 * k1);   // the inner circle
```

**Voronoi and recursive subdivision.** Partition by nearest site, or split rectangles at varying ratios; colour with harmonic hue offsets.

**Looks like.** Mazes, mosaics, circuit boards, stained glass, quasicrystals.

---

## 8. Three-dimensional and node-based workflows

**Idea.** Every 2D method above can be lifted into 3D: a curve becomes a swept tube, a field becomes a displacement or a density, an IFS becomes a point cloud.

**Scatter and instance.** Distribute points on a surface, instance a shape at each, and drive scale, rotation, and selection from a noise or cellular field evaluated at the point. Per-instance index gives deterministic variation.

**Displacement.** Push a mesh along its normal by a field:

\[
p' = p + \hat n\, \cdot \text{fbm}(p)\, h
\]

```js
vertex.add(normal.multiplyScalar(fbm(vertex.x, vertex.y, vertex.z) * height));
```

**Volumes.** Evaluate a density \(\rho(p)\) (domain-warped fBm) and render it with light scattering for clouds and nebulae.

\[
T = e^{-\int \rho(p)\, ds}, \qquad L = \int \rho(p)\, T(s)\, \text{light}(p)\, ds
\]

```js
let T = 1, L = 0;
for (let s = 0; s < steps; s++) {
  const rho = density(p);
  const a = 1 - Math.exp(-rho * ds);
  L += T * a * lightAt(p);
  T *= 1 - a;
  p = add(p, scale(dir, ds));
}
```

**Per-pixel programs.** A fragment shader evaluates a function at every pixel in parallel, which is the natural home for SDFs, raymarching, noise, and reaction–diffusion.

---

## 9. Physical media: watercolor simulation

**Idea.** Treat the image as wet pigment on paper rather than as a shaded colour field. The geometry still comes from a mathematical rule; the *surface* comes from simulating what pigment does.

**Deformed-polygon washes.** A wash is a polygon whose edges are recursively displaced: subdivide each edge at its midpoint and offset it along the normal.

\[
\delta = \mathcal{N}(0, 1)\cdot \mu \cdot \min(\ell, \ell_\text{max}), \qquad
\text{sheet} \mathrel{+}= \frac{\alpha}{L}\sum_{j=1}^{L} \text{raster}\big(\text{deform}_j(\text{polygon})\big)
\]

```js
function deform(poly, iterations, mu) {
  for (let it = 0; it < iterations; it++) {
    const out = [];
    for (let i = 0; i < poly.length; i++) {
      const a = poly[i], b = poly[(i + 1) % poly.length];
      const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
      const d = rng.gauss() * mu * Math.min(len, maxEdge);
      const nx = -(b[1] - a[1]) / len, ny = (b[0] - a[0]) / len;
      out.push(a, [(a[0] + b[0]) / 2 + nx * d, (a[1] + b[1]) / 2 + ny * d]);
    }
    poly = out;
  }
  return poly;
}
for (let j = 0; j < layers; j++) coverage += rasterize(deform(base, 2, bleed)) / layers;
```

The base polygon is re-displaced independently for each of \(L\) glaze layers, and the layers are stacked at opacity \(\alpha / L\). The edge stays irregular but legible; the interior granulates where layers disagree. Blurring a mask is **not** the same thing: it produces fog, not paint.

**Subtractive pigment.** Each wash of colour \(c\) adds absorbance, and the sheet transmits what remains:

\[
A = -\ln c, \qquad D \leftarrow D + \alpha\, C(p)\, A, \qquad \text{sheet} = \text{paper}\cdot e^{-D}
\]

```js
const A = colour.map((c) => -Math.log(Math.max(c / 255, 1e-3)));   // per channel
for (const i of pixelsUnderWash) {
  D[i] += opacity * coverage[i] * A[ch];
}
// at the end:
out = paper * Math.exp(-D);
```

One wash at full opacity reproduces its colour exactly; two glazes multiply, which is what real watercolor does and what alpha-blending gets wrong. Stacked glazes reach black quickly, so per-layer opacity must fall as layers accumulate.

**Paper tooth and edge pooling.** Coverage is multiplied by a high-frequency noise field (granulation), and extra pigment is added where coverage changes fastest (the dark rim of a drying wash).

\[
C'(p) = C(p)\,\big(1 - \tau + \tau\, \text{tooth}(p)\big) + b\, |\nabla C(p)|
\]

```js
const gx = C[i + 1] - C[i - 1], gy = C[i + w] - C[i - w];
Cp = C[i] * (1 - texture + texture * tooth[i]) + edge * Math.hypot(gx, gy);
```

**Reserve and lift.** *Reserved* pixels never take pigment and read as the white of the sheet: the cyanotype fern is an IFS density used as a mask. *Lifting* removes pigment already laid down, for mist, snow, waterfalls, god-rays, and highlights.

\[
D \leftarrow D\,\big(1 - \lambda\, M(p)\big)
\]

```js
const mask = blur(rasterize(polygon), softness);
for (let i = 0; i < D.length; i++) D[i] *= 1 - amount * mask[i];
```

**Rings.** Concentric washes multiply to black at the centre. An annulus rasterizes the outer polygon with weight \(+1\) and the inner with \(-1\), so each band keeps its own colour.

```js
coverage = rasterize(deform(outer), +1) + rasterize(deform(hole), -1);   // clamp ≥ 0
```

**Composition rules that follow from the physics.**

- Close a landscape polygon where the next thing starts (the waterline), not at the bottom of the sheet; otherwise it lies under everything painted later and darkens it.
- Lift under anything that must read lighter than what is already there (shore, boulders).
- Let the mathematics draw (IFS, phyllotaxis, ridged profile, recursion) and let the medium paint. Hand-placed blobs with heavy bleed do not survive.
- Keep the mark vocabulary tiny: `wash(polygon, colour, {opacity, bleed, layers, texture, edge})`, `stroke`, `lift`, `reserve`. That is what keeps the picture editable as a program.

**Looks like.** Cyanotypes, herbarium plates, dahlias, ink-wash mountains, national-park poster plates.

---

## Where each method is used

All projects live in `mathematical-art-projects/` and share `_shared/` (noise, seeded RNG, float raster, watercolor sheet, scenery helpers, PNG export, browser viewer). Each folder has `art.js` with `render(pixels, w, h, seed)`, a viewer `index.html`, and `export.js` for a 4K PNG.

| Project | Method | Section |
|---|---|---|
| Fern Grove | Barnsley IFS chaos game, perturbed maps, log tone map | §3.3 |
| Attractor Nebula | Clifford / De Jong histogram, speed → hue | §3.6 |
| Coral Reef | Gray–Scott with noise-varying \(F, k\), relit as height | §6 |
| Autumn Tree | Stochastic bracketed L-system with taper and tropism; warped clouds | §3.4, §4 |
| Tidal Marsh | Flow field with evenly spaced streamlines | §4 |
| Window Frost | Julia set at a Misiurewicz point, distance estimation | §3.1 |
| Mycelium | Physarum agents, trail history | §6 |
| Aurora | Anisotropic domain-warped fBm curtains | §4 |
| Cyanotype Meadow | Glazed washes over an IFS / bezier reserve, lifted toward the light | §9, §3.3 |
| Dahlia | Vogel phyllotaxis placing glazed petals | §9, §1 |
| Fern Plate | Three IFS species as pigment density, wet then dry | §9, §3.3 |
| Misty Ridges | Ridged fBm profiles as washes, lifting between layers, recursive trees | §9, §4, §3.5 |
| Glacier: Hidden Lake | Ridged profiles with a shaped horn; mirrored reflection; snow lifted | §9, §4 |
| Yosemite: Tunnel View | Power-curve cliff, circular arc cut by a chord; waterfall lifted | §9 |
| Grand Canyon: South Rim | Quantized fBm terraces with a Gaussian gorge | §9, §4 |
| Yellowstone: Grand Prismatic | Annular washes per temperature band; branching random-walk runoff | §9, §6 |
| Joshua Tree: Dusk | Three-level branching recursion with rosette tips | §9, §3.5 |
| Redwood: Fog in the Grove | Trunks in depth planes separated by lifted fog; IFS ferns | §9, §4, §3.3 |

Not yet built: a primitive swarm (§1), a Truchet or wave-function-collapse piece (§7), a true heightfield raymarch (§5), and a geometrically grown coral by differential growth or DLA (§6).
