description: "Godot 4 has no built-in asset streaming. We built four layers — terrain regions, vegetation chunks, async loading, and entity rendering — to run a 12km open world with 9 islands, 155 vegetation types, and 2,000 crowd instances."
og_description: "Four streaming layers, async loading, and zero memory leaks — running a 12km open world in Godot 4."
excerpt: "Godot has no built-in asset streaming. We built four layers to run a 12km archipelago with 9 islands, 155 vegetation types, and 2,000 crowd instances — on an RTX 3060."
Godot 4 has no terrain streaming, no asset LOD pipeline, and no distance-based loading. Our game is a 12km × 12km archipelago with 9 islands, 155 vegetation prototypes, and 2,000 simulated colonists. If you load everything at startup, you run out of VRAM before the player sees the main menu.
Each island is roughly 4km across with its own terrain heightmap, biome textures, vegetation, and building grids. The player travels between islands by canoe. At any given moment, only a small fraction of the world is visible — but Godot doesn't know that unless you tell it.
We use **Terrain3D** for heightmaps — a GDExtension that provides clipmap rendering with 7 LOD levels. Each island is split into 512m × 512m regions. A 4km island has 64 regions. Nine islands: 576 regions total.
The original code created all 9 terrain nodes in `_Ready()` and toggled visibility. This wasted hundreds of megabytes on islands the player hadn't visited. The fix: create the current island's terrain on startup, defer the rest. When the player sails to a new island, create that island's terrain node on demand, import the heightmap, start async texture loading — all behind a loading screen.
The camera position is checked every 0.5 seconds. When it crosses a chunk boundary, we calculate which chunks should be active within a 400m radius (~39 chunks), destroy chunks that fell out of range, and build new ones that entered. Each chunk groups vegetation by prototype, creates a **MultiMesh** per group, and places instances using height queries. A chunk with 50 palm trees and 30 rocks becomes 2 MultiMesh draw calls — not 80 individual nodes.
The cache problem: vegetation meshes and materials are cached in dictionaries keyed by prototype name. These caches are append-only by default — visit all 9 islands and you accumulate every mesh variant permanently. The fix is island-scoped eviction. When the player leaves an island, we clear vegetation caches. They reload from disk on return, behind a loading screen.
We audited the entire codebase and found **26 resource load calls across 13 files** — only 1 was async. The worst offender was `GetMeshForProto()` in the vegetation grid. As the player walks across a new island, every new vegetation prototype triggers a synchronous load. With 155 prototypes, the first traversal stutters visibly.
- **Pre-warm during loading screens.** When an island is imported, kick off background loads for all known prototypes. By the time the player gains control, most meshes are cached.
- **Async texture loading.** Terrain textures use `ResourceLoader.LoadThreadedRequest()` with `_Process()` polling. The terrain renders immediately with autoshader colors; biome textures pop in when ready.
The ResourceLoader trap: Godot maintains an internal resource cache. Every `GD.Load()` caches the result globally. If you load an FBX as a `PackedScene`, instantiate it to extract a mesh, then free the instance — the PackedScene **stays cached**. Rule: use `ResourceLoader.Load(path, "", CacheMode.Ignore)` for one-shot loads where you extract data and discard the container.
- **Crowd rendering:** Single MultiMesh for up to 2,000 colonists. Positions lerped per frame from pre-allocated arrays. Labels distance-culled, capped at 20.
- **Animals:** One MultiMesh per type. Max 500 per type. Updates only on state change.
- **Buildings:** Tracked by ID from sim state. `QueueFree` when removed.
We audited every `QueueFree()` call — 47 calls across 17 files. **Zero `RemoveChild()` calls without a corresponding `QueueFree()`.** Three patterns we follow everywhere:
1.**Chunk streaming:** Iterate active dict, call `QueueFree()`, collect keys to remove, then remove after iteration. Never modify a dictionary while iterating.
2.**Extract from PackedScene:** Instantiate, extract mesh, `QueueFree()` the temp instance. The mesh survives because it's a Resource, not a Node.
3.**UI rebuild:**`QueueFree()` all children, build new content. Safe because `QueueFree` is deferred — new children added in same frame before old ones freed.
Two shaders to watch: the ocean shader (4 Gerstner waves, depth reconstruction, caustics, foam — heaviest thing in the pipeline) and the wind sway shader (6 trig ops per vertex on every vegetation mesh within 400m). Future optimization: disable sway on distant chunks.
**Always measure before optimizing.** We added VRAM logging before writing a single line of optimization code. Half the "problems" we expected were non-issues. The other half were worse than expected. Profiling isn't optional.
Godot 4 can handle open worlds at this scale, but it won't do it for you. You need to build streaming, manage your own caches, audit resource loading, and be disciplined about what runs per frame. The engine gives you the primitives — MultiMesh, `LoadThreadedRequest`, `QueueFree`. It's up to you to wire them into a system that scales.
*We're building [Ariki](https://arikigame.com), a survival colony sim, with these systems. The tools we use — git hosting, AI agents, creative pipelines — are part of [Tinqs Studio](https://tinqs.com).*