L-Systems: From String to 3D Plant
A single character string, a handful of rewriting rules, and a turtle that never gets tired — that is all it takes to grow a convincing 3D tree. This tutorial builds an L-system engine from the ground up: the grammar, the 3D turtle interpreter, stochastic and parametric variants for natural-looking variety, and the final step of turning the turtle's path into real Three.js geometry.
1. What Is an L-System?
A Lindenmayer system (L-system) is a formal grammar invented in 1968 by biologist Aristid Lindenmayer to model the growth of algae and, later, higher plants. Unlike a Chomsky-style grammar used for parsing, an L-system is generative: it starts from a short seed string and rewrites every symbol in parallel at every iteration. That parallel rewriting is what makes it a natural fit for biological growth — every cell in a real plant divides at the same "tick."
Formally, an L-system is a tuple
G = (V, ω, P):
To generate the next generation of the string, scan the current
string left to right and replace every symbol
simultaneously with its successor from P (symbols with
no matching rule are left unchanged — they act as their own
identity rule). Repeating this n times produces the
string at "generation n," commonly written
Lₙ.
2. String Rewriting by Example
The simplest possible L-system, the algae model
Lindenmayer used in his original 1968 paper, has alphabet
{A, B}, axiom A, and two rules:
| Symbol | Successor |
|---|---|
A | AB |
B | A |
The string length follows the Fibonacci sequence — a nice sanity check that your rewriting engine is correct. The engine itself is a handful of lines:
function rewrite(axiom, rules, generations) {
let current = axiom;
for (let g = 0; g < generations; g++) {
let next = '';
for (const symbol of current) {
// fall back to identity rule if no production exists
next += rules[symbol] ?? symbol;
}
current = next;
}
return current;
}
const algaeRules = { A: 'AB', B: 'A' };
rewrite('A', algaeRules, 5); // → "ABAABABAABAAB"
For plants, the alphabet is redefined so each symbol means a drawing command rather than a biological state — that's the job of the turtle interpreter covered next.
3. Turtle Graphics: Symbols to Strokes
To turn a symbol string into a picture, walk the string left to right and interpret each character as an instruction for a turtle — a cursor with a position and a heading direction. The classic alphabet (introduced by Prusinkiewicz & Lindenmayer in The Algorithmic Beauty of Plants) is:
| Symbol | Turtle action |
|---|---|
F | move forward one step, drawing a line segment |
f | move forward one step without drawing (pen up) |
+ | turn left by angle δ |
- | turn right by angle δ |
[ | push the current turtle state onto a stack |
] | pop the stack — jump back to the saved state |
The [ ] pair is what turns a simple bent line into a
branching tree: pushing state before a side branch
and popping after it lets the turtle "teleport" back to the trunk
and continue as if the branch had never been drawn. A classic
branching axiom looks like this:
4. Turtle Graphics in 3D
A 2D turtle only needs a heading angle. A 3D turtle needs a full orientation — three mutually orthogonal unit vectors: H (heading, forward), L (left) and U (up). Rotating the turtle means rotating this frame around one of the three axes. Prusinkiewicz's extended alphabet adds six more symbols:
| Symbol | Rotation |
|---|---|
& / ^ | pitch down / up, rotate around L |
\ / / | roll left / right, rotate around H |
< / > | yaw left / right, rotate around U |
| | turn around 180° |
In code, represent the turtle's orientation as a quaternion (or a 3×3 rotation matrix) instead of separate H/L/U vectors — it avoids gimbal lock and composes cleanly with Three.js:
class Turtle3D {
constructor() {
this.pos = new THREE.Vector3(0, 0, 0);
this.quat = new THREE.Quaternion(); // identity = pointing +Y
this.stepLen = 1.0;
this.stack = [];
}
forward(draw, segments) {
const dir = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quat);
const next = this.pos.clone().addScaledVector(dir, this.stepLen);
if (draw) segments.push({ from: this.pos.clone(), to: next.clone() });
this.pos = next;
}
rotate(axis, degrees) {
const q = new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(degrees));
this.quat.multiply(q); // rotate in the turtle's local frame
}
push() {
this.stack.push({ pos: this.pos.clone(), quat: this.quat.clone(), len: this.stepLen });
}
pop() {
const s = this.stack.pop();
this.pos = s.pos; this.quat = s.quat; this.stepLen = s.len;
}
}
const UP = new THREE.Vector3(0, 0, 1); // H axis
const LEFT = new THREE.Vector3(1, 0, 0); // L axis
const FWD = new THREE.Vector3(0, 1, 0); // original heading
function interpret3D(str, turtle, angle = 22.5) {
const segments = [];
for (const c of str) {
switch (c) {
case 'F': turtle.forward(true, segments); break;
case 'f': turtle.forward(false, segments); break;
case '&': turtle.rotate(LEFT, angle); break;
case '^': turtle.rotate(LEFT, -angle); break;
case '+': turtle.rotate(UP, angle); break;
case '-': turtle.rotate(UP, -angle); break;
case '\\': turtle.rotate(FWD, angle); break;
case '/': turtle.rotate(FWD, -angle); break;
case '|': turtle.rotate(UP, 180); break;
case '[': turtle.push(); break;
case ']': turtle.pop(); break;
}
}
return segments;
}
rotate() above
post-multiplies the quaternion so each turn happens in the
turtle's current local frame, not world space — exactly
what you want when a bent branch turns again relative to its own
tip, not relative to the trunk's original orientation.
5. Stochastic L-Systems
Deterministic L-systems produce a perfectly self-similar plant — every branch is an exact scaled copy of every other branch at the same level. Real trees are not that regular. A stochastic L-system attaches a probability to several candidate successors for the same symbol, and the rewriter picks one at random on each application:
function pickWeighted(options) {
// options: [{ str, p }, ...] with p summing to ~1
let r = Math.random();
for (const opt of options) {
if ((r -= opt.p) < 0) return opt.str;
}
return options[options.length - 1].str; // floating point fallback
}
const stochasticRules = {
F: [
{ str: 'F[+F]F[-F]F', p: 0.34 },
{ str: 'F[+F][-F]F', p: 0.34 },
{ str: 'F[+F][-F][F]', p: 0.32 },
],
};
Because every branch resolves its own random draw independently, two branches that start identical quickly diverge — the visual signature of a natural canopy instead of a fractal wallpaper pattern. Always seed your random source (e.g. a small mulberry32 PRNG) if you need reproducible trees for a saved scene.
6. Parametric L-Systems
A plain L-system only tells the turtle where to turn — it
has no notion of branch thickness or the fact that twigs near the
top of a tree are shorter and thinner than the trunk. A
parametric L-system attaches numeric parameters to
symbols, e.g. F(length, radius), and the production
rules compute new parameter values instead of just copying symbols:
function expandParametric(axiomTokens, apply, generations) {
let tokens = axiomTokens; // [{sym:'F', args:[1.0, 0.1]}, ...]
for (let g = 0; g < generations; g++) {
const next = [];
for (const tok of tokens) {
const produced = apply(tok); // returns a token array, or null → keep as-is
next.push(...(produced ?? [tok]));
}
tokens = next;
}
return tokens;
}
function applyRule(tok) {
if (tok.sym !== 'F') return null;
const [l, r] = tok.args;
if (l < 0.05) return [tok]; // stop condition: branch too small to keep splitting
return [
{ sym: 'F', args: [l * 0.9, r * 0.7] },
{ sym: '[' }, { sym: '+' },
{ sym: 'F', args: [l * 0.7, r * 0.5] },
{ sym: ']' },
{ sym: 'F', args: [l * 0.9, r * 0.7] },
];
}
l < ε and stop recursing. Without it, a fixed
generation count either under-grows thin branches or produces an
explosion of microscopic twigs on thick ones.
7. From Turtle Path to Mesh
The turtle interpreter above produces a flat list of line segments
— great for a debug LineSegments preview, but a real
tree needs solid tapered branches. The standard approach:
replace every segment with a short cylinder (or a frustum, since
radius changes along the branch), oriented from the segment's
from point to its to point, then merge
all of them into one BufferGeometry for a single draw
call.
function segmentToCylinder(seg) {
const dir = new THREE.Vector3().subVectors(seg.to, seg.from);
const len = dir.length();
const geo = new THREE.CylinderGeometry(
seg.radiusEnd, seg.radiusStart, len, 6, 1
);
geo.translate(0, len / 2, 0); // pivot at base, cylinder default is centered
// align local +Y with the segment direction
const quat = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0), dir.clone().normalize()
);
geo.applyQuaternion(quat);
geo.translate(seg.from.x, seg.from.y, seg.from.z);
return geo;
}
function buildTreeMesh(segments, material) {
const geometries = segments.map(segmentToCylinder);
const merged = THREE.BufferGeometryUtils.mergeGeometries(geometries);
return new THREE.Mesh(merged, material);
}
Low radial segment counts (5–6 sides) are enough for distant
branches; reserve 8–12 sides for the trunk, where the facets are
actually visible up close. If your L-system is parametric, feed
radiusStart/radiusEnd straight from the
F(l, r) arguments recorded during interpretation
instead of a fixed radius.
8. Adding Leaves with Instancing
Leaves are best placed at the tips of the recursion — record the
turtle's position and orientation every time interpretation reaches
a leaf symbol (commonly L), then draw thousands of
leaves in a single draw call with
InstancedMesh instead of thousands of separate
Mesh objects:
const leafGeo = new THREE.PlaneGeometry(0.3, 0.5);
const leafMat = new THREE.MeshStandardMaterial({ color: 0x4aade80, side: THREE.DoubleSide });
const leaves = new THREE.InstancedMesh(leafGeo, leafMat, leafPositions.length);
const dummy = new THREE.Object3D();
leafPositions.forEach((leaf, i) => {
dummy.position.copy(leaf.pos);
dummy.quaternion.copy(leaf.quat);
const s = 0.8 + Math.random() * 0.4; // vary leaf size slightly
dummy.scale.setScalar(s);
dummy.updateMatrix();
leaves.setMatrixAt(i, dummy.matrix);
});
leaves.instanceMatrix.needsUpdate = true;
InstancedMesh for leaves + optionally one more
for blossoms/fruit is enough to render an entire forest at 60 FPS
— the branch merge from step 7 combined with leaf instancing
keeps draw calls constant no matter how many thousands of segments
the L-system produced.
9. Performance and Common Pitfalls
-
Exponential string growth. Rules like
F → FF[+F][-F]roughly double the string length each generation. Generation 8 of a branching rule can already exceed a million symbols — cap generations at 5–7 for interactive use, and prefer parametric stop conditions over raw iteration count. -
Don't rebuild the mesh every frame. Rewriting
and turtle interpretation are one-time (or on-parameter-change)
costs. Cache the merged
BufferGeometryand only regenerate when the L-system's parameters actually change. - Angle jitter beats string-level randomness for cheap variety: keep one deterministic L-system string but perturb each turtle turn by a small random ± epsilon degrees at interpretation time. Much cheaper than maintaining a full stochastic grammar, and still breaks up perfect self-similarity.
-
Bracket balance. A single unmatched
[or]in a hand-written rule silently corrupts the whole stack for the rest of the string. Validate bracket balance after every rewrite pass, especially when rules are stochastic and generated programmatically. - Scale before you cull. Frustum-cull whole trees, not individual branch segments — computing per-segment bounding spheres for a 50,000-segment tree costs more than just drawing it. Batch culling at the tree (or LOD chunk) level instead.
Once individual trees run smoothly, the natural next step is rendering an entire forest — see GPU-Accelerated L-System Trees for merging thousands of generated trees into a single instanced draw call.