L-Systems and Plant Growth — Lindenmayer grammars, turtle graphics and 3D branches
In 1968, botanist Aristid Lindenmayer devised a string-rewriting formalism to model cell division in algae. The same simple idea — replace symbols in parallel — generates convincing trees, ferns, seaweeds, coral and coastlines.
1. Grammar — axiom, alphabet and production rules
An L-system is a formal grammar G = (V, ω, P) where:
- V — alphabet: the set of characters (symbols). Symbols that have a production rule are called variables; the rest are constants.
- ω — axiom (start string): the initial string.
- P — productions: a map from each variable to its replacement string.
At each generation, all symbols in the string are rewritten simultaneously — this parallel replacement distinguishes L-systems from standard Chomsky grammars and is what produces self-similar structure naturally.
Variables: A B
Axiom: A
Rules: A → AB
B → A
Generation 0: A
Generation 1: AB
Generation 2: ABA
Generation 3: ABAAB
Generation 4: ABAABABA
Generation 5: ABAABABAABAAB
Length grows as Fibonacci numbers: 1,2,3,5,8,13,…
2. Turtle graphics interpretation
An L-system string gains visual meaning by interpreting each character as a turtle command. The turtle has a position (x, y), a heading θ, and an optional stack for branching.
| Symbol | Command |
|---|---|
| F | Move forward by step length d, drawing a line segment |
| f | Move forward by d without drawing (jump) |
| + | Turn left by angle δ |
| − | Turn right by angle δ |
| [ | Push current state (position + angle) onto stack |
| ] | Pop state from stack (return to saved position) |
| | | Turn 180° |
| & | Pitch down by δ (3D) |
| ^ | Pitch up by δ (3D) |
| \ | Roll left by δ (3D) |
| / | Roll right by δ (3D) |
The [ and ] stack operations are what create
branches: push state at the base of a branch, recurse
into the branch, pop back to the base — the fundamental idiom of all
tree and shrub L-systems.
3. Classic L-systems
Koch Curve (δ = 60°)
Axiom: F
F → F+F−−F+F
Gen 4: FFFF… (1024 segments)
D = log(4)/log(3) ≈ 1.262
Sierpiński Triangle (δ = 60°)
Axiom: F−G−G
F → F−G+F+G−F
G → GG
3 recursive levels fills the triangle
D = log(3)/log(2) ≈ 1.585
Fractal Plant (δ = 25°)
Axiom: X
X → F+[[X]−X]−F[−FX]+X
F → FF
Gen 6 → realistic leafy branch
Classic branching shrub
Dragon Curve (δ = 90°)
Axiom: FX
X → X+YF+
Y → −FX−Y
Gen 16 → folded paper dragon
D = 2 (fills a plane region)
Bush (δ = 22.5°)
Axiom: F
F → FF+[+F−F−F]−[−F+F+F]
Gen 5 → dense bushy tree
Angle controls spread
Hilbert Curve (δ = 90°)
Axiom: A
A → +BF−AFA−FB+
B → −AF+BFB+FA−
Gen 6 → 64×64 space-filling path
D = 2, used for memory locality
4. JavaScript engine — expand and draw
The engine has two phases: string expansion (generate the L-system string up to n iterations) and turtle interpretation (walk the string and draw line segments on Canvas 2D).
// ── 1. L-system string expansion ──────────────────────────────────
function expand(axiom, rules, generations) {
let str = axiom;
for (let g = 0; g < generations; g++) {
// Build output char by char — avoids repeated string concatenation
const parts = [];
for (const ch of str) {
parts.push(rules[ch] ?? ch); // replace if rule exists, else keep
}
str = parts.join('');
}
return str;
}
// ── 2. Turtle interpretation on Canvas 2D ─────────────────────────
function draw(ctx, str, x0, y0, angle0, step, delta) {
const stack = [];
let x = x0, y = y0, angle = angle0;
const RAD = Math.PI / 180;
ctx.beginPath();
ctx.moveTo(x, y);
for (const ch of str) {
switch (ch) {
case 'F':
x += step * Math.cos(angle * RAD);
y -= step * Math.sin(angle * RAD);
ctx.lineTo(x, y);
break;
case 'f':
x += step * Math.cos(angle * RAD);
y -= step * Math.sin(angle * RAD);
ctx.moveTo(x, y);
break;
case '+': angle += delta; break;
case '-': angle -= delta; break;
case '[': stack.push({ x, y, angle }); break;
case ']':
({ x, y, angle } = stack.pop());
ctx.moveTo(x, y);
break;
}
}
ctx.stroke();
}
Usage example — fractal plant at 6 generations
const rules = {
X: 'F+[[X]-X]-F[-FX]+X',
F: 'FF'
};
const str = expand('X', rules, 6);
const ctx = canvas.getContext('2d');
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 0.7;
draw(ctx, str,
canvas.width / 2, // start x
canvas.height - 20, // start y (bottom centre)
90, // initial angle (up)
5, // step length (shrinks with gen)
25 // delta angle
);
5. Stochastic and context-sensitive rules
Stochastic L-systems
Replace a deterministic rule A → xyz with a set of alternative productions each with a probability that sums to 1. Running the same grammar twice produces different plants — essential for avoiding the artificial uniformity of deterministic systems.
// Stochastic L-system — F has two productions chosen by random
const stochastic = {
F: [
{ weight: 0.33, result: 'F[+F]F[-F]F' },
{ weight: 0.33, result: 'F[+F]F' },
{ weight: 0.34, result: 'F[-F]F' },
]
};
function expandStochastic(axiom, rules, gens) {
let str = axiom;
for (let g = 0; g < gens; g++) {
const parts = [];
for (const ch of str) {
if (rules[ch]) {
const r = Math.random();
let cumW = 0;
for (const prod of rules[ch]) {
cumW += prod.weight;
if (r < cumW) { parts.push(prod.result); break; }
}
} else {
parts.push(ch);
}
}
str = parts.join('');
}
return str;
}
Context-sensitive rules
A rule can condition replacement on the left or right neighbour:
A<B>C → D means "replace B by D when preceded by A
and followed by C". This models inter-cell chemical signalling — the
original biological motivation for Lindenmayer's work.
6. 3D L-systems in Three.js
For 3D branching the turtle carries a
3×3 rotation matrix (heading H, left L, up U vectors)
rather than a single heading angle. The 3D symbols &,
^, \, / apply rotation matrices
around each axis.
// 3D turtle — heading (H), left (L), up (U) frame
import * as THREE from 'three';
class Turtle3D {
constructor() {
this.pos = new THREE.Vector3();
this.H = new THREE.Vector3(0, 1, 0); // heading: up
this.L = new THREE.Vector3(-1, 0, 0); // left
this.U = new THREE.Vector3(0, 0, 1); // up-vector
this.stack = [];
this.lines = []; // Array of [from, to] Vector3 pairs
}
rotate(axis, angleRad) {
const q = new THREE.Quaternion().setFromAxisAngle(axis, angleRad);
this.H.applyQuaternion(q);
this.L.applyQuaternion(q);
this.U.applyQuaternion(q);
}
forward(step) {
const from = this.pos.clone();
this.pos.addScaledVector(this.H, step);
this.lines.push([from, this.pos.clone()]);
}
interpret(str, step, delta) {
const r = delta * Math.PI / 180;
for (const ch of str) {
switch (ch) {
case 'F': this.forward(step); break;
case '+': this.rotate(this.U, r); break;
case '-': this.rotate(this.U, -r); break;
case '&': this.rotate(this.L, r); break;
case '^': this.rotate(this.L, -r); break;
case '/': this.rotate(this.H, r); break;
case '\\': this.rotate(this.H, -r); break;
case '[': this.stack.push({
pos: this.pos.clone(),
H: this.H.clone(), L: this.L.clone(), U: this.U.clone()
}); break;
case ']': {
const s = this.stack.pop();
this.pos.copy(s.pos); this.H.copy(s.H);
this.L.copy(s.L); this.U.copy(s.U);
} break;
}
}
}
}
Build a Three.js LineSegments object from the collected
line pairs:
function buildMesh(lines, scene) {
const positions = [];
for (const [a, b] of lines) {
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({ color: 0x22c55e, linewidth: 1 });
scene.add(new THREE.LineSegments(geo, mat));
}
🐦 Boids Simulation
Watch emergent flocking behaviour from just three simple rules — separation, alignment and cohesion.
7. Open L-systems and parametric extensions
The version of L-systems described so far are closed: the grammar has no interaction with the environment. Prusinkiewicz and Lindenmayer's 1990 book The Algorithmic Beauty of Plants extends the formalism in several ways:
-
Parametric L-systems: symbols carry numeric
parameters, e.g.
F(l, w)— a forward step of length l, line width w. Rules may involve arithmetic on parameters, so branches taper naturally:F(l, w) → F(l*0.8, w*0.7)[+F(l*0.6, w*0.5)]F(l*0.8, w*0.7). - Open L-systems: the plant model exchanges information with a simulated environment. A light-field simulation can starve shaded branches, causing them to grow shorter or stop growing — producing realistic shade avoidance.
- Differential L-systems: instead of discrete generation steps, the rewriting is continuous, modelling continuous growth over time. Used in papers on morphogenesis and vascular pattern formation.
- Map L-systems: encode 2D cellular grids where adjacency governs which rules fire — the original motivation of context-sensitive rules.