Last week I sent you off with a big promise: I said next time we'd stop decorating one fixed square and start generating whole worlds that run right off the edge of the screen. And I meant it. But before you can grow a forest, you have to be able to grow a single tree - a real one, with branches that lean out into actual space, not just a flat silhouette painted on a canvas. So today is the bridge. We're going back to one of my favourite ideas in the whole series, the L-system, and we're giving it a third dimension. Allez, let's grow something in space.
You'll remember L-systems from way back - we grew plants from grammar, a tiny set of rewrite rules and a turtle that walked the resulting string. Those were gorgeous, but they were pancakes. Everything lived on the flat xy-plane. A branch could go left, right, up the page, down the page, and that was it. Today we hand the turtle a full 3D body, teach it to pitch and roll and turn like a little aeroplane, and let it draw trees, coral, and root systems that you can actually orbit around in Three.js. Let me show you what I figured out, because the jump from 2D to 3D turtle is smaller than you'd think, and the results are ridiculously satisfying.
The heart of an L-system never changes, no matter how many dimensions we're in. You start with an axiom (a seed string), and you have rules that say "wherever you see this symbol, replace it with this longer string". Apply the rules a few times and a tiny seed blooms into a huge instruction tape. Here's the rewrite engine, exactly the one we've used since the plant episodes:
// expand an L-system: start from the axiom, apply the rewrite rules N times.
// each pass, every symbol is replaced by its rule (or left alone if it has none).
function expand(axiom, rules, iterations) {
let s = axiom;
for (let i = 0; i < iterations; i++) {
let next = "";
for (const ch of s) next += rules[ch] || ch; // rewrite or pass through
s = next;
}
return s;
}
That's the whole grammar side. Nothing about this cares whether we're on a plane or in space - it just grows strings. All the 3D magic happens in how the turtle reads those strings. That's where we spend today.
Our 2D turtle had a dead-simple state: a position (x, y) and a single heading angle. To turn, you added or subtracted from the angle. To move, you took a step in that direction with a bit of trig (remember Math.cos and Math.sin from the angles episode - they were doing the steering). Nice and easy, but a single angle can only point you around inside a flat circle. There's no "tilt up out of the page", no "roll over sideways".
In 3D you need a proper orientation, and the cleanest way to carry one is three perpendicular direction vectors that travel with the turtle. Think of them as the turtle's own little dashboard:
These three always stay at right angles to each other, like the corner of a room. Together they're called a frame, and turning the turtle just means spinning this whole frame around one of its own axes. Here's the turtle's starting state:
// a 3D turtle: a position plus three perpendicular direction vectors (its frame).
// H = heading (forward), L = left, U = up. they stay mutually perpendicular.
function makeTurtle() {
return {
pos: [0, 0, 0],
H: [0, 1, 0], // start facing straight up (trees grow upward)
L: [-1, 0, 0], // left
U: [0, 0, 1], // up-out-of-the-head
step: 1,
angle: Math.PI / 8, // 22.5 degrees per turn - a classic botanical value
};
}
I set the initial heading to straight up the y-axis because trees grow toward the sky, but for coral or roots you'd flip it. Everything else follows from these three vectors.
To turn, we rotate two of the three vectors around the third. Roll spins L and U around H. Pitch spins H and U around L. Yaw (turning left/right) spins H and L around U. The actual rotation is the classic "rotate a vector around an arbitrary axis" formula (Rodrigues' rotation, if you want the fancy name), and it's worth having in your kit forever:
// rotate vector v around unit axis k by angle a. (Rodrigues' rotation formula.)
// this is THE workhorse for turning a 3D frame - keep it in your toolbox.
function rotate(v, k, a) {
const c = Math.cos(a), s = Math.sin(a);
const dot = v[0]*k[0] + v[1]*k[1] + v[2]*k[2];
// cross product k x v
const cx = k[1]*v[2] - k[2]*v[1];
const cy = k[2]*v[0] - k[0]*v[2];
const cz = k[0]*v[1] - k[1]*v[0];
return [
v[0]*c + cx*s + k[0]*dot*(1 - c),
v[1]*c + cy*s + k[1]*dot*(1 - c),
v[2]*c + cz*s + k[2]*dot*(1 - c),
];
}
Don't let the formula scare you - you never have to derive it, you just feed it a vector, an axis, and an angle, and it hands back the rotated vector. Now the three turn operations become tiny. Each one spins the two vectors that aren't the axis:
// turn the turtle by rotating its frame around one of its own axes.
function yaw(t, a) { t.H = rotate(t.H, t.U, a); t.L = rotate(t.L, t.U, a); } // around Up
function pitch(t, a) { t.H = rotate(t.H, t.L, a); t.U = rotate(t.U, t.L, a); } // around Left
function roll(t, a) { t.L = rotate(t.L, t.H, a); t.U = rotate(t.U, t.H, a); } // around Heading
See where this is going? Yaw is our old 2D turn, just done properly with vectors. Pitch and roll are the two new moves that only make sense once you've got a third dimension. That's genuinly the entire difference between a flat plant and a spatial one - two extra ways to spin.
There's a standard-ish set of symbols for 3D L-systems, and I'll use it so your grammars will match what you see in books and papers. F draws a branch segment forward. The rotation symbols come in pairs, one for each direction:
+ - yaw left / right (turn)& ^ pitch down / up\ / roll left / right| turn around 180 degrees[ ] push / pop - save the turtle's whole state, then restore it (this is what makes branching work)The brackets are the real hero. When the turtle hits [, it stashes a full copy of its state on a stack. It wanders off drawing a branch. When it hits ], it pops that saved state back, teleporting straight to where the branch began, ready to grow a different branch from the same fork. That push/pop is exactly how one trunk sprouts many limbs. Here's the interpreter that walks a string and collects branch segments:
// walk the L-system string and emit 3D line segments {a, b} for each F.
// brackets save/restore the whole turtle so branches share a fork point.
function interpret(str, t) {
const segments = [];
const stack = [];
for (const ch of str) {
if (ch === "F") {
const a = t.pos.slice();
// step forward along the heading vector H
const b = [a[0] + t.H[0]*t.step, a[1] + t.H[1]*t.step, a[2] + t.H[2]*t.step];
segments.push({ a, b });
t.pos = b;
}
else if (ch === "+") yaw(t, t.angle);
else if (ch === "-") yaw(t, -t.angle);
else if (ch === "&") pitch(t, t.angle);
else if (ch === "^") pitch(t, -t.angle);
else if (ch === "\\") roll(t, t.angle);
else if (ch === "/") roll(t, -t.angle);
else if (ch === "|") yaw(t, Math.PI);
else if (ch === "[") {
// deep-copy the frame so the branch can't mutate the saved state
stack.push({ pos: t.pos.slice(), H: t.H.slice(), L: t.L.slice(), U: t.U.slice() });
}
else if (ch === "]") {
const s = stack.pop();
t.pos = s.pos; t.H = s.H; t.L = s.L; t.U = s.U;
}
}
return segments;
}
One thing to watch, and it's bitten me before: when you push, you must copy the vectors (.slice()), not just store references. If you store references, the branch keeps editing the same arrays you saved, and when you pop you get garbage. Copy on the way in, and every fork stays clean.
Now the fun bit. Here's a classic three-dimensional plant grammar. It sends the trunk up, then at each step sprouts branches that pitch away in different directions and roll around the trunk so they don't all land in one plane:
// a 3D bush/tree grammar. F grows a segment; the branches pitch & roll away
// so limbs spread through space instead of staying flat.
const treeAxiom = "F";
const treeRules = {
F: "FF-[-F+F+F]+[+F-F-F]", // 2D-ish spread...
};
// ...but we lift it into 3D by rolling between the two branch groups.
const bushAxiom = "A";
const bushRules = {
A: "F[&FA]/////[&FA]/////[&FA]", // pitch down into a branch, roll 72deg, repeat
F: "S/////F",
S: "F",
};
That bushRules set is the one to play with - it grows a trunk, and at each node it pitches a branch downward (&), then rolls a fifth of a turn (///// is five rolls, roughly 72 degrees if your angle is set right) before making the next branch. Roll-then-branch, roll-then-branch: that's what fans the limbs evenly around the trunk in 3D instead of squashing them onto a plane. Let's expand it and interpret it:
// grow the bush 5 generations and turn it into 3D segments.
const turtle = makeTurtle();
turtle.angle = Math.PI / 7; // ~25 degrees, leafy and open
const dna = expand(bushAxiom, bushRules, 5);
const branches = interpret(dna, turtle);
console.log(branches.length + " branch segments"); // grows FAST - hundreds to thousands
At this point branches is just a list of little 3D line segments floating in math-space. We haven't drawn anything yet. That separation is deliberate and it's a habit worth keeping: the L-system produces pure geometry, and how you render it is a totally seperate decision. Which is exactly what lets us hand it to Three.js.
Time to make it real. We first met Three.js back when we built our first 3D scene, and this is where that pays off. The quickest way to see the tree is as a single LineSegments object - shove every segment's two endpoints into one big buffer and let the GPU draw them all in one go:
// build a Three.js LineSegments object from our branch list.
// two points per segment, flattened into one position buffer.
function buildLines(branches) {
const positions = [];
for (const { a, b } of branches) {
positions.push(a[0], a[1], a[2], b[0], b[1], b[2]);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position",
new THREE.Float32BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({ color: 0x5fbf60 });
return new THREE.LineSegments(geo, mat);
}
scene.add(buildLines(branches));
Spin the camera around and there it is - a real, three-dimensional plant you can orbit, branches leaning out toward you and away, no longer trapped on a flat page. I still get a proper kick out of the first orbit, honestly. Makes sense, right? It's the same string of instructions we'd have drawn flat, but the pitch and roll pushed every branch into space.
Lines are great for seeing the structure, but a tree wants thickness. Instead of line segments we can drop a thin cylinder along each branch. Cylinders take a bit more work because we have to orient them along the segment, but Three.js gives us a shortcut with quaternions:
// place a cylinder along each branch segment so the tree has real volume.
function buildCylinders(branches, radius) {
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: 0x6b4a2b }); // bark brown
const up = new THREE.Vector3(0, 1, 0);
for (const { a, b } of branches) {
const va = new THREE.Vector3(...a);
const vb = new THREE.Vector3(...b);
const dir = new THREE.Vector3().subVectors(vb, va);
const len = dir.length();
const geo = new THREE.CylinderGeometry(radius, radius, len, 6);
const mesh = new THREE.Mesh(geo, mat);
// move to the segment's midpoint, then rotate to align with the branch
mesh.position.copy(va).add(vb).multiplyScalar(0.5);
mesh.quaternion.setFromUnitVectors(up, dir.clone().normalize());
group.add(mesh);
}
return group;
}
Now it looks like actual wood. Add a light (we covered lighting a while back) and the cylinders catch highlights, and suddenly the thing reads as a tree instead of a wireframe. One warning: a cylinder per branch gets heavy fast - a big plant can be thousands of segments, and thousands of separate meshes will crawl. When you go big, this is exactly where instancing (which we covered) or merging all the geometry into one buffer saves your frame rate. For learning, plain meshes are perfect.
Real branches get thinner as they climb away from the trunk. We can fake that beautifully by tracking how deep into the branching each segment is - how many [ brackets are open when it's drawn - and shrinking the radius with depth. Let me tweak the interpreter to record a depth on each segment:
// same interpreter, but tag each segment with its branch depth (bracket count).
function interpretWithDepth(str, t) {
const segments = [];
const stack = [];
let depth = 0;
for (const ch of str) {
if (ch === "F") {
const a = t.pos.slice();
const b = [a[0]+t.H[0]*t.step, a[1]+t.H[1]*t.step, a[2]+t.H[2]*t.step];
segments.push({ a, b, depth });
t.pos = b;
} else if (ch === "[") {
depth++;
stack.push({ pos: t.pos.slice(), H: t.H.slice(), L: t.L.slice(), U: t.U.slice() });
} else if (ch === "]") {
depth--;
const s = stack.pop();
t.pos = s.pos; t.H = s.H; t.L = s.L; t.U = s.U;
}
else if (ch === "+") yaw(t, t.angle);
else if (ch === "-") yaw(t, -t.angle);
else if (ch === "&") pitch(t, t.angle);
else if (ch === "^") pitch(t, -t.angle);
else if (ch === "\\") roll(t, t.angle);
else if (ch === "/") roll(t, -t.angle);
}
return segments;
}
// then pick each cylinder's radius from its depth:
function radiusForDepth(depth) {
return Math.max(0.05, 0.5 * Math.pow(0.7, depth)); // shrink 30% per level
}
Feed radiusForDepth(seg.depth) into buildCylinders instead of a fixed radius and the tree instantly looks a hundred times more believable: a fat solid trunk, medium boughs, and delicate little twigs at the tips. That single "shrink with depth" trick is the difference between a stick figure and something that looks grown.
A tree with bare twigs is a winter tree. To dress it, we find the segments that have no children - the endpoints of the branching - and pop a little leaf sprite or sphere there. The quick heuristic: any segment whose endpoint isn't the start of some other segment is a tip.
// scatter a small sphere (leaf/blossom) at every branch tip.
function addLeaves(branches, group) {
const starts = new Set(branches.map(s => s.a.join(",")));
const leafGeo = new THREE.SphereGeometry(0.12, 6, 6);
const leafMat = new THREE.MeshStandardMaterial({ color: 0x8fd14f });
for (const { b } of branches) {
if (!starts.has(b.join(","))) { // nothing grows from b -> it's a tip
const leaf = new THREE.Mesh(leafGeo, leafMat);
leaf.position.set(b[0], b[1], b[2]);
group.add(leaf);
}
}
}
Tiny green dots at every twig-end, and the bush blooms. Tint them pink and you've got cherry blossom; make them tiny and dense and you've got a conifer. Same skeleton, different wardrobe.
Here's the thing that turns this from "a tree" into "a forest": randomness. A stochastic L-system gives a symbol several possible rules and picks one at random each time it expands. Two runs, two different plants, both plausibly the same species. Let me build a stochastic expander:
// stochastic expand: a symbol may map to several options, each with a weight.
// rules = { F: [["FF", 0.5], ["F[+F]F", 0.3], ["F[-F]F", 0.2]] }
function expandStochastic(axiom, rules, iterations) {
let s = axiom;
for (let i = 0; i < iterations; i++) {
let next = "";
for (const ch of s) {
const options = rules[ch];
if (!options) { next += ch; continue; }
let r = Math.random(), acc = 0, chosen = options[0][0];
for (const [str, w] of options) { acc += w; if (r <= acc) { chosen = str; break; } }
next += chosen;
}
s = next;
}
return s;
}
Now every seed grows its own plant. Scatter a dozen of these across a ground plane, each from a fresh random run, and you have an instant grove where no two trees are identical - which is precisely the kind of variety you need once we start filling whole landscapes. That's not an accident; that's the whole reason we're doing this today.
The lovely thing about a 3D turtle is that "tree" is only one setting. Crank the pitch angle way up and shorten the segments and the same machinery grows coral - those dense, cauliflower-ish reef structures. Point the initial heading downward and reduce the branching and you get a root system spreading under the soil. Widen the roll steps and tighten the pitch and you drift toward seaweed or antlers. Here's a coral-ish tweak to try:
// coral vibes: steep pitch, short thick segments, lots of tight forks.
const coralAxiom = "A";
const coralRules = {
A: "F[&&A][^A]//[&A]/[&&A]", // many branches, aggressive downward pitch
F: "FF",
};
const coral = makeTurtle();
coral.angle = Math.PI / 4; // 45 degrees - fat, chunky, reef-like
coral.step = 0.6;
const coralGeo = interpretWithDepth(expand(coralAxiom, coralRules, 4), coral);
Same engine, same turtle, same renderer. Only the grammar and a couple of numbers changed, and you've swung from an oak to a lump of brain coral. That is the superpower of this whole approach: the rules are the DNA, and tiny edits to the DNA give wildly different organisms. Poke at the angles and generation count for ten minutes and you'll grow things you didn't plan - that surprise is the best part of generative work.
Look at what we pulled off today. We took the flat L-system plants from earlier in the series and gave the turtle a full 3D body - three perpendicular vectors it can pitch, roll, and yaw - then walked a grammar string to grow real branching structures in space. We rendered them in Three.js as lines, then as tapered cylinders with leaves at the tips, and we made them stochastic so every seed grows a unique plant. Swap the grammar and the same machine grows coral or roots instead. From a handful of rewrite rules to a whole orchard, and every branch of it came from the same little push/pop turtle.
So here's your homework, and it's a proper playground. Get bushRules growing and rendering as cylinders, then start turning knobs: change turtle.angle, bump the iteration count from 4 to 6 (careful, it explodes fast), swap the leaf colour, point the heading down for roots. Then write your own grammar from scratch - just start with "F" and one rule and keep adding branches until it feels alive. And if you're feeling bold, wire up expandStochastic and grow ten trees side by side so you can see the family resemblance with no two the same.
And carry this thread with you, because it's the whole reason I dragged us back to L-systems before tackling worlds. We just built a machine where a tiny written grammar produces endless structured variety - and that idea, a compact set of rules generating far more than you typed, is about to go way beyond plants. What if a grammar could lay out a building? A dungeon? A sentence? That leap - from rules-grow-shapes to rules-grow-anything - is exactly where we head next, and today's turtle was the warm-up. Then, with a pocketful of grammars, we finally start growing those endless landscapes I keep promising you. 't Was plezant to grow a tree in space with you - now go plant a weird one and orbit around it :-).
& ^) and roll (\ /) join the old yaw (+ -). Roll-then-branch is what fans limbs evenly around the trunk instead of squashing them onto a plane[ pushes a full copy of the turtle's state, ] pops it back to the fork. Copy the vectors on push (.slice()) or branches corrupt each otherSo that's 3D L-systems, from a flat turtle with one angle all the way to a stochastic orchard you can orbit around in the browser - and every branch of it grew from a few rewrite rules and a turtle that learned to tilt. The big takeaway, one more time: give the turtle a proper 3D frame and a grammar becomes a seed for real spatial structure. Go grow a tree, taper its branches, blossom its tips, and orbit the thing. And hold onto the idea that a tiny grammar can generate endless structured variety, because next time we take that exact idea and point it at something much bigger than plants. Merci voor het lezen, en tot de volgende keer :-).
Sallukes! Thanks for reading.
X