337 lines
15 KiB
HTML
337 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
||
<title>Streaming a 12km Archipelago in Godot 4 — Tinqs Blog</title>
|
||
<meta name="description" content="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.">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="canonical" href="https://www.tinqs.com/blog/godot-optimisation">
|
||
|
||
<meta property="og:type" content="article">
|
||
<meta property="og:url" content="https://www.tinqs.com/blog/godot-optimisation">
|
||
<meta property="og:title" content="Streaming a 12km Archipelago in Godot 4">
|
||
<meta property="og:description" content="Four streaming layers, async loading, and zero memory leaks — running a 12km open world in Godot 4.">
|
||
<meta property="og:image" content="https://www.tinqs.com/img/og-cover.jpg">
|
||
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="Streaming a 12km Archipelago in Godot 4">
|
||
<meta name="twitter:description" content="Four streaming layers, async loading, and zero memory leaks — running a 12km open world in Godot 4.">
|
||
<meta name="twitter:image" content="https://www.tinqs.com/img/og-cover.jpg">
|
||
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "BlogPosting",
|
||
"headline": "Streaming a 12km Archipelago in Godot 4",
|
||
"datePublished": "2026-05-22",
|
||
"author": {
|
||
"@type": "Person",
|
||
"name": "Ozan Bozkurt"
|
||
},
|
||
"publisher": {
|
||
"@type": "Organization",
|
||
"name": "Tinqs Limited",
|
||
"url": "https://www.tinqs.com"
|
||
},
|
||
"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."
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
/* ── Self-contained post styles (Studio provides site chrome) ── */
|
||
|
||
:root {
|
||
--c-accent: #c9935a;
|
||
--c-accent-l: #d4a87c;
|
||
--c-bg: #0d1117;
|
||
--c-text: #e6edf3;
|
||
--c-muted: #9aa7b4;
|
||
--c-border: #2a3340;
|
||
--c-blue: #38bdf8;
|
||
--c-purple: #a855f7;
|
||
--c-gold: #f59e0b;
|
||
--c-code-bg: #1c2230;
|
||
--c-pre-bg: #0a0e14;
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: var(--c-text);
|
||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||
line-height: 1.6;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ── Post container ── */
|
||
.post {
|
||
max-width: 720px;
|
||
margin: 0 auto;
|
||
padding: 40px 24px 48px;
|
||
}
|
||
|
||
/* ── Back link ── */
|
||
.post__back {
|
||
color: var(--c-blue);
|
||
text-decoration: none;
|
||
font-size: 0.9rem;
|
||
display: inline-block;
|
||
margin-bottom: 24px;
|
||
}
|
||
.post__back:hover { color: var(--c-purple); }
|
||
|
||
/* ── Gradient title ── */
|
||
.post__title {
|
||
background: linear-gradient(90deg, #c9935a, #f59e0b 40%, #38bdf8);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
font-weight: 800;
|
||
font-size: 2.2rem;
|
||
line-height: 1.25;
|
||
margin: 0 0 16px;
|
||
}
|
||
|
||
/* ── Date pill ── */
|
||
.post__date {
|
||
display: inline-block;
|
||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--c-blue);
|
||
border: 1px solid rgba(147, 140, 129, 0.25);
|
||
border-radius: 999px;
|
||
padding: 4px 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* ── Lead ── */
|
||
.post__lead {
|
||
color: var(--c-muted);
|
||
font-size: 1.08rem;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
/* ── Body ── */
|
||
.post__body { font-size: 1rem; line-height: 1.7; }
|
||
|
||
.post__body p { margin: 14px 0; }
|
||
|
||
.post__body h2 {
|
||
font-size: 1.7rem;
|
||
margin: 54px 0 6px;
|
||
padding-left: 16px;
|
||
border-left: 4px solid var(--c-accent);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.post__body h3 {
|
||
color: var(--c-purple);
|
||
font-size: 1.18rem;
|
||
margin: 30px 0 4px;
|
||
}
|
||
|
||
.post__body h4, .post__body h5, .post__body h6 {
|
||
margin: 20px 0 4px;
|
||
}
|
||
|
||
/* ── Inline code ── */
|
||
.post__body code {
|
||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||
font-size: 0.86em;
|
||
background: var(--c-code-bg);
|
||
color: #9fe6c0;
|
||
padding: 2px 6px;
|
||
border-radius: 5px;
|
||
border: 1px solid var(--c-border);
|
||
}
|
||
|
||
/* ── Code blocks ── */
|
||
.post__body pre {
|
||
background: var(--c-pre-bg);
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 10px;
|
||
padding: 16px 18px;
|
||
overflow-x: auto;
|
||
margin: 14px 0;
|
||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||
font-size: 0.85rem;
|
||
line-height: 1.55;
|
||
color: var(--c-text);
|
||
}
|
||
|
||
.post__body pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
border: none;
|
||
font-size: inherit;
|
||
color: inherit;
|
||
border-radius: 0;
|
||
}
|
||
|
||
/* ── Blockquote ── */
|
||
.post__body blockquote {
|
||
background: rgba(245, 158, 11, 0.08);
|
||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||
border-left: 4px solid var(--c-gold);
|
||
border-radius: 0 12px 12px 0;
|
||
padding: 16px 18px;
|
||
margin: 18px 0;
|
||
color: #f4e3c4;
|
||
font-size: 0.94rem;
|
||
}
|
||
|
||
/* ── Links ── */
|
||
.post__body a { color: var(--c-blue); }
|
||
.post__body a:hover { color: var(--c-purple); }
|
||
|
||
/* ── Strong ── */
|
||
.post__body strong { color: var(--c-gold); }
|
||
|
||
/* ── HR ── */
|
||
.post__body hr {
|
||
border: none;
|
||
border-top: 1px solid var(--c-border);
|
||
margin: 32px 0;
|
||
}
|
||
|
||
/* ── Figures ── */
|
||
.post__body figure { margin: 20px 0; }
|
||
.post__body figure img {
|
||
max-width: 100%;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--c-border);
|
||
}
|
||
|
||
.post__body figcaption {
|
||
color: var(--c-muted);
|
||
font-size: 0.85rem;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* ── Lists ── */
|
||
.post__body ul, .post__body ol { padding-left: 1.5em; margin: 10px 0; }
|
||
.post__body li { margin: 4px 0; }
|
||
|
||
/* ── Author ── */
|
||
.post__author {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
margin-top: 48px;
|
||
padding-top: 24px;
|
||
border-top: 1px solid var(--c-border);
|
||
}
|
||
|
||
.post__author-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: var(--c-accent);
|
||
color: var(--c-bg);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
font-size: 0.85rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.post__author-info {
|
||
font-size: 0.85rem;
|
||
color: var(--c-muted);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.post__author-name {
|
||
color: var(--c-text);
|
||
font-weight: 600;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- POST -->
|
||
<article class="post">
|
||
<a href="/blog/" class="post__back">← All Posts</a>
|
||
<span class="post__date">22 May 2026</span>
|
||
<h1 class="post__title">Streaming a 12km Archipelago in Godot 4</h1>
|
||
<p class="post__lead">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.</p>
|
||
|
||
<div class="post__body">
|
||
<p>Here's how we built four streaming layers on top of Godot, all in C#, to make it work.</p>
|
||
<h2>The scale problem</h2>
|
||
<p>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.</p>
|
||
<p>We built four layers that teach Godot what to load, when to load it, and when to let it go.</p>
|
||
<h2>Layer 1: Terrain regions (lazy instantiation)</h2>
|
||
<p>We use <strong>Terrain3D</strong> 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.</p>
|
||
<p>The original code created all 9 terrain nodes in <code>_Ready()</code> 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.</p>
|
||
<h2>Layer 2: Vegetation chunks (128m grid)</h2>
|
||
<p>The main prop streaming system. Every island's vegetation is divided into a spatial grid of 128m × 128m chunks.</p>
|
||
<p>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 <strong>MultiMesh</strong> 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.</p>
|
||
<p>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.</p>
|
||
<h2>Layer 3: Async resource loading</h2>
|
||
<p>Godot's <code>GD.Load()</code> is synchronous. It blocks the main thread. During gameplay, the frame freezes.</p>
|
||
<p>We audited the entire codebase and found <strong>26 resource load calls across 13 files</strong> — only 1 was async. The worst offender was <code>GetMeshForProto()</code> 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.</p>
|
||
<p>Two fixes:</p>
|
||
<ul>
|
||
<li><strong>Pre-warm during loading screens.</strong> When an island is imported, kick off background loads for all known prototypes. By the time the player gains control, most meshes are cached.</li>
|
||
<li><strong>Async texture loading.</strong> Terrain textures use <code>ResourceLoader.LoadThreadedRequest()</code> with <code>_Process()</code> polling. The terrain renders immediately with autoshader colors; biome textures pop in when ready.</li>
|
||
</ul>
|
||
<p>The ResourceLoader trap: Godot maintains an internal resource cache. Every <code>GD.Load()</code> caches the result globally. If you load an FBX as a <code>PackedScene</code>, instantiate it to extract a mesh, then free the instance — the PackedScene <strong>stays cached</strong>. Rule: use <code>ResourceLoader.Load(path, "", CacheMode.Ignore)</code> for one-shot loads where you extract data and discard the container.</p>
|
||
<h2>Layer 4: Entity rendering (event-driven)</h2>
|
||
<p>Dynamic entities — colonists, animals, buildings, VFX — update when the simulation pushes new state, not per frame.</p>
|
||
<ul>
|
||
<li><strong>Crowd rendering:</strong> Single MultiMesh for up to 2,000 colonists. Positions lerped per frame from pre-allocated arrays. Labels distance-culled, capped at 20.</li>
|
||
<li><strong>Animals:</strong> One MultiMesh per type. Max 500 per type. Updates only on state change.</li>
|
||
<li><strong>Buildings:</strong> Tracked by ID from sim state. <code>QueueFree</code> when removed.</li>
|
||
<li><strong>VFX:</strong> Capped at 50 active particle systems. Worst case: 10,000 GPU particles.</li>
|
||
</ul>
|
||
<h2>Memory safety: the QueueFree audit</h2>
|
||
<p>We audited every <code>QueueFree()</code> call — 47 calls across 17 files. <strong>Zero <code>RemoveChild()</code> calls without a corresponding <code>QueueFree()</code>.</strong> Three patterns we follow everywhere:</p>
|
||
<p>1. <strong>Chunk streaming:</strong> Iterate active dict, call <code>QueueFree()</code>, collect keys to remove, then remove after iteration. Never modify a dictionary while iterating.</p>
|
||
<p>2. <strong>Extract from PackedScene:</strong> Instantiate, extract mesh, <code>QueueFree()</code> the temp instance. The mesh survives because it's a Resource, not a Node.</p>
|
||
<p>3. <strong>UI rebuild:</strong> <code>QueueFree()</code> all children, build new content. Safe because <code>QueueFree</code> is deferred — new children added in same frame before old ones freed.</p>
|
||
<h2>What runs every frame (and what doesn't)</h2>
|
||
<p><code>_Process()</code> is strictly limited:</p>
|
||
<ul>
|
||
<li>Vegetation grid: camera chunk check (0.5s throttle, early-exit if same chunk)</li>
|
||
<li>Terrain manager: poll async texture loads</li>
|
||
<li>Crowd renderer: lerp 2,000 positions (math-only, pre-allocated arrays)</li>
|
||
<li>Day/night: rotate sun</li>
|
||
<li>Camera: follow + zoom</li>
|
||
<li>Sim bridge: drain WebSocket message queue</li>
|
||
</ul>
|
||
<p><strong>No heap allocation in any of these.</strong> Per-frame overhead is dominated by the crowd lerp and message queue drain.</p>
|
||
<p>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.</p>
|
||
<h2>Target: RTX 3060, 8GB VRAM</h2>
|
||
<ul>
|
||
<li>Main island + full vegetation < 4GB VRAM → ship it</li>
|
||
<li>Approaching 6-8GB → implement lazy terrain nodes + cache eviction</li>
|
||
<li>Exceeding 8GB → implement vegetation LOD and region-level streaming</li>
|
||
</ul>
|
||
<p><strong>Always measure before optimizing.</strong> 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.</p>
|
||
<hr>
|
||
<p>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, <code>LoadThreadedRequest</code>, <code>QueueFree</code>. It's up to you to wire them into a system that scales.</p>
|
||
<p><em>We're building <a href="https://arikigame.com" style="color: var(–c-accent-l);">Ariki</a>, a survival colony sim, with these systems. The tools we use — git hosting, AI agents, creative pipelines — are part of <a href="https://tinqs.com" style="color: var(–c-accent-l);">Tinqs Studio</a>.</em></p>
|
||
|
||
</div>
|
||
|
||
<div class="post__author">
|
||
<div class="post__author-avatar">OB</div>
|
||
<div class="post__author-info">
|
||
<span class="post__author-name">Ozan Bozkurt</span><br>
|
||
CTO & Developer, Tinqs
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
</body>
|
||
</html>
|