PL. 141 · LATTICES / GROWTH / DIFFERENTIAL LINE
Differential Line Growth
Common practice in generative art; earliest documented account Anders Hoff, 2016
OPEN THE LIVE PLATE ▸DEFINITION
attract: kA(d-d0) toward each neighbour along the ring repel: kR sum over nodes within r (spatial hash), (r-d)/r away align: kG toward the neighbour midpoint split a ring edge past d0*grow; merge one below 0.4*d0
NOTES
A closed ring of nodes negotiates its own shape under three local rules: every node is pulled toward its two ring neighbours by a spring at rest length d0, pushed away from any node at all — ring neighbour or not — that has drifted within a repulsion radius, and nudged toward the midpoint its two neighbours already sit on, which damps sharp kinks. Where two ring neighbours drift further apart than a growth threshold a new node is inserted between them; where they crowd closer than a floor, one is dropped. Nothing here computes a global shape or knows the ring is a ring beyond its own two neighbours — and the ring still finds room where there is room and buckles into meanders where there is not. The repulsion query runs through a spatial hash rather than a check against every other node, because a differential-growth ring without one is quadratic in the node count, and the node count is exactly what keeps rising.
PROVENANCE
- Origin
- Common practice in the generative-art and creative-coding community; no single paper defines this algorithm. The clearest early published account is Anders Hoff, Differential Line, inconvergent.net, 2016, and Nervous System built Floraform (2015), a differentially grown surface, on the same mechanism. Implemented here from the published description of the three rules — attraction, repulsion, resampling — never from any repository
- Related physics, not source
- E. Sharon, B. Roman, M. Marder, G.-S. Shin, H. L. Swinney, Buckling cascades in free sheets, Nature 419, 579 (2002), doi:10.1038/419579a — verified on Crossref against title, authors, year and venue. Torn plastic sheets and growing leaves both buckle into repeating wavy edges once a free boundary is made to grow faster than the surface behind it can stay flat, which is the same crowding this plate enacts with discrete nodes and pairwise repulsion rather than with an elastic sheet. Named here as the nearest physics to what the plate shows, never as the algorithm origin — the rules below were never derived from that paper and do not solve its equations
- Standing
- Public domain — implemented from the published description of the mechanism, not from any repository
- Constants
- The repulsion radius sets the packing scale; the growth threshold is a multiple of the rest spacing it derives from, so raising it lets segments stretch further before a node is inserted, which slows how fast the ring fills the frame
HOUDINI · VEX
The same published mathematics as a Detail Wrangle body. Paste it into a Wrangle with Run Over set to Detail; every constant is the published value plus a tweak channel, so Create Spare Parameters gives a slider that starts where the paper does.
// FORMA — PL. 141 · DIFFERENTIAL LINE GROWTH — Common practice in generative art; earliest documented account Anders Hoff, 2016
// attract: kA(d-d0) toward each neighbour along the ring
// repel: kR sum over nodes within r (spatial hash), (r-d)/r away
// align: kG toward the neighbour midpoint
// split a ring edge past d0*grow; merge one below 0.4*d0
// Paste into a Detail Wrangle (Run Over: Detail), no inputs needed.
// Written from the published mathematics, not adapted from any code.
// Constants arrive at their published values. Press the node's Create
// Spare Parameters button and every tweak becomes a slider — starting
// at 0, the published figure, and moving in the constant's own units.
// https://forma-gen.com/#plate=diffgrowth
float p_repel = 0.024 + chf('repel_tweak'); // repulsion radius · live 0.012 .. 0.05
float p_grow = 1.5 + chf('grow_tweak'); // growth threshold (x spacing) · live 1.3 .. 1.75
float p_cap = 550 + chf('cap_tweak'); // node cap · live 250 .. 900
// The plate's own colour: FORMA's LATTICES accent as a cosine ramp,
// brightest near t = 0 and t = 1, near-black around t = 0.5.
vector forma_ramp(float t){
return set(
0.46 + 0.5 * cos(6.28318530718 * (t + 0)),
0.4185 + 0.4549 * cos(6.28318530718 * (t + 0.05)),
0.1389 + 0.151 * cos(6.28318530718 * (t + 0.1)));
}
// Differential growth run to a fixed step count in one cook: the same three
// local rules the plate steps once a frame — attract each node toward its
// two ring neighbours at a rest spacing, repel it from any node at all
// (ring neighbour or not) that has drifted within a repulsion radius via a
// spatial hash, and nudge it toward the neighbour midpoint to damp kinks —
// plus the same resample pass that inserts a node where a ring edge has
// stretched past the growth threshold and drops one where two have crowded
// below a floor. The plate stops on its own fill/cap/stall test and holds
// the result on screen; a cook has no frame loop to hold anything on, so
// this runs a fixed 200 steps instead, comfortably past where the plate's
// own test fires across the declared box (measured on the page's own code,
// holding the frame-rate dial at its default: 12 to 134 steps to finish
// across the eight corners of repel x grow x cap). Past that the resample
// pass keeps respecting the node cap on its own (an explicit insertion
// budget, not a check on the array it is building — see below), so extra
// steps only relax the shape further rather than overrunning it.
//
// waived: rate — it paces the plate's growth per frame, and a cook has no
// clock; this runs its own fixed step count instead of stepping frame by
// frame at a chosen rate
//
// The physics runs in the SAME unit-width (0..1) coordinates the page
// itself works in — repulsion is an average of unit vectors, which carries
// no length scale of its own, so it only balances against the attraction
// spring's actual-distance forces at the one scale the page's constants
// were tuned against. Multiplying every length by a 560-unit reference
// before running the physics (this port's first draft) left attraction
// scaled by 560 while repulsion stayed unitless, so repulsion collapsed to
// a rounding error next to attraction and the ring stalled at its seed
// count regardless of the dial — caught by comparing this port's own
// growth against the page's measured range and finding it order-of-
// magnitude short, not by inspection. The fix is to keep the physics at
// unit scale and only step up to the 560-unit reference when points are
// finally emitted, which is exactly where the page itself performs that
// multiplication (at draw time, never inside the physics). Canvas y is
// negated onto Houdini's up at that same final step. Domain is a 1x1 unit
// square with a 6% inset — the page's own domain is a live card aspect
// this cook has no card to match, so it takes the square case, same choice
// lloyd and venation made for their own reference canvases.
float Wref = 560.0;
float mg = 0.06;
float x0 = mg, x1 = 1.0 - mg, y0 = mg, y1 = 1.0 - mg;
int forma_steps = 200; // fixed step count this cook runs
int forma_cap = int(rint(p_cap));
float cell = p_repel;
float restSpacing = cell * 0.5;
float splitDist = restSpacing * p_grow;
float pruneDist = restSpacing * 0.4;
float maxDisp = restSpacing * 0.6;
float kAttract = 0.5, kAlign = 0.12, kRepel = 0.9, kBoundary = 0.6;
// the seed ring: density derived from the repulsion radius, exactly as the
// page derives it, so the opening ratio of spacing to rest length is the
// same at every repel value rather than an arbitrary fixed node count
int rc = 2016; // seeded(2016 + epoch*4409) at epoch 0
float cx = (x0 + x1) / 2.0, cy = (y0 + y1) / 2.0, r0 = 0.045;
int N0 = int(max(16, rint(2.0 * M_PI * r0 / (restSpacing / 0.7))));
float nx[], ny[];
for (int i = 0; i < N0; i++){
float a = (float(i) / float(N0)) * M_PI * 2.0 + (random(rc) - 0.5) * 0.04; rc++;
float rr = r0 * (0.96 + random(rc) * 0.08); rc++;
push(nx, cx + cos(a) * rr);
push(ny, cy + sin(a) * rr);
}
for (int step = 0; step < forma_steps; step++){
int n = len(nx);
// spatial hash: every node bucketed into a grid cell sized to the
// repulsion radius via a counting-sort linked list, so the repulsion
// query below walks nine cells instead of testing every other node —
// the page's own reason, ported line for line
int gw = int(ceil((x1 - x0) / cell)) + 3;
int gh = int(ceil((y1 - y0) / cell)) + 3;
int head[]; resize(head, gw * gh);
for (int k = 0; k < gw * gh; k++) head[k] = -1;
int nextIdx[]; resize(nextIdx, n);
for (int i = 0; i < n; i++){
int gx = clamp(int(floor((nx[i] - x0) / cell)) + 1, 0, gw - 1);
int gy = clamp(int(floor((ny[i] - y0) / cell)) + 1, 0, gh - 1);
int key = gy * gw + gx;
nextIdx[i] = head[key];
head[key] = i;
}
float scratchx[], scratchy[];
resize(scratchx, n); resize(scratchy, n);
for (int i = 0; i < n; i++){
int pi = (i - 1 + n) % n, ni = (i + 1) % n;
float xi = nx[i], yi = ny[i];
// attract + align: a spring to each ring neighbour at rest length
// restSpacing, plus a pull toward the neighbour midpoint that damps
// kinks a spring alone leaves jagged
float dxP = nx[pi] - xi, dyP = ny[pi] - yi;
float dP = sqrt(dxP * dxP + dyP * dyP); if (dP < 1e-6) dP = 1e-6;
float dxN = nx[ni] - xi, dyN = ny[ni] - yi;
float dN = sqrt(dxN * dxN + dyN * dyN); if (dN < 1e-6) dN = 1e-6;
float fP = (dP - restSpacing) * kAttract, fN = (dN - restSpacing) * kAttract;
float ax = (dxP / dP) * fP + (dxN / dN) * fN;
float ay = (dyP / dP) * fP + (dyN / dN) * fN;
ax += ((nx[pi] + nx[ni]) / 2.0 - xi) * kAlign;
ay += ((ny[pi] + ny[ni]) / 2.0 - yi) * kAlign;
// repel: every node within the radius, ring neighbour or not, found
// through the hash above; the immediate ring neighbours are skipped
// so the spring above stays the only force between them
int gx = clamp(int(floor((xi - x0) / cell)) + 1, 0, gw - 1);
int gy = clamp(int(floor((yi - y0) / cell)) + 1, 0, gh - 1);
float rx = 0.0, ry = 0.0;
int rcount = 0;
for (int dgy = -1; dgy <= 1; dgy++){
int cgy = gy + dgy;
if (cgy < 0 || cgy >= gh) continue;
for (int dgx = -1; dgx <= 1; dgx++){
int cgx = gx + dgx;
if (cgx < 0 || cgx >= gw) continue;
int j = head[cgy * gw + cgx];
while (j >= 0){
if (j != i && j != pi && j != ni){
float dx = xi - nx[j], dy = yi - ny[j];
float d2 = dx * dx + dy * dy;
if (d2 < cell * cell && d2 > 1e-14){
float d = sqrt(d2);
float strength = (cell - d) / cell;
rx += (dx / d) * strength; ry += (dy / d) * strength;
rcount++;
}
}
j = nextIdx[j];
}
}
}
if (rcount > 0){ rx /= float(rcount); ry /= float(rcount); }
// boundary: a soft spring, zero unless a node has already crossed
// the frame — it stops the ring escaping rather than shrinking the
// domain, which is what turns crowding into buckling
float bx = 0.0, by = 0.0;
if (xi < x0) bx = (x0 - xi) * kBoundary; else if (xi > x1) bx = (x1 - xi) * kBoundary;
if (yi < y0) by = (y0 - yi) * kBoundary; else if (yi > y1) by = (y1 - yi) * kBoundary;
float ddx = ax + rx * kRepel + bx, ddy = ay + ry * kRepel + by;
float mag = sqrt(ddx * ddx + ddy * ddy);
if (mag > maxDisp){ float s = maxDisp / mag; ddx *= s; ddy *= s; }
scratchx[i] = xi + ddx; scratchy[i] = yi + ddy;
}
for (int i = 0; i < n; i++){ nx[i] = scratchx[i]; ny[i] = scratchy[i]; }
// resample: prune crowded nodes, then split stretched ones — the same
// two passes the page runs once a step rather than once an iteration
int MINN = 16;
float px[], py[];
for (int i = 0; i < n; i++){
if (len(px) == 0){ push(px, nx[i]); push(py, ny[i]); continue; }
float lx = px[len(px) - 1], ly = py[len(py) - 1];
float dd = sqrt((nx[i] - lx) * (nx[i] - lx) + (ny[i] - ly) * (ny[i] - ly));
if (dd < pruneDist && (n - i) + len(px) > MINN) continue;
push(px, nx[i]); push(py, ny[i]);
}
if (len(px) > MINN){
float ddx = px[0] - px[len(px) - 1], ddy = py[0] - py[len(py) - 1];
if (sqrt(ddx * ddx + ddy * ddy) < pruneDist){ pop(px); pop(py); }
}
nx = px; ny = py;
n = len(nx);
// Every original node survives a resample pass unconditionally —
// dropping one to stay under budget would break the ring rather than
// cap it — so the cap can only be enforced against how many NEW nodes
// this pass is allowed to insert, tracked as its own counter rather
// than read off the output array's mixed length. Reading the output
// length instead still lets a pass admit a burst of insertions before
// its own counter catches up to the cap, and this cook calls the pass
// 200 times with nothing to freeze it the way the page's own life-
// cycle event does, so that slack compounds without bound across
// steps. An explicit budget makes the cap an exact ceiling instead of
// a per-pass margin: this pass can add at most forma_cap - n nodes,
// whatever n already is.
int budget = max(0, forma_cap - n);
int placed = 0;
float ox[], oy[];
for (int i = 0; i < n; i++){
push(ox, nx[i]); push(oy, ny[i]);
if (placed >= budget) continue;
int j = (i + 1) % n;
float dd = sqrt((nx[j] - nx[i]) * (nx[j] - nx[i]) + (ny[j] - ny[i]) * (ny[j] - ny[i]));
if (dd > splitDist){
push(ox, (nx[i] + nx[j]) / 2.0);
push(oy, (ny[i] + ny[j]) / 2.0);
placed++;
}
}
nx = ox; ny = oy;
}
// the finished ring, as one closed polyline, stepped up from unit-width to
// the 560-unit reference only now — canvas y runs down, so it is negated
// about centre in the same statement
int n = len(nx);
int first = -1, prev = -1;
for (int i = 0; i < n; i++){
int pt = addpoint(0, set(nx[i] * Wref - Wref / 2.0, Wref / 2.0 - ny[i] * Wref, 0.0));
setpointattrib(0, "Cd", pt, forma_ramp(0.98));
setpointattrib(0, "Alpha", pt, 0.88);
if (i == 0) first = pt;
if (prev >= 0) addprim(0, "polyline", prev, pt);
prev = pt;
}
if (n > 1) addprim(0, "polyline", prev, first);