Hey everyone!
I built a GPU-friendly procedural forest system for Three.js that renders hundreds of fully 3D trees at real-time framerates. This fills a gap I noticed: there are plenty of examples showing either one beautifully detailed procedural tree, or billboard-based forests for massive scale, but not many showing the happy medium: a good-looking forest with actual tree geometry that still performs well.
Features:
-
Fully procedural L-system-style branching with 5 tree type presets for variety
-
Instanced rendering
-
Vertex-shader LOD culling
-
Distance-based leaf shrinking with bark green-tinting to hide LOD transitions
-
Per-leaf sway animation with distance cutoff
-
Pre-computed per-instance attributes
-
Procedural leaf texture with veins generated at runtime
-
Procedural bark texture with per tree brightness and hue shifts
-
Surface root spread simulated by flaring out vertices near ground level
-
Configurable: tree count, forest radius, branch levels, leaf density, LOD distances, shadow quality
Performance Approach:
-
Instancing is the foundation — InstancedMesh for both bark cylinders and leaf quads
-
LOD happens in vertex shader: distant leaves get gl_Position = vec4(0,0,0,1) which GPUs cull before fragment stage
-
Sway animation skipped beyond 50 units
-
Bark shifts toward green at distance to compensate for culled leaves; essentially free visually
-
Shadow quality toggle (or disable entirely) for tight budgets
-
Pixel ratio capping on mobile
-
Runs 60fps on mid-range desktop GPU
-
Configurable LOD distances let you trade quality for performance
The code is heavily commented explaining the “why” behind each optimization.
Live Demo on CodePen →
7 Likes
Update: Custom Per-Tree Culling for Mobile
A friend challenged me to push performance further on mobile devices and low-end GPUs.
The challenge: I wanted to keep the large unified leaf and bark meshes to maintain the lowest possible draw calls (just 2!), but needed a way to skip rendering trees that are outside the camera frustum.
Enter “Custom Per-Tree Culling”
I created a Separate Demo** on CodePen →** and left the original clean and untouched, because this is a bit niche.
The approach:
-
Each tree gets a bounding sphere computed at generation time
-
Every frame, I test each tree’s sphere against the camera frustum on the CPU
-
Visible trees have their instances packed to the front of the buffer arrays
-
mesh.count is set to only the visible instance count
-
GPU only processes what’s actually on screen
The tricky part was keeping all per-instance attributes in sync (matrices, colors, wobble values, sway phases) they all need to be reordered together when visibility changes.
Key implementation details:
-
Camera movement throttling (only recalculate when camera moves >0.5 units or rotates >0.01 rad)
-
Track previous visibility state to skip rebuilding when nothing changed
-
DynamicDrawUsage on buffers since we’re updating them frequently
-
Pre-allocated typed arrays to avoid GC pressure during reordering
Results: it depends on your use case.
On mid-to-high-end GPUs, custom culling likely isn’t worth it. Modern GPUs chew through the vertex shader LOD culling without breaking a sweat, and you’re just adding CPU overhead. This optimization is primarily for mobile and low-end GPUs that struggle with high instance counts.
It also depends on forest density. Sparse trees spread over a large area? Custom culling wins: you’re only ever seeing a fraction of them. Dense trees packed tightly? You’re probably rendering most of them anyway, so the CPU overhead isn’t paying off.
That said, if you are on constrained hardware:
-
Sweeping overviews / fast camera movement: Stick with native Three.js culling (disable custom). The CPU cost of constantly recalculating visibility during quick turns can hurt, and the vertex shader LOD handles distance culling anyway.
-
First-person / limited FOV / slow movement: Custom per-tree culling shines here. When you’re only seeing 20-30% of the forest at a time, the GPU savings are massive. Went from ~15fps to solid 60fps on my old iPhone in this scenario.
-
Stealth game sneaking through trees: Definitely custom. Camera moves slowly, visibility changes infrequently, and you’re only rendering what’s directly ahead.
-
Fast-paced action with quick 180° turns: Probably native. The sudden visibility changes mean constant buffer rebuilds, and you might be looking at most of the forest anyway.
Toggle “Custom Per-Tree Culling” in the Performance folder to compare for your specific scenario. Note: toggling regenerates the forest since it requires different buffer configurations.
3 Likes
No Christmas lights?
Actually, you might throw some pine trees in there. They can be constructed using very few triangles - just a vertical trunk with slightly cone-shaped horizontal rings of, say, 6-8 branches which increase in size as you near the bottom. If you find a ring-shaped texture, you can use a single texture for each ring. So, maybe 50-100 triangles per tree.
Forgot to add: Excellent job, very few draw calls, etc. It’s hard to find anything that is efficient at drawing large numbers of deciduous trees in three.js.
1 Like
Hi @red-reddington, this looks fantastic — especially the two-draw-call architecture and the per-tree CPU culling. Really impressive for mobile.
I’m building birdybird, a small mobile-first bird-flight game in Three.js (tilt controls, open-source hobby project, runs on GitHub Pages). We already integrate @phil_crowther’s iFFT ocean on the water side, and your procedural forest would be a huge upgrade over our current sprite-tree setup for the 6 km world we render.
Before I start porting it, I wanted to ask directly: would you be OK with us using your L-system forest code in a non-commercial open-source project, with attribution and a link back to this thread? Happy to follow whatever license terms you prefer (MIT / CC-BY / custom / “sure, just credit me” — your call).
If you’d rather keep it as a personal demo, no problem at all — I completely understand. Just thought it was worth asking.
Repo: https://github.com/pmmathias/birdybird
Live: https://pmmathias.github.io/birdybird/
Thanks either way — and great work!
1 Like
@Mathias_Leonhardt You’re a class act for reaching out like this. Seriously, not everyone does, and it means a lot.
I’m genuinely happy the code is useful, and I just love what you’ve built with birdybird. The controls are super cool, really intuitive feel. One small suggestion: you might consider adding an option to invert W/S (up/down), since some players strongly prefer it that way.
As for attribution, it’s not necessary, but it would be appreciated. Totally your call, and please don’t feel obliged to link at all. If you do want to, I’d actually wonder whether linking to my CodePen (https://codepen.io/the-red-reddington) might be more useful than this thread; easier to find and follow future stuff.
My whole reason for publishing these CodePens is to help lower the barrier for people who are passionate about Three.js and gamedev. Projects like yours are exactly what I had in mind.
And just to be clear: this is MIT, meaning you’re free to commercialize it too if that ever becomes a direction for the project. No strings.
Go build something great!
2 Likes
Thank you so much @red-reddington. Releasing it under MIT is incredibly generous, and it’s exactly the kind of openness that makes the Three.js community feel like a good place to build.
You’re already in the in-app credits next to @phil_crowther’s ocean module ( birdybird → Credits). Linked your CodePen profile as you suggested — happy to tweak the wording if you’d like something different.
And thanks for the W/S-invert tip — noted, definitely adding it as a setting.
Quick note: birdybird is a hobby project built almost entirely with Claude Code as the pair-programming partner, and everything is open source. If anyone else here wants to poke at how we
integrated your forest, the repo is at GitHub - pmmathias/birdybird · GitHub —src/world/ProceduralForest.js.
We’re currently running a clean-room version I sketched up from your discourse description (worked nicely, 2800 trees in 8 draw calls on a 6 km world) and now with your MIT blessing we’ll probably study the real CodePen source to pick up the per-tree culling trick on mobile.
Go build something great right back at you — and thanks again 

2 Likes
Just FYI, it appears that Red’s program (procedural instanced forest) could be converted to WebGPU. (I won’t say “easily” because I won’t be doing the conversion.
)
I started with the original program, which works fine locally.
I stripped out the GUI because WebGPU uses something else and I don’t use GUI.
I stripped out the statsGL because that doesn’t work with WebGPU
I changed the renderer setup to load WebGPU and added the await command.
The program worked fine, except that the leaves were not colored.
The associated error message said, correctly, that WebGPU does not recognize ShaderMaterial. So, to make the colors work, the shaders associated with the ShaderMaterial would have to be implemented in a WebGPU-friendly manner. (Unless you are happy with black leaves and 1000s of draw calls.)
This indicates that if you ever want to switch to WebGPU (which experiences indicate may be useful with iPhones), Red’s program could be a valuable resource there as well.
2 Likes
We have it running on WebGPU in https://pmmathias.github.io/birdybird/
Source is dual-path so the same repo serves both renderers:
Two gotchas worth flagging for anyone else attempting this:
-
WebGPU caps vertex buffers at 8. The original leaf ShaderMaterial uses
five per-instance float attributes; combined with position+normal+uv+
instanceMatrix that hits 9 → WGSL compile error. We packed
instanceRandom + instanceSwayPhase into a single vec2
instanceRandSway and dropped the static wobble attrs (cosmetic only
at flight altitude).
-
The monolithic InstancedMesh has a world-spanning boundingSphere, so
three.js frustum culling never fires — the vertex shader ran for ~1M
instances per frame regardless of camera angle. Post-processing the
output into per-cluster InstancedMeshes (20 on desktop) gave a 3Ă— FPS
gain on WebGPU across most scenarios. ~80 lines in
splitForestByClusters():
https://github.com/pmmathias/birdybird/blob/8885b5a02ad3fc80057c0ee869cbe216acb69d1e/src/world/WorldBuilder.js#L227
2 Likes