Chaos Game, Sierpinski Tetrahedron, and Three.js
A random walk that draws a perfect 3D fractal — and how I rendered it.
Chaos Game, Sierpinski Tetrahedron, and Three.js
A random walk that draws a perfect 3D fractal — and how I rendered it.
Here’s a three-step rule:
- Place three corners — vertices of a triangle.
- Pick any starting point.
- Repeat: pick a random corner, jump halfway toward it, plot the new point. Loop.
You’d expect noise. Random input every iteration.
What you actually get is a Sierpinski triangle — a self-similar fractal emerging perfectly from random jumps.
Add a fourth corner, do it in 3D, and you get the scene below.
[embed]
How the Chaos Game ends. A fractal — perfectly balanced.
Why the starting point doesn’t matter
The rule has three elements. Only one is random.
- (Random) Pick a corner. Uniformly across the three, no memory between picks.
- (Deterministic — fixed targets) Three corners, set at the start. Random over a finite set is bounded; random over all of 2D space would be noise.
- (Deterministic — halving) Each iteration cuts the distance from your current point to the picked corner by exactly half.
The halving compounds. After 10 iterations the influence of any specific past pick is ~1/2¹⁰ ≈ 1/1,000. After 20, ≈ 1 in a million. After 30, lost in floating-point noise. Only your most recent ~20 picks contribute meaningfully.
That’s the memory horizon. The system forgets its past at exponential speed — which is why the simulation works from any starting point.
That’s why your starting point doesn’t matter — even from outside the triangle. The shape that emerges is next.
Why this isn’t actually noise
Step back from the chaos game for a minute. Forget the random walk. Look at one rule in isolation: “halve toward A.”

Starting from P: three possible landings (P’A, P’B, P’C), one void.
Treat it as a black-box function. Feed it any point P → out comes a new point P’ = (P + A) / 2 — the midpoint between P and A.
What does this function do to the triangle as a whole? A triangle is determined by its three corners — so let’s probe the function with each corner:
- Feed in A → output is (A + A) / 2 = A. A is halfway between A and itself.
- Feed in B → output is (B + A) / 2 — the midpoint of side AB. Call it M_AB.
- Feed in C → output is (C + A) / 2 — the midpoint of side AC. Call it M_AC.
So “halve toward A” maps the original triangle (corners A, B, C) into a smaller triangle (corners A, M_AB, M_AC) tucked into the A corner.
Same logic for “halve toward B” and “halve toward C” — each maps the triangle into its respective corner sub-triangle.Three corner-mappings, three sub-triangles, one per corner.
Concretely: pick any starting point P inside the triangle. There are only three possible points to plot next:
- P’A = (P+A)/2.
- P’B = (P+B)/2.
- P’C = (P+C)/2.
each landing in one of the three sub-triangles, as shown in the diagram above.
Now the geometric punchline.
The three sub-triangles don’t tile the original — the hatched middle is unreachable by any mapping.
Apply this recursively inside each sub-triangle and what’s left is the Sierpinski triangle.
The chaos game doesn’t build it — it samples from what’s left after this infinite geometric subtraction.
Now the engineering.
Drawing two million points without dropping frames
1. Allocate once, update incrementally.
A 3-million-point buffer (3 floats × 4 bytes = 36MB) is allocated once at startup. Each frame, the chaos step writes a few thousand new positions into the next free slots — never reallocates, never memcpy’s the whole array.
Three.js exposes two knobs that matter:
positionAttr.addUpdateRange(start * 3, count * 3);
positionAttr.needsUpdate = true;
geometry.setDrawRange(0, count);
addUpdateRange uploads only the new bytes to the GPU next frame, not the whole 36MB. setDrawRange tells the GPU to draw only the points that exist yet. Together: 60fps while the cloud grows in real time.
2. One simulation, two scenes.
The hero above runs the same chaos walk in light and dark. The naive setup is two simulations stepped in parallel — they drift the moment a frame skips on either side.
What actually runs: one Float32Array, one cursor, one chaos step per frame. Two BufferGeometry instances point at the same underlying buffer. Two cameras both copy a single master transform every frame.
for (const side of sides) {
side.camera.position.copy(masterPos);
side.camera.up.copy(masterUp);
side.camera.lookAt(0, 0, 0);
side.renderer.render(side.scene, side.camera);
}
Two views, one source of truth. Mathematically identical, not just visually similar.
3. Theme is a uniform.
Each side renders with its own ShaderMaterial whose only difference is one line:
uColor: { value: new Color(0x8e8d8a) } // light
uColor: { value: new Color(0xf1efed) } // dark
The shader writes that color into every fragment. Geometry, camera, simulation — all shared. Color is the leaf that gets swapped.
Same trick is why the standalone version flips themes instantly: one uniform assignment, no rebuild.

See it in practical use at amr.codes
메타데이터
- post_id
- cbb943a09d44
- slug
- chaos-game-sierpinski-tetrahedron-and-three-js-cbb943a09d44
- url
- https://medium.com/@hassaballah.amr/chaos-game-sierpinski-tetrahedron-and-three-js-cbb943a09d44
- canonical_url
- https://medium.com/@hassaballah.amr/chaos-game-sierpinski-tetrahedron-and-three-js-cbb943a09d44
- author_url
- https://medium.com/@hassaballah.amr
- status
- ok
- fetched_at
- 2026-06-09 15:37:30