Merged overlapping posts: - forking-gitea + fork-dont-build → one post about the fork philosophy - fal-image-generation + image-generation-fal → one post about AI art pipeline Rewrote all posts with external/public voice: - Stronger hooks, concrete examples, punchier language - agentic-workflow: restructured around soul files + skills + numbers - agent-harness: clearer framing of 'what an agent harness is' - cloud-harness: tighter narrative about overnight agents - godot-optimisation: same depth, sharper opening - pre-commit-agent: clearer architecture, cost breakdown - studio-cli: reframed around identity/cold-start problem - blog-visual-upgrade: tightened the restyle story 10 posts total (9 markdown + 1 hand-authored HTML)
7.3 KiB
title, slug, date, description, og_description, og_image, excerpt, author, author_initials, author_role
| title | slug | date | description | og_description | og_image | excerpt | author | author_initials | author_role |
|---|---|---|---|---|---|---|---|---|---|
| Streaming a 12km Archipelago in Godot 4 | godot-optimisation | 2026-05-22 | 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. | Four streaming layers, async loading, and zero memory leaks — running a 12km open world in Godot 4. | https://www.tinqs.com/img/og-cover.jpg | 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. | Ozan Bozkurt | OB | CTO & Developer, Tinqs |
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.
Here's how we built four streaming layers on top of Godot, all in C#, to make it work.
The scale problem
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 built four layers that teach Godot what to load, when to load it, and when to let it go.
Layer 1: Terrain regions (lazy instantiation)
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.
Layer 2: Vegetation chunks (128m grid)
The main prop streaming system. Every island's vegetation is divided into a spatial grid of 128m × 128m chunks.
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.
Layer 3: Async resource loading
Godot's GD.Load() is synchronous. It blocks the main thread. During gameplay, the frame freezes.
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.
Two fixes:
- 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.
Layer 4: Entity rendering (event-driven)
Dynamic entities — colonists, animals, buildings, VFX — update when the simulation pushes new state, not per frame.
- 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.
QueueFreewhen removed. - VFX: Capped at 50 active particle systems. Worst case: 10,000 GPU particles.
Memory safety: the QueueFree audit
We audited every QueueFree() call — 47 calls across 17 files. Zero RemoveChild() calls without a corresponding QueueFree(). Three patterns we follow everywhere:
- Chunk streaming: Iterate active dict, call
QueueFree(), collect keys to remove, then remove after iteration. Never modify a dictionary while iterating. - Extract from PackedScene: Instantiate, extract mesh,
QueueFree()the temp instance. The mesh survives because it's a Resource, not a Node. - UI rebuild:
QueueFree()all children, build new content. Safe becauseQueueFreeis deferred — new children added in same frame before old ones freed.
What runs every frame (and what doesn't)
_Process() is strictly limited:
- Vegetation grid: camera chunk check (0.5s throttle, early-exit if same chunk)
- Terrain manager: poll async texture loads
- Crowd renderer: lerp 2,000 positions (math-only, pre-allocated arrays)
- Day/night: rotate sun
- Camera: follow + zoom
- Sim bridge: drain WebSocket message queue
No heap allocation in any of these. Per-frame overhead is dominated by the crowd lerp and message queue drain.
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.
Target: RTX 3060, 8GB VRAM
- Main island + full vegetation < 4GB VRAM → ship it
- Approaching 6-8GB → implement lazy terrain nodes + cache eviction
- Exceeding 8GB → implement vegetation LOD and region-level streaming
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, a survival colony sim, with these systems. The tools we use — git hosting, AI agents, creative pipelines — are part of Tinqs Studio.