Making of

Building Selva: a rainforest with no models and no build step

Three hundred trunks, close to five thousand stones and seventeen hundred fireflies, all generated in the browser from a single seed. Here is how it is put together, and what went wrong.

Three.js · WebGPU with a WebGL 2 fallback · no framework

01 — The constraint

Chapter one of Selva: a night rainforest under a moon, with a large headline set over it and a capuchin monkey cut out at the right edge of the frame.
Chapter 01 as a visitor gets it. Everything behind the type is generated in the browser; the monkey and the ferns at the edges are photographs composited over it in the DOM. Every other frame in this article has the page’s own text hidden so the renderer is visible.

Selva is one HTML file, one vendored copy of Three.js, a stylesheet and a module of scene code. No framework, no bundler, no build step, and no network call to anything that is not in its own folder. Open the file, read the scene, change a number, refresh. That is the whole development loop.

Nothing in the world is a downloaded model. There is no glTF, no scan, no asset store. Every trunk, limb, leaf, vine, stone, raindrop and firefly is constructed at runtime from a seeded random number generator, which is the one decision the rest of the piece hangs off:

// Deterministic randomness: the forest is the same forest every visit.
const R = rng(20260815);
const rand = (a = 0, b = 1) => a + (b - a) * R();

A seeded generator is what makes the thing art-directable. If the forest reshuffled on every load you could never compose a shot — you would frame the fallen tree against the moon, refresh, and find a different tree somewhere else. Fixing the seed turns a procedural forest into a set you can dress. Every camera position in the piece was chosen against a world that will be identical for the next visitor.

02 — Leaves are photographs

Nothing convincingly leaf-shaped comes out of a shader cheaply, so the leaves are real: photographs of real foliage, cut out, and set on planes. They ship as three 2×2 atlases — four leaf shapes per texture — and each instance picks its own quadrant in the shader from a per-instance attribute. Four times the variety of one leaf per material, at three draw calls instead of eight.

A two-by-two grid of four photographed leaves on a transparent background, the texture atlas used for the canopy.
The canopy atlas, shipped exactly as it is here. Four photographed leaves, cut out, in one texture — and every leaf in the canopy is one of these four on a plane, turned and scaled and tinted.
The understory chapter with photographic cutouts of ferns and a capuchin monkey at the edges of the frame.

Cutouts on

The same view of the understory with the cutouts removed, showing only the rendered forest.

The 3D world alone

The same frame with the DOM cutout layer shown and hidden. Photographs at the edges do the work of expensive close-up geometry, and because they are DOM rather than scene they cost the renderer nothing at all — but everything past the first few metres, including the ruin burning in the distance, is built at runtime.

That split is most of the piece’s performance budget. The things nearest the lens, where a viewer looks hardest, are photographs; everything at a distance, where the eye is forgiving, is geometry.

Two things about alpha-tested foliage are worth stating plainly, because both produce a bug that looks like something else entirely.

The shadow pass has to read the same colour node as the material. If it does not, the depth pass sees an opaque quad, and every leaf casts the shadow of a rectangle. You get a forest floor covered in playing cards and spend an hour looking at your light rig, which is fine, because the light rig is not the problem.

Leaves have to get out of the way of the lens. The camera walks through the canopy rather than around it, and a camera-facing plane that intersects the near plane smears across the entire frame. Anything within arm’s reach of the lens is discarded in the fragment shader, so the walk passes through foliage instead of wearing it.

Edges resolve with alpha-to-coverage against the multisampled target rather than a hard alpha test, which is the difference between a leaf edge and a staircase.

03 — Turning a heap of boxes into masonry

The ruined city at the centre of the walk — a seven-tier temple, two palace ranges, a gateway, a sister pyramid the figs have nearly finished eating — is 5,184 individual stones, 160 of them dressed for the carving. Every one of them is a box.

Close view of a stone wall with a large carved face, water pouring from its mouth into a basin, and a stepped temple stair to the right.
The mask fountain, close enough to read. The courses run on across the wall rather than restarting at each block; every stone is a slightly different grey with a darker arris; and the face is cut from the same coursed masonry as the wall it sits in, so it weathers with it.

Nothing in that picture is modelled by hand. It is boxes, a colour rule and a projection.

What makes a heap of boxes read as masonry is not the box. It is two things, and they are both nearly free.

Each block carries its own vertex colour. Written while the block is still at the origin, so “down” and “the edges” still mean something: darker along every arris, darker under every ledge where water runs down it, and no two blocks the same grey.

// distance to the nearest arris, summed over the two in-face axes:
// 1 at the middle of a face, 0.5 at an edge, 0 in a corner
const e = Math.min(lx, 1 - lx) * 3 + Math.min(ly, 1 - ly) * 3 + Math.min(lz, 1 - lz) * 3;
const ao = seg < 2 ? 0.86 : lerp(0.52, 1, clamp(e / 2, 0, 1));
const b = v * ao * lerp(0.72, 1, smooth(0, 0.62, ly));   // and a stain from every ledge

The texture is projected from world space, not per stone. The uv is written after the block is placed, so the courses of the stone photograph carry on across a whole building instead of restarting at every block. Give each stone its own 0–1 uv and the wall turns into a mosaic of identical tiles, which is the single most common reason procedural masonry looks procedural.

After that it is bricklaying. Joints break between courses, which the code comment describes better than this paragraph can: the way a mason lays them, and the way a programmer forgets to. Openings are corbelled — courses oversailing from both jambs until they meet — because that is the only arch these builders had, and a true arch in the middle of it would be the kind of mistake only some readers notice, which is the worst kind.

A height map drives a parallax offset shared by the colour, normal and roughness reads, so the joints actually sink and the faces stand proud as the camera moves, rather than being a picture of a wall printed flat on a wall.

04 — Light at night, cheaply

The whole piece happens at night, which is a gift and a trap. A gift because you can hide almost everything in shadow. A trap because the few things you do light have to be right, and wet surfaces at night are mostly specular.

The cheapest trick in the file: a surface loads its colour map once and declares it twice — once as sRGB colour, and once as linear data to drive roughness. Dark, wet crevices come back shiny under the moon for no extra download at all.

function surface(name, repeat, mirror = true) {
  return {
    map:          photo(`${name}.webp`, { repeat, mirror }),
    normalMap:    photo(`${name}-n.webp`, { repeat, mirror, linear: true }),
    roughnessMap: photo(`${name}.webp`, { repeat, mirror, linear: true }),
  };
}

Left alone that overdoes it and every dark crevice becomes a mirror, so the roughness node is clamped into a believable range rather than used raw. And the linear flag is not decoration: a normal map decoded as sRGB is simply wrong, and it is wrong in a way that reads as “the lighting feels a bit off” rather than as an error.

The most useful optimisation in the scene is about shadows. The ruin has fires burning on it, and a point light’s shadow is a cube map — six renders of the scene — which would be far and away the most expensive thing in the frame if it ran every frame. It does not have to. The fire flickers, but nothing that casts a shadow moves. So each map is rendered once and kept, and only the four fires that light something worth a shadow get one at all, because the sampling still costs even when the render does not. The moon’s map stays live, because the moon follows the walk.

The ruined temple at night with ambient occlusion, showing dark contact shadows where stones meet.

With GTAO

The same temple without ambient occlusion, where the stones look lit but not seated.

Without

Ambient occlusion is what seats a thing in its own shadow. Without it the masonry is still lit correctly and still reads as stone — it just floats, because nothing gets darker where two surfaces meet.
The canopy chapter with visible shafts of moonlight falling between the trees.

With god rays

The same canopy view with the shafts of light removed.

Without

God rays do something a light cannot: they make the air visible. It is the difference between a forest lit by a moon and a forest with a moon somewhere in it.

Anisotropy is raised to the hardware maximum on every texture once the renderer exists. At grazing angles — the floor running to the horizon, bark going up a trunk — that is the difference between detail and mush.

05 — The three bugs that cost the most

None of these were hard to fix. All three were hard to find, which is the more interesting property.

The temple was sampling a single texel

Vines, roots and masonry are merged into one geometry each to collapse the draw calls. The first version of the merge copied position and normal — the two attributes you think of — and nothing else.

Every merged surface silently ended up pinned to uv (0,0). The entire temple was rendering from one texel of its stone map: correctly lit, correctly shadowed, and completely flat, in a way that looked like a material problem rather than a geometry one.

The fix is to carry every attribute the first geometry has rather than an assumed list.

function mergeGeos(list) {
  const proto = list[0].attributes, names = Object.keys(proto);
  // …every name, not position and normal
  for (const n of names) buf[n] = new Float32Array(vc * proto[n].itemSize);

A pier in the fountain’s basin

The gateway on the causeway originally stood at the edge of the plaza. It looked correct in plan and wrong in every shot: the camera passed a pier rather than going through an opening, and the pier landed square in the fountain’s basin behind it.

This is the failure mode of building a world in code rather than in a viewport. A thing can be in a perfectly sensible place and still be composed badly, and you only find out from the one position the camera actually occupies. The gateway now sits exactly on the walk and is turned to face it. Its lintel is half down and the blocks are still lying there, which is both better history and a better silhouette.

Safari’s permission to make noise expires

Sound is off until the visitor asks for it — every browser requires that, and it is the right manners anyway. The audio layer is a separate module, imported on the click so nothing is fetched for visitors who never turn it on.

That import is the bug. On iOS, the user gesture that grants permission to start audio expires a few seconds after the tap — so a dynamic import or a fetch placed before ctx.resume() works perfectly on every desktop browser and fails on an iPhone. Everything up to the resume now happens synchronously inside the gesture.

There is a second one hiding behind it. An AudioContext that is opened and then orphaned still holds an output stream, and a browser only allows a handful at once. Leak one per click and the sound switch dies for the rest of the session — after about the fifth toggle, silently, with no error anywhere.

06 — Two problems that weren’t code

Both of the worst moments in the first cut were composition failures, and neither was fixable with a shader.

The canopy had nothing in it. Chapter 00 puts the camera forty metres up, and what was up there was bare poles with a crown balanced on top of each one. The reference solves it: a real rainforest canopy is mostly lateral — heavy limbs going out level, carrying a second forest of ferns and bromeliads rooted in nothing but bark and rain. Adding that layer to the trees near the hero shot, and only those, fixed the chapter.

The understory had no landmark. Thirty metres of identical forest with nothing in it to look at. The fix was a fallen giant — a tree big enough that its coming down is the event that makes a clearing, with the plate of roots it tore out standing taller than a person. It is the one thing in that chapter with a story attached, so it goes exactly where the camera is already looking.

Both fixes came from looking at photographs of the real thing rather than at the render. Procedural work drifts toward the plausible-but-empty, and the correction is almost always outside the codebase.

07 — Making it survive a phone

The renderer takes WebGPU where the browser has it and WebGL 2 where it does not, and ?forcegl takes the WebGL road on a machine that has WebGPU — so the fallback that other visitors get stays testable on the machine doing the building. A fallback nobody can reproduce is a fallback nobody maintains.

From there the scene picks one of four tiers out of what the adapter says about itself:

let TIER = new URLSearchParams(location.search).get("tier")
  || (SMALL() || COARSE ? "low"
      : BACKEND === "webgl" ? "mid"
      : /nvidia|amd|apple/.test(GPU_INFO) ? "ultra"
      : "high");

Which is a guess, and it is wrong often enough to need a net. An APU reports “amd”. A discrete GPU on battery is not a discrete GPU. A 4K screen quietly quadruples everything. So a guard in the render loop watches the frame time once the world has settled, and when an ultra frame averages long it concludes the machine lied about itself, rebuilds the post-processing graph at high, and stops the fires casting.

The ruined temple rendered at the highest quality tier, with soft occlusion, depth of field and heavy atmosphere.

tier=ultra

The same temple at the lowest tier, flatter and sharper, with less atmosphere and simpler shadows.

tier=low — a phone’s settings

Same seed, same chapter, same camera — only the tier differs. Low loses the occlusion, the depth of field and most of the volumetrics, and it is deliberately not a smaller version of the same picture: it is a flatter, brighter one, because it is going to be looked at on a small screen in a lit room.

Phones also get a different picture on purpose, not just a cheaper one. Geometry density drops to 55%, the buildings are cut from bigger stones — same silhouette, a third of the geometry — and exposure is raised, because a phone is a small bright screen held in a lit room and it carries the reading scrim across the whole frame rather than down one side. The same tone curve arrives somewhere different.

08 — What we’d do differently

The textures load eagerly, all of them, at module scope. That was a deliberate call — materials pick each one up as it arrives, so nothing waits on the network before the first frame draws — and it is the wrong call.

The stone colour map is 1.75 MB and its normal map is 1.28 MB. Both are for the ruin, which is chapter 03, two minutes down the page. Both start downloading immediately, competing for bandwidth with the canopy atlas that the visitor is looking at right now. Roughly three megabytes of a page’s opening seconds are spent on something nobody can see yet.

Nothing about the architecture prevents fixing it — the fix is priority, not laziness: fetch the canopy, understory and bark first, and let the ruin’s textures start when the walk reaches the river. It would make the opening resolve noticeably faster and look identical.

The other thing we would change is smaller and more embarrassing: those two maps are simply larger than they need to be for the distances they are seen at.

09 — After the camera let go

Everything above describes a world built for one camera. A week after publishing, we handed the camera to the visitor. There is now a switch in Selva’s nav marked Roam: it locks the pointer, puts you on the ground wherever the rail had the camera, and you walk — the usual keys, Shift to run, F to bring the dawn up, Escape to be drawn back onto the rail at the nearest chapter.

It cost less than it sounds like it should, and the reason is the same reason the rest of the page exists: nothing here is a model, so nothing had to be made walkable. The ground is an analytic function — one call gives the exact height anywhere — and the ruin’s tiers already had a walkable-height function, written so ferns could root on them. A walker asks the same question a fern does. The entire collision system is a single rule: sample the height where you are about to be, and refuse the step if it is more than 1.15 metres up. That number is a hair over the temple’s 0.24-metre stair tread and well under its 2.1-metre tier, so you can climb the sixty-one steps and cannot scale the pyramid’s face. There is no collision mesh. The world is 5,000 stones and a few hundred thousand leaves, and a collider for that would cost more than the scene.

The real price is the foreground cutouts. Those are photographs the page composites over the canvas, shot for a camera standing still; with the camera in your hands they are stuck to the glass, so roam mode hides them. That is why it is a toggle and not the default — the rail is the better-looking thing, and it stays the first thing anyone sees.

Inside a small stone chamber, a carved relief of a serpent devouring its tail hangs in a stone frame on the back wall; a small gold figure with a radiating halo stands on a plinth before it, and blackened bowls and gold beads lie on the floor.
The room at the top of the stair, which used to be a solid dark box standing in for a room. It is hollow now, because someone can walk in. The relief is a photograph on a plane — the same trick as every leaf — and the idol is forty boxes, two spheres and a torus, gold only because it reflects a tiny imagined chamber we pre-filtered into an environment map. Lit by fire alone it was orange plastic.

A world that only has things where the camera goes is a set, and the day the visitor got the camera it stopped being one. So we built what the six shots never look at: a ballcourt out past the river’s west bank, with a ruler carved into its end wall and a fire at the foot of it that is visible through the trees from a long way off; a waymark stela on the walking line to it; and, far south beyond the temple, a wall from an older city, sunk to its waist and leaning, its carved face turned away from the plaza. That last one taught us the moon has a side. Its direction here has a southward component, so a south-facing wall is lit and a north-facing one is silhouette; the first build faced the arriving walker and was an unreadable black slab. Turned around, you meet its blank back, circle it, and the face is the reveal — better staging, and honest lighting, which turned out to be the same thing.

A night view down a narrow stone alley between two long coursed walls, a carved roundel mounted high on each; at the far end a relief of a ruler is lit by a small fire; palm fronds close over the top.
The ballcourt, built from the same cut-stone calls as the temple. Two roundels for the game, a third lying face-up in the litter, and one fire — the breadcrumb. Fog here is exponential-squared, so at sixty metres a flame keeps most of its brightness, and from the dark between here and the plaza it is the only warm thing in the trees.

Two systems that cost nothing did more than any of the architecture. Twenty-eight pairs of eyes sit in the black between trunks: point sprites on the fireflies’ clock, two soft dots each, holding steady, blinking on their own slow timers, closing at dawn — and fading out between twelve and six metres as you approach, so nothing is ever there when you arrive. And every fire in the city has its moths, pale specks orbiting tight, which sell the flames harder than the flames do: a light with nothing circling it is a lamp.

A dark forest trail between tall trunks and ferns; low in the undergrowth to the left, two small warm points of light glow side by side like a pair of eyes.
Eyes in the dark. The oldest night-forest device there is, and the cheapest thing in the file. Before them the forest was beautiful and nobody was home.

The five carvings are generated images, night-graded to the same moonlight and set into stone cut on the page, credited as such in the footer. They are the only new downloads in any of this — 457 KB between them. The chamber, the ballcourt, the stela, the buried wall, the idol, the eyes and the moths added roughly 260 stones and not one kilobyte, and none of it appears in the six composed chapters, which we reshot to check.

The whole thing is one page you can read

Selva has no build step, so what ships is what was written — view source on it, or open /selva/assets/selva.js and read the scene top to bottom. If you are weighing up something like this for your own project, or you just want to argue about the merge bug, we would genuinely enjoy the conversation.