Tutorial · Procedural Generation · Three.js · Algorithms
📅 July 2026 ⏱ ≈ 22 min 🎯 Intermediate

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):

V = alphabet, the set of symbols that can appear in a string ω = axiom, the initial string (the "seed") P = a set of production rules, each mapping one symbol to a replacement string: symbol → successor

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:

SymbolSuccessor
AAB
BA
L0: A L1: AB L2: ABA L3: ABAAB L4: ABAABABA L5: ABAABABAABAAB

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:

SymbolTurtle action
Fmove forward one step, drawing a line segment
fmove 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:

axiom: F rule: F → F[+F]F[-F]F δ = 25.7° Generation 2 already shows the recursive branch-of-branches structure that makes L-system trees look organic instead of looking like a regularly forking diagram.

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:

SymbolRotation
& / ^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;
}
Rotation order matters. 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:

F → F[+F]F[-F]F p = 0.34 F → F[+F][-F]F p = 0.34 F → F[+F][-F][F] p = 0.32 Σp = 1.0 (probabilities for one symbol must sum to 1)
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:

axiom: F(1.0, 0.1) rule: F(l, r) → F(l·0.9, r·0.7) [ + F(l·0.7, r·0.5) ] F(l·0.9, r·0.7) Each generation shrinks length by 10% and radius by 30% along the main axis, and spawns a side branch that is shorter (70%) and thinner (half the radius) — this single rule is enough to produce a believable tapering trunk with tapering side limbs.
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] },
  ];
}
Explicit stop condition. Parametric rules give you something regular L-systems can't: a natural place to check 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;
One tree, three draw calls. Merged branch geometry + one 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

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.