Generative drawing · Processing, ported to the browser
Study of Progress cover
A feedback loop, and a network that grows out of it.
The cover of The Study of Progress comes from a Processing sketch. In the middle is a feedback loop with two smaller loops inside it, each with two more inside. A network of roads grows out of its bottom-left corner and forks more often the further it gets from the loop. Each seed gives a different drawing. The cover uses seed 23.
Seed 23Click for a new seed · keys 1–4, n, b, s
The code
Paste this into a new Processing 4 sketch and press Run. Keys 1–4 switch mode, space or n gives a new seed, b goes back one, g toggles the title guide and s saves a 3600 × 5400 PNG and an SVG to out/.
sop_fractal.pde · 366 lines
Download
// SoP cover experiment: generative fractal in the cover's palette.
//
// Two ideas, alone or together:
// 1 runaway a branching network grows out of the control loop's node and
// forks more the further it gets from the loop (runaway complexity)
// 2 recursion every feedback loop contains two smaller feedback loops
// (Beer's recursive system theorem), cascading along the diagonal
// 3 both
// 4 civilisation: 3, plus tapering organic routes, settlements at the forks,
// street grids round the old towns, lights along the roads and
// cross-links that turn the tree into a network
//
// Keys: 1-4 mode, space or n = new seed, b = back one seed,
// g = toggle title-zone guide, s = save PNG (3600x5400) + SVG to out/
//
// Headless: SOP_EXPORT=1 SOP_MODE=3 SOP_SEED=7 Processing cli --sketch=... --run
import processing.svg.*;
final int W = 1200, H = 1800; // design units, 2:3 like the 6x9in cover
final color RED = #B8141A, CREAM = #F4F1EA, INK = #141010;
int MODE = 4;
long SEED = 23; // the book cover (adopted 2026-11-07)
boolean guide = true;
boolean exporting = false;
// SOP_BOLD=<factor>: heavier main loop, symbols and roads so the cover reads from
// a distance (thumbnail, shelf). Weights and sizes only; geometry and seed unchanged.
// Default 2.5 = the cover since 2026-11-12; SOP_BOLD=1 gives the earlier thin lines.
float BOLD = 2.5;
// main loop box, same placement as the current cover
float bx0, by0, bx1, by1;
float TITLE_Y = H * 0.30; // keep the network below the title block
int segs; // runaway guard
final int MAX_SEGS = 40000;
PGraphics view;
void settings() {
size(600, 900);
pixelDensity(displayDensity());
}
void setup() {
bx0 = W * 0.21; bx1 = W * 0.79; by0 = H * 0.51; by1 = H * 0.77;
// Square main loop by default (the cover since 2026-11-12): same area as the old
// rectangle, node (bottom-left, where the network starts) kept in place.
// SOP_SQUARE=0 brings back the rectangle; SOP_SQUARE=<side in design units> sets the side.
String sq = System.getenv("SOP_SQUARE");
if (sq == null) sq = "1";
if (!sq.equals("0")) {
float side = sq.equals("1") ? sqrt((bx1 - bx0) * (by1 - by0)) : float(sq);
bx1 = bx0 + side; by0 = by1 - side;
}
String bo = System.getenv("SOP_BOLD");
if (bo != null) BOLD = float(bo);
String m = System.getenv("SOP_MODE"), s = System.getenv("SOP_SEED");
if (m != null) MODE = int(m);
if (s != null) SEED = Long.parseLong(s);
if (System.getenv("SOP_EXPORT") != null) {
guide = false;
exportAll();
exit();
return;
}
redrawView();
}
void draw() {
image(view, 0, 0, width, height);
}
void redrawView() {
view = createGraphics(W, H);
render(view, 1);
surface.setTitle("SoP fractal mode " + MODE + " seed " + SEED);
}
void keyPressed() {
if (key >= '1' && key <= '4') MODE = key - '0';
else if (key == ' ' || key == 'n') SEED++;
else if (key == 'b') SEED = Math.max(0L, SEED - 1);
else if (key == 'g') guide = !guide;
else if (key == 's') { boolean gg = guide; guide = false; exportAll(); guide = gg; }
else return;
redrawView();
}
void exportAll() {
String base = sketchPath("out/sop_fractal_m" + MODE + "_s" + SEED);
PGraphics png = createGraphics(W * 3, H * 3);
render(png, 3);
png.save(base + ".png");
PGraphics svg = createGraphics(W, H, SVG, base + ".svg");
render(svg, 1);
svg.dispose();
println("saved " + base + ".png/.svg");
}
// ------------------------------------------------------------------ scene
void render(PGraphics g, float s) {
randomSeed(SEED);
noiseSeed(SEED);
segs = 0;
g.beginDraw();
g.scale(s);
g.background(RED);
g.strokeCap(ROUND);
g.strokeJoin(ROUND);
g.noFill();
civ = MODE == 4;
if (MODE >= 2) {
loopSystem(g, bx0, by0, bx1, by1, 0, MODE == 2 ? 4 : 3);
} else {
feedbackLoop(g, bx0, by0, bx1, by1, 0);
}
if (MODE != 2) runaway(g);
if (guide) {
g.noStroke();
g.fill(255, 30);
g.rect(0, 0, W, TITLE_Y);
g.fill(255, 120);
g.textSize(18);
g.text("title zone", 40, TITLE_Y - 20);
g.noFill();
}
g.endDraw();
}
// ------------------------------------------------------------------ runaway
// settlements (forks) and frontier ends (tips), collected while growing;
// z holds the branch depth, so low z = old trunk road = big town
ArrayList<PVector> forks = new ArrayList<PVector>();
ArrayList<PVector> tips = new ArrayList<PVector>();
boolean civ; // mode 4 extras on/off
void runaway(PGraphics g) {
forks.clear();
tips.clear();
// a few trunks fanned between horizontal and steep up-right, like the cover
int trunks = civ ? 6 : 5;
for (int i = 0; i < trunks; i++) {
float t = i / float(trunks - 1);
float target = lerp(-0.08, -1.05, t) + random(-0.06, 0.06);
float a = lerp(-0.25, -0.75, t);
branch(g, bx0, by1, a, target, 7, 0, (civ ? 3.4 : 2.6) * pow(BOLD, 0.4), int(random(150, 220)));
}
if (civ) civilisation(g);
}
float fadeAt(float y) { // dissolve under the title
return constrain((y - TITLE_Y) / 140.0, 0, 1);
}
void branch(PGraphics g, float x, float y, float a, float target,
float step, int depth, float w, int life) {
float off = random(1000); // each branch meanders on its own
float wig = civ ? 0.46 : 0.32; // more wander = more organic
for (int i = 0; i < life; i++) {
if (++segs > MAX_SEGS) break;
// meander with noise, pulled back toward this branch's own heading
a += (noise(off + i * 0.09) - 0.5) * wig + (target - a) * 0.14;
float nx = x + cos(a) * step, ny = y + sin(a) * step;
if (nx > W + 10 || nx < -10 || ny > H * 0.94 || ny < TITLE_Y) break;
boolean inside = insideBox((x + nx) / 2, (y + ny) / 2);
float fade = fadeAt(ny);
// taper along the branch, like a root or a river
float ww = civ ? w * lerp(1.0, 0.55, i / float(life)) : w;
g.stroke(inside ? INK : CREAM, 255 * fade);
g.strokeWeight(max(0.4, ww * (inside ? 1.0 : 0.8)));
g.line(x, y, nx, ny);
float d = dist(nx, ny, bx0, by1) / W;
// lights along the routes, thicker the further out (sprawl)
if (civ && !inside && random(1) < 0.05 + 0.3 * d) {
float r = random(0.35, 1.0);
float ox = random(-6, 6), oy = random(-6, 6);
g.noStroke();
g.fill(CREAM, 255 * fade * random(0.45, 1));
g.ellipse(nx + ox, ny + oy, r * 2, r * 2);
g.noFill();
}
// forking gets likelier with distance from the loop: runaway complexity
float p = civ ? 0.012 + 0.11 * d * d : 0.01 + 0.09 * d * d;
int maxD = civ ? 9 : 8;
if (depth < maxD && i > 5 && random(1) < p) {
float side = random(1) < 0.5 ? -1 : 1;
float da = side * random(0.25, 0.65);
float nt = constrain(target + da * 0.5, -1.35, 0.25);
forks.add(new PVector(nx, ny, depth));
branch(g, nx, ny, a + da, nt, step * 0.93,
depth + 1, ww * 0.72, int((life - i) * random(0.5, 0.95)));
}
x = nx; y = ny;
}
tips.add(new PVector(x, y, depth));
}
// ------------------------------------------------------------------ civilisation
void civilisation(PGraphics g) {
ArrayList<PVector> all = new ArrayList<PVector>(forks);
all.addAll(tips);
// cross-links: frontier ends join up with nearby places, the tree becomes a network
for (PVector t : tips) {
if (insideBox(t.x, t.y) || random(1) < 0.25) continue;
PVector best = null;
float bd = 60;
for (PVector o : all) {
float dd = dist(t.x, t.y, o.x, o.y);
if (dd > 14 && dd < bd) { bd = dd; best = o; }
}
if (best == null) continue;
g.stroke(CREAM, 170 * min(fadeAt(t.y), fadeAt(best.y)));
g.strokeWeight(0.55);
wobblyLine(g, t.x, t.y, best.x, best.y);
}
// street grids around a handful of the old, big towns
for (PVector f : forks) {
if (f.z > 1 || insideBox(f.x, f.y) || random(1) < 0.5) continue;
streetGrid(g, f.x, f.y, random(9, 15), random(TWO_PI), fadeAt(f.y));
}
// settlements: a hierarchy, few towns, more villages, mostly unmarked forks
for (PVector f : forks) {
boolean inside = insideBox(f.x, f.y);
if (inside && f.z > 0) continue; // keep the loop readable
float keep = f.z <= 1 ? 1.0 : f.z <= 3 ? 0.45 : 0.18;
if (random(1) > keep) continue;
float r = (f.z == 0 ? 4.2 : f.z <= 2 ? 2.6 : 1.5) * (f.z <= 2 ? pow(BOLD, 0.4) : 1);
float fade = fadeAt(f.y);
g.noStroke();
g.fill(inside ? INK : CREAM, 255 * fade);
g.ellipse(f.x, f.y, r * 2, r * 2);
if (f.z == 0 && !inside) {
g.noFill();
g.stroke(CREAM, 200 * fade);
g.strokeWeight(0.7);
g.ellipse(f.x, f.y, r * 4.2, r * 4.2);
}
}
g.noFill();
}
// a small irregular block plan, rotated to the local road
void streetGrid(PGraphics g, float cx, float cy, float s, float rot, float fade) {
g.pushMatrix();
g.translate(cx, cy);
g.rotate(rot);
g.stroke(CREAM, 120 * fade);
g.strokeWeight(0.4);
int n = int(random(3, 5));
for (int k = 0; k < n; k++) {
float u = map(k, 0, n - 1, -s, s) + random(-2, 2);
float a0 = -s * random(0.5, 1), a1 = s * random(0.5, 1);
g.line(u, a0, u + random(-1.5, 1.5), a1); // one direction
float v = map(k, 0, n - 1, -s, s) + random(-2, 2);
float b0 = -s * random(0.5, 1), b1 = s * random(0.5, 1);
g.line(b0, v, b1, v + random(-1.5, 1.5)); // the other
}
g.popMatrix();
g.noFill();
}
boolean insideBox(float x, float y) {
return x > bx0 && x < bx1 && y > by0 && y < by1;
}
// ------------------------------------------------------------------ recursion
// A loop containing two smaller loops, each containing two more ...
void loopSystem(PGraphics g, float x0, float y0, float x1, float y1,
int level, int maxLevel) {
feedbackLoop(g, x0, y0, x1, y1, level);
float w = x1 - x0, h = y1 - y0;
if (level >= maxLevel || w < 40) return;
float k = 0.40; // child scale
float pad = w * 0.09;
float cw = w * k, ch = h * k;
// lower-left child and upper-right child, along the diagonal of growth
float jx = random(-0.02, 0.02) * w, jy = random(-0.02, 0.02) * h;
loopSystem(g, x0 + pad + jx, y1 - pad - ch + jy, x0 + pad + cw + jx, y1 - pad + jy,
level + 1, maxLevel);
loopSystem(g, x1 - pad - cw - jx, y0 + pad * 1.6 - jy, x1 - pad - jx, y0 + pad * 1.6 + ch - jy,
level + 1, maxLevel);
// the two subsystems talk: a thin channel between their nodes
g.stroke(INK);
g.strokeWeight(max(0.5, 1.3 - level * 0.3));
wobblyLine(g, x0 + pad + cw + jx, y1 - pad - ch * 0.5 + jy,
x1 - pad - cw - jx, y0 + pad * 1.6 + ch * 0.5 - jy);
}
// One hand-drawn control loop: node, forward path, amplifier, comparator.
void feedbackLoop(PGraphics g, float x0, float y0, float x1, float y1, int level) {
float bf = level == 0 ? BOLD : level == 1 ? sqrt(BOLD) : 1;
float sw = max(0.5, 2.6 * pow(0.62, level)) * bf;
float u = (x1 - x0) / (bx1 - bx0) * pow(bf, 0.6); // symbol scale relative to the main loop
g.stroke(INK);
g.strokeWeight(sw);
g.noFill();
wobblyLine(g, x0, y1, x1, y1); // bottom, left to right
wobblyLine(g, x1, y1, x1, y0); // right, upward
wobblyLine(g, x1, y0, x0, y0); // top, right to left
wobblyLine(g, x0, y0, x0, y1); // left, downward
float ah = max(3, 16 * u);
arrow(g, lerp(x0, x1, 0.5), y1, 0, ah);
arrow(g, x1, lerp(y1, y0, 0.25), -HALF_PI, ah);
arrow(g, x1, lerp(y1, y0, 0.75), -HALF_PI, ah);
arrow(g, lerp(x1, x0, 0.25), y0, PI, ah);
arrow(g, lerp(x1, x0, 0.75), y0, PI, ah);
arrow(g, x0, lerp(y0, y1, 0.5), HALF_PI, ah);
// amplifier triangle on the right
float ts = 26 * u, ty = lerp(y1, y0, 0.55);
g.fill(RED);
g.triangle(x1, ty - ts, x1 - ts * 0.75, ty + ts * 0.5, x1 + ts * 0.75, ty + ts * 0.5);
// comparator on the top
float r = 30 * u, cx = lerp(x0, x1, 0.5);
g.ellipse(cx, y0, r * 2, r * 2);
g.noFill();
g.ellipse(cx, y0, r * 0.9, r * 0.9);
g.line(cx - r * 0.3, y0 - r * 0.1, cx + r * 0.3, y0 - r * 0.1);
g.line(cx - r * 0.3, y0 + r * 0.12, cx + r * 0.3, y0 + r * 0.12);
// node
g.noStroke();
g.fill(INK);
g.ellipse(x0, y1, max(3, 14 * u), max(3, 14 * u));
g.noFill();
}
void arrow(PGraphics g, float x, float y, float a, float s) {
g.pushMatrix();
g.translate(x, y);
g.rotate(a);
g.noStroke();
g.fill(INK);
g.triangle(s * 0.6, 0, -s * 0.6, -s * 0.38, -s * 0.6, s * 0.38);
g.popMatrix();
g.noFill();
g.stroke(INK);
}
// hand-drawn line: low-frequency noise wobble, pinned near the ends
void wobblyLine(PGraphics g, float x1, float y1, float x2, float y2) {
float d = dist(x1, y1, x2, y2);
if (d < 1) return;
int n = max(2, int(d / 5));
float nx = -(y2 - y1) / d, ny = (x2 - x1) / d;
float off = random(1000);
float amp = min(3.2, d * 0.012);
g.beginShape();
for (int i = 0; i <= n; i++) {
float t = i / float(n);
float o = (noise(off + t * d * 0.012) - 0.5) * 2 * amp * sin(PI * t);
g.vertex(lerp(x1, x2, t) + nx * o, lerp(y1, y2, t) + ny * o);
}
g.endShape();
}