How many trees can three.js render? 5,200 in Lullwood, with chunked InstancedMesh

Lullwood draws 5,200 trees in a browser tab by splitting them into 64 chunks, each its own three-part InstancedMesh, so three.js can frustum-cull whole chunks, and by building only the chunks near the player: 25 at spawn, 36 at most while walking. The heaviest whole frame we measured was 101,996 triangles; one pool would be 270,400.

We build Lullwood, a free first-person horror game for the browser, in three.js and Next.js. This post is the forest renderer, read from the source at commit 90a269b, the head of our release branch when we wrote it. Line numbers are for that commit, in engine/forest-engine.js unless a file is named. The one thing it does not give you is a frame-rate number, because we never measured one.

The forest: 5,200 trees, 64 chunks

CONFIG.trees is 5,200 and CONFIG.mapSize is 480 world units (engine/tuning.js L86, L18). A tree is three meshes: a trunk, CylinderGeometry(0.12, 0.20, 1.6, 6), and two foliage cones with 7 radial segments (L712-714). Placing them is a rejection loop over random positions, capped at 130,000 attempts, 25 per tree on average (L1468).

The first version drew all of it from one InstancedMesh pool that spanned the map. The source says why that could not be culled: a mesh covering the whole map is always at least partly in view, so frustumCulled was off and all 5,200 trees were submitted every frame (L1159-1163).

The fix, ticket LUL-1487, is a grid. TREE_CHUNK_SIZE is 60 units, so a 480-unit map is 8 by 8 = 64 chunks (L1165-1166), and each tree is filed into a chunk by its x and z alone (L1168-1172). We counted on the live game: all 64 chunks hold trees, which is 81 per chunk on average.

One trio per chunk, and a bounding sphere

Each chunk gets its own trunk mesh and two cone meshes, each an InstancedMesh sized to exactly that chunk’s tree count (L1206-1214). The engine leaves frustumCulled at three.js’s default of true, and calls computeBoundingSphere() on each mesh once, because the layout is static (L1215, L1236).

three.js decides whether to draw an object by testing its bounding sphere against the camera frustum. For an InstancedMesh in three.js 0.185.1 (package.json pins it), the test reads the mesh’s own boundingSphere, and computeBoundingSphere() builds that sphere from every instance matrix, so it wraps the chunk’s trees and not one tree’s geometry (Frustum.js L148-152, InstancedMesh.js L151-174). three.js will build it lazily if it is missing. The engine builds it up front, once, because the layout never changes. A chunk behind you is then skipped with no new per-frame code.

The test is coarse. A 60-unit cell has a diagonal of about 85 units, so the sphere around a chunk reaches past the chunk itself, and a chunk grazing the edge of the view draws all of its trees.

Then we stopped building most of it

Culling skips the draw, but the meshes and their GPU buffers still exist. A second ticket, LUL-2249, streams them. Only chunks within STREAM_RADIUS_CHUNKS = 2 of the player’s own chunk are built: a 5 by 5 square, 300 by 300 units (L1253). A chunk is disposed only once it is more than STREAM_UNLOAD_CHEBYSHEV = 3 chunks away, so a player stepping back and forth across a boundary does not rebuild it every time (L1254, L1291).

The check is called once per frame but returns at once unless the player has changed chunk (updateStreamedChunks, L1273-1275, called at L8639), and building a chunk makes no rng() call, so the map is identical either way. The map is still 5,200 trees in data. The ring is 25 chunks, but the unload band keeps the chunks you just left, so a walking player can have up to 36 of the 64 as meshes: six per axis, two ahead and three behind (L1286-1291). We derived 36 from that rule and then measured it.

The numbers, and the number we do not have

We measured the live game on 2026-11-09: headless Chrome at 1280 by 720 on a desktop GPU, our pinned QA seed (20260718), at five positions and four headings each, reading qaProbePerf() and qaProbeTreeChunks().

  • At spawn, 25 chunks are live: 2,088 of the 5,200 trees exist as instances. At the corner (−220, −220) the ring is clamped by the map edge to 9 chunks and 760 trees.
  • Across all 20 views, one whole frame (every tree, cover prop, predator and effect) cost between 35 and 190 draw calls and between 11,640 and 82,740 triangles.
  • The same position changes with the heading. From (0, 200) the frame was 75,928 triangles facing one way and 19,582 facing the other. That is consistent with the frustum test, though the heading also changes which cover props and predators are in view.
  • Walking changes the chunk count. We stepped the player one chunk at a time, teleporting to each chunk centre because the unload rule reads only the chunk index, along a staircase path across the map. Live chunks climbed to 35, and a path that doubled back across a boundary reached 36 chunks and 2,983 trees. The heaviest whole frame in those walks was 101,996 triangles and 219 draw calls.

For the old pool, the figure is arithmetic, not a measurement. A tree is 24 + 14 + 14 = 52 triangles, from the three geometries as three.js 0.185.1 builds them, and 52 times 5,200 is 270,400 triangles in three draw calls, every frame, before a single predator. The heaviest whole frame we measured is under a third of that.

There is no recorded before. When the chunking commit landed on September 7, 2026, its own message reported the same reading before and after, 1 call and 2 triangles, and explained why: renderer.info.render holds only the last render call of a frame, and ours ends on the full-screen post-processing blit. The probe was measuring a quad. LUL-2257 fixed the probe on September 9, after which the last ticket of the streaming epic, LUL-2250, recorded the first real numbers (143 draw calls and 70,294 triangles at spawn on desktop, docs/specs/lul-2245-baseline-perf.md). Ours are in the same range. That file holds no run of the single-pool version through the fixed probe, so the 270,400 stays arithmetic.

What it costs

Chunking trades triangles for draw calls. Each live chunk is three meshes, so a full ring can submit 75 tree draw calls at spawn and up to 108 (36 chunks times 3) while walking, where the old pool submitted 3, and three.js culls per object, which is why the chunks exist at all. We do not know where the frame-time break-even sits on a low-end phone, and we ran no benchmark of the old and new versions against each other, so this post makes no speed claim.

The ring is also wide. Its edge is at least 120 units from the player, while a source comment puts the fog’s own limit at about 40 to 60 units (L1159-1160). By that comment, most of what the ring builds is fogged out. We have not tested a smaller radius, and nothing here says 2 is the right one.

Where to go next

The other half of the fog story is in the fog tide, and the sight test that never touches a scene mesh is in how a wolf sees you. Play Lullwood free in the browser and judge the forest from inside it.