Forests at scale
Draw any tree a million times in three.js: wind, shadows, distant impostors, temporal upscaling and an auto-quality budget. Only what you can see is drawn, so a forest to the horizon costs little more than a wood.
It also comes with what an outdoor game needs next: hills or your own land, a forest floor, grass, sky, and solid trunks you can walk into.
It's the renderer from threejs-nanites by Victor Zakharov (MIT): cluster LOD, GPU culling, occlusion, a software rasterizer and impostors, in vendor/nanite-lib/. Patch added the Amount and Trees switches, planting on demand, a load with everything baked ahead, a whole low-poly look, trunk collision, height fields and this demo. It needs WebGPU, and three.js 0.186.1.
The trees: the repo's own are ez-tree, the same generator as patch/tree-generator. The forests here use the Tree generator block's presets, one pair per forest type, baked. Low-poly uses the Low-poly trees block, and turns the whole scene low-poly to match: ground, shrubs, rocks, sky, clouds and light.
In the live demo, W, A, S and D walk, Shift runs, Space jumps, dragging looks, and the wheel goes in through a lens or out until the whole forest shows. Walk the path follows the paved loop until you press any movement key or the button again. Trunks stop you.
Use it in a game
Beside other blocks, drop this repo's three alias to Three.Core.js from your Vite config: it breaks any block that imports WebGLRenderer, and this one ran the same without it. patch/car runs under world.frame, with world.trunksNear for its collisions: Patch's guide has that recipe. patch/clouds doesn't work here yet: its pipelines exceed this renderer's device limits.
Copy src/, vendor/nanite-lib/ and baked/ (the trees, their textures, the far trees' atlas, the forest floor) into your game, with three@0.186.1 and meshoptimizer@1.3.0, serve baked/ as your public folder, and copy vite.config.mjs's aliases into your Vite config. Then:
import { createWorld } from './src/index.ts';
const world = await createWorld({
canvas,
adapter: navigator.gpu.requestAdapter(),
maxTrees: 1_000_000, // room for this many; only what you ask for is planted
amount: 100_000, // planted before the world is ready
budget: 10, // GPU ms a frame that auto quality aims for
onProgress: (share, words) => showLoadingBar(share, words),
});
world.setTrees('mixed'); // or 'oak', 'birch', 'pine', 'low', or a map of your own
await world.warm(() => placeCamera(world.camera)); // compiles every shader: keep a cover over the canvas until it's done
world.renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
moveCamera(world.camera, dt); // the camera is yours: world.camera, a PerspectiveCamera
world.frame(dt); // draws it all, and auto quality takes its step
});
world.scene is a normal three.js scene for your own meshes: use node materials, on layer 0. Add lights to layer PHASE2_LAYER too. world.frame owns the frame and its passes, so you can't put it under another renderer's post-processing. Add yours to TAA's extra or to the output pass in src/world.ts.
Steer it:
| Call | What it does |
|---|---|
setTrees(choice) | A key of CHOICES, or your own map: per planted type, the type it draws as. One uniform write, no hitch. 'low' also swaps the whole scene's look; its pieces are compiled at load, so that doesn't stall either. |
setAmount(n) | Draws about n trees, whole tiles round the middle. Lower is instant. Higher plants the rest in the background, a few workers at a time, and the forest grows out as they land (planting() says while): about 2 s for a million on this PC, with no stall. |
setWind(v), setSeason(v) | Wind strength. Autumn from 0 to 1. Both are uniforms; the ground's fallen leaves follow the season. |
setSunHeight(deg) | The sun, its shadows, the light's colour and the sky. |
setShadows(on), setBudget(ms) | Shadows off is a cheap switch, with no recompile. A budget of 14 suits a phone. |
setInstance(i, { scale: 0 }) | Fells one tree, or moves it. One small upload whatever the forest's size. Tell your physics too. |
stats() | How many trees are near, far, and how many triangles drew this frame (async, read it twice a second at most). |
Your own trees
Any mesh works. addTree before createWorld, then it's a type like any other:
import { addTree } from './src/index.ts';
const mine = addTree({
name: 'My tree',
height: 12, // metres
mesh: { bark: { pos, nrm, uv, idx }, leaves: { pos, nrm, uv, vpl: 4 } }, // leaves are optional
bark: './textures/my-bark.jpg', // 1K square; leaves need an alpha
leaf: './textures/my-leaves.png',
pool: 'leaf', // planted with the broadleaves ('pine': the conifers; none: only through a map)
});
barkis any solid triangles, indexed, standing on y = 0 round the y axis. Rocks work too.leavesare alpha-tested cards: quads in order,vpl4 vertices each (a flat card), or 8 (two quads: bent or crossed). Normals can be anything you like: the block's trees bend theirs towards the crown so it shades as a volume.- Vertex colours aren't kept. The renderer's vertices are position, normal and UV. Put colours in a little palette texture and point each vertex's UV at its swatch, as
tools/bake/pack-trees.pydoes for the low-poly trees. - Your trees cost load time. Their DAGs are built in a worker when the world starts (about 0.3 s for 120,000 triangles), and adding any tree means the far trees' atlas is baked at load too, on the main thread, for every type: about a second, and the page doesn't answer during it. To ship without that, add yours to
VARIANTSinsrc/trees.tsand runtools/bake/as the block's own are (tools/bake/bake-lab.mjslists the steps). - Check the far trees' colour.
tools/calibrate.mjsmeasures the impostors against the real trees for each species and prints the gains forimpGaininsrc/trees.ts. Without it a species reads slightly too bright or dark from far off.
For a world with none of this, NaniteRenderer and buildDag come straight from vendor/nanite-lib/index.ts with hooks for your own look. It's the most flexible way in and also the most work.
Ground height and collision
The renderer only draws trees, so they aren't solid unless you ask. Three calls cover it, all on the CPU, all taking microseconds:
const y = world.heightAt(x, z); // the ground's height in metres
player.position.y = Math.max(player.position.y, y);
world.collide(player.position, 0.35); // pushes a circle of 0.35 m out of any trunk it overlaps (x and z)
// or feed your physics: the trunks near a point, each with x, y, z, radius, height, index and type
for (const t of world.trunksNear(player.position.x, player.position.z, 25)) {
ensureCapsule(t.index, t.x, t.y, t.z, t.radius, t.height);
}
trunksNearrespectssetAmount,setTreesand felled trees.- Stream the colliders. Call
trunksNearevery 10 m or so, with a radius a bit wider than your fastest mover covers in that time, and add and remove capsules byindex. Don't make a body for all million. radiusis the trunk's at chest height. Branches and leaves have no collider at all. For a trunk that leans, like a birch's stems, the circle covers the lean, so it's slightly generous.- Bushes and grass aren't solid.
On your own land, and with patch/terrain
heightAt is the built-in hills until you give it yours. Pass a square grid of heights centred on the origin, and the trees, ground, grass and shadows all follow it:
const world = await createWorld({
canvas, adapter,
heightField: { size: 4096, res: 513, data: heights }, // Float32Array, res x res, in metres
path: false, // no paved loop on your land
plantAbove: 131, // no trees under your sea
plantSlope: 0.9, // or on ground steeper than this (rise over run)
});
patch/terrain gives you the heights but can't share a frame. Both own the whole frame. Terrain renders with WebGL 2 and three 0.185, and this renders with WebGPU and three 0.186, so one can't draw inside the other. What works, and I tested it, is taking terrain's land as data: run terrain once, read world.heightAt across its board into a grid, ship the grid, and draw with this block alone. tools/terrain-heights.mjs does exactly that, 513 x 513 in a few seconds. A forest stood on its Volcanic coast with plantAbove at its sea level and plantSlope 0.9, staying off the cliffs and out of the water. You get terrain's shapes but not its looks. The ground here is a meadow and dirt by slope, with no water, snow or terrain's sky, so add your own water at sea level. Past the edge of the grid the land stays flat at its border height.
Trees on terrain seen from the air are where terrain's own README says you'd want thousands of small ones. This is the way to get them.
Phones and no WebGPU
- Phones: touch works. The left thumb is a stick, the right thumb looks, and two buttons lift the view and bring it back. I tried it in Chrome's phone emulation on this PC, with the upstream touch code and then this one. It ran with the auto budget and the right layout, but I haven't run it on a real phone. Set
budget: 14and expect the grass to read coarser, because phones get fewer, wider blades. - WebGL: upstream has a reduced WebGL 2 mode, near trees as instanced meshes and far ones as impostors. I tried it and it works in real Chrome (first frame after 4.6 s, grass and floor about 2 s later), but it crashed the tab in headless Chrome, and it's a separate renderer with its own near-tree code. So it isn't in this block. Where there's no WebGPU, the demo says so in the frame. If you need it, take
src/webgl/from upstream.
What it's bad at
- Trees up close are as good as their mesh. Oak, Birch and Pine are 40,000 to 120,000 triangles, and leaf cards stay cards: within a metre or two you see flat twig cards. There's no per-leaf animation beyond flutter.
- Leaf edges dither. Under temporal anti-aliasing, leaves are cut out by a moving pattern, so a screenshot or a very fast turn shows a fine screen door. It settles in a few frames.
- Coarse detail looks chunky. When auto quality pushes the error threshold above about 3, near leaf masses turn visibly blocky. That's what a weak GPU sees before the resolution starts to drop.
- Mixing many species is limited by the leaf atlas: one 1K layer a species, and every species costs GPU memory all the time, shown or not.
- The low-poly ground has no grass or fine detail. It's flat-shaded facets about 20 m across at the largest forest size, with shrubs and rocks for scale. Autumn doesn't change low-poly trees.
- Trees don't fall, bend to impacts or grow. They sway. A felled tree simply vanishes (
scale: 0), so play your own falling tree over it. - Far trees don't sway. Impostors stand still, and their colours are matched to the real trees by hand-run calibration, per species.
- Amount grows the forest outward from the middle, in 128 m tiles. Round numbers land a little off (100,075 for 100,000), and 1,000 trees are a blocky wood, not a round one. Density is fixed; a thinner forest means a new planting.
- Room is taken at load.
maxTreessets the GPU buffers from the start, about 64 MB for 1M trees and 1M bushes, planted or not. Lower it if you won't use them. - The frame belongs to it. Mixing in other post-processing means editing
src/world.ts. - Tested only on an RTX 4090. The numbers below are on it. Under a busy GPU auto quality visibly stepped down and back up, and the demo starts at 100,000 trees to be kind to slower cards, but no laptop has played it yet. A CPU-only software GPU never finished loading in 3 minutes.
Performance
RTX 4090 at 1600 x 900, full detail (error threshold 1 px), on a quiet GPU:
| Where | Trees planted | Near, far in view | GPU |
|---|---|---|---|
| On the path | 100,075 | 1,760 near, 711 far | 5.6 ms |
| On the path | 1,000,023 | 1,704 near, 780 far | 5.4 ms |
| 400 m up | 1,000,023 | 3,904 near, 239,027 far | 5.3 ms |
| 1 km up | 1,000,023 | 156 near, 294,272 far | 4.6 ms |
| 2.5 km up | 1,000,023 | 321,206 far | 3.8 ms |
A million trees cost no more than a hundred thousand on the ground, since the difference is all out of sight. These are single readings and move by a millisecond or so.
Load: about 3 s on this PC, and the page keeps answering throughout: in real Chrome on a first visit, no task on the page over about 60 ms. Trees, their DAGs, the far trees' atlas and the forest floor are baked ahead, at most six workers run at a time, only the first 100,000 trees are planted at the start, and shaders compile in the background before the cover lifts, including the two things that come on by themselves later (the low-poly look and the leaves' depth prepass). The download is 10.6 MB.
Controls
The block page talks to the demo over the patch-block channel (lib/patch-block.js).
For people: Trees (tree: Mixed, Oak, Birch, Pine, Low-poly), Amount (amount, 1,000 to 1,000,000), Wind (wind), Sun height (sun), Autumn (season), and the triggers Walk the path (walk) and Reset (reset). Reset puts every control back, and the walker at the start.
For agents (in recipe.json with ranges): GPU budget (budget), Shadows (shadows), God rays (rays), Grass (grass), Clouds (clouds).
Build
npm install
npm run build
dist/ is the demo. npm run dev serves it live on port 5301. tools/ has what checked it: views.mjs (scripted screenshots), load-trace.mjs (a cold load: what stalled, when), stress.mjs (hard play in real Chrome), calibrate.mjs (far tree colours), readme-check.html (this README's code, run as written) and terrain-heights.mjs. baked/ is what tools/bake/ made, from the Tree generator and Low-poly trees blocks kept beside this one; tools/bake/bake-lab.mjs lists the steps.
Credits and licences are in CREDITS.txt. What changed in the renderer is in vendor/nanite-lib/VENDOR.md.