Disclosure: I'm part of the team behind FoliMesh, a tree generator. This guide is engine-only — everything here works with any trees.
The first forest in a Godot project usually looks great — until you walk into it. Two hundred trees later the frame rate halves and the profiler shows thousands of draw calls.
Godot 4 has every tool you need for a big, fast forest. They just have to be combined in the right order: budget → LOD → instancing → leaves → shadows → culling.
Why forests are slow

A tree costs you in four places at once:
- Draw calls — bark and leaves are separate meshes/materials. 100 trees = hundreds of draw calls.
- Triangles — a detailed tree easily has 40k+ triangles.
- Overdraw — leaf cards are mostly transparent pixels, and the GPU still shades them, layer after layer.
- Shadows — every tree is drawn again into the shadow map, sometimes once per cascade.
Step 1. Set a triangle budget first
Decide how much a tree may cost at each distance before you model or generate it. Example chain for one oak:
| Level | Distance | Triangles |
|---|---|---|
| LOD0 | 0–30 m | 14,490 |
| LOD1 | 30–70 m | 3,984 |
| LOD2 | 70–150 m | 1,154 |
| Impostor | 150 m+ | 2 |
The shape of the curve matters more than the numbers: each level ~3–4× lighter. On mobile, divide by 2–3. Most of your forest is far away, so most of it should be cheap.
Step 2. Automatic mesh LOD vs visibility ranges
Automatic mesh LOD (generated on import with meshoptimizer) is great for rocks and buildings. A tree crown, though, is hundreds of separate leaf cards — generic simplification can thin and distort it.
Visibility ranges let you supply your own LODs and set the distance band for each. Every GeometryInstance3D has visibility_range_begin/end, margins to avoid flicker at the border, and visibility_range_fade_mode (Disabled is fastest; fades force transparent rendering during the transition).
# Tree.gd — children: LOD0, LOD1, LOD2 (MeshInstance3D) extends Node3D @export var lod_starts := PackedFloat32Array([0.0, 30.0, 70.0]) @export var cull_distance := 150.0 func _ready() -> void: for i in get_child_count(): var mi := get_child(i) as MeshInstance3D if mi == null: continue mi.visibility_range_begin = lod_starts[i] mi.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance mi.visibility_range_end_margin = 2.0 mi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_DISABLED If the switch is visible, check that LODs keep the same silhouette first — it matters more than any fade setting.
Step 3. Thousands of trees: MultiMesh, in chunks
One MultiMeshInstance3D draws any number of copies in one draw call. The catch: all instances share one LOD level, and culling only removes the node as a whole (see Godot issue #76436). One MultiMesh for the whole level = no per-tree LOD, no culling.
The fix is a grid of chunks, each with its own MultiMesh per LOD level and its own visibility range:
# ForestChunks.gd extends Node3D @export var lod_meshes: Array[Mesh] = [] # LOD0, LOD1, LOD2, impostor @export var lod_starts := PackedFloat32Array([0.0, 35.0, 80.0, 160.0]) @export var cull_distance := 400.0 @export var chunk_size := 32.0 func build(transforms: Array[Transform3D]) -> void: var chunks := {} for t in transforms: var key := Vector2i(floori(t.origin.x / chunk_size), floori(t.origin.z / chunk_size)) if not chunks.has(key): chunks[key] = [] chunks[key].append(t) for key in chunks: _build_chunk(chunks[key]) func _build_chunk(items: Array) -> void: for i in lod_meshes.size(): var mm := MultiMesh.new() mm.transform_format = MultiMesh.TRANSFORM_3D mm.mesh = lod_meshes[i] mm.instance_count = items.size() for k in items.size(): mm.set_instance_transform(k, items[k]) var node := MultiMeshInstance3D.new() node.multimesh = mm node.visibility_range_begin = lod_starts[i] node.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance node.visibility_range_end_margin = 4.0 add_child(node) - Chunk size ≈ LOD0 distance. Distance is measured to the chunk centre, so huge chunks show LOD1 right next to the player. With LOD0 up to 35 m, 24–32 m chunks work well.
- Not too small either — every chunk adds draw calls. Measure.
- MultiMesh has no collision: add a capsule per trunk only where the player can reach.
Step 4. Leaves: Alpha Scissor, not Alpha
- Set Transparency → Alpha Scissor. Blended alpha needs sorting, breaks depth and shadows, and is far more expensive.
- With MSAA, use Alpha Antialiasing → Alpha Edge Clip/Blend for soft edges.
- Cut cards to the shape of the leaf cluster to reduce overdraw. Check with Display Overdraw — bright red crowns mean too many layers.
- Drop inner clusters nobody sees, starting with far LODs.
Step 5. Shadows: the hidden second forest
- Limit DirectionalLight3D → Directional Shadow → Max Distance (100–200 m is usually enough).
- Cast Shadow → Off on LOD2 and impostors.
- Two cascades instead of four on weak hardware.
Step 6. Occlusion culling and wind
Trees are full of holes and make poor occluders — let terrain, hills and buildings do the work. Enable occlusion culling, add an OccluderInstance3D and bake. Chunked MultiMeshes benefit most: a hidden chunk is skipped entirely.
Wind belongs in the vertex shader, reading precomputed per-vertex weights (trunk barely moves, branches sway, leaf tips flutter). Reading two numbers per vertex is almost free.
Step 7. Measure on the target hardware
- Debugger → Monitors: draw calls, objects, primitives in frame.
- Debugger → Visual Profiler: CPU vs GPU time.
- Viewport → Display Overdraw / Wireframe.
Measure before and after each change, on the weakest device you support.
Checklist
- [ ] Triangle budget per distance, each LOD 3–4× lighter
- [ ] Visibility ranges with own LODs for trees; auto mesh LOD for rocks/props
- [ ] Chunked MultiMeshes, chunk ≈ LOD0 distance
- [ ] Alpha Scissor + alpha to coverage; cards follow cluster shape
- [ ] Limited shadow distance; far LODs don't cast shadows
- [ ] Occlusion baked from terrain and large objects
- [ ] Wind as a cheap vertex shader
- [ ] Draw calls and frame time measured on target device
If you'd rather not build the LOD chain by hand: we made FoliMesh's Godot package for our own large-scale game — a GLB with LODs at your triangle budgets plus a ready .tscn that sets visibility ranges, a wind shader and trunk collision.
What's your biggest forest performance pain in Godot? Happy to dig into it in the comments.
