368 lines
18 KiB
HTML
368 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
||
<title>Zero-CPU Crowd Animation: 1,000 Animals, One Draw Call, No Skeletons — Tinqs Blog</title>
|
||
<meta name="description" content="We built a GPU-driven crowd animation platform into Tinqs Engine that renders 1,000 animated animals at 60 FPS with zero per-frame CPU cost. Each agent plays its own clip, speed, and phase — no live skeletons, no lockstep, no compromises.">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="canonical" href="https://www.tinqs.com/blog/gpu-driven-crowd-animation">
|
||
|
||
<meta property="og:type" content="article">
|
||
<meta property="og:url" content="https://www.tinqs.com/blog/gpu-driven-crowd-animation">
|
||
<meta property="og:title" content="Zero-CPU Crowd Animation: 1,000 Animals, One Draw Call, No Skeletons">
|
||
<meta property="og:description" content="1,000 animated agents, zero live skeletons, zero per-frame CPU. A GPU-driven crowd animation platform in the Tinqs Engine fork of Godot.">
|
||
<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="Zero-CPU Crowd Animation: 1,000 Animals, One Draw Call, No Skeletons">
|
||
<meta name="twitter:description" content="1,000 animated agents, zero live skeletons, zero per-frame CPU. A GPU-driven crowd animation platform in the Tinqs Engine fork of Godot.">
|
||
<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": "Zero-CPU Crowd Animation: 1,000 Animals, One Draw Call, No Skeletons",
|
||
"datePublished": "2026-06-15",
|
||
"author": {
|
||
"@type": "Person",
|
||
"name": "Ozan Bozkurt"
|
||
},
|
||
"publisher": {
|
||
"@type": "Organization",
|
||
"name": "Tinqs Limited",
|
||
"url": "https://www.tinqs.com"
|
||
},
|
||
"description": "We built a GPU-driven crowd animation platform into Tinqs Engine that renders 1,000 animated animals at 60 FPS with zero per-frame CPU cost. Each agent plays its own clip, speed, and phase — no live skeletons, no lockstep, no compromises."
|
||
}
|
||
</script>
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
/* ── Tinqs Studio brand — post styles ── */
|
||
|
||
:root {
|
||
/* Studio near-black base */
|
||
--c-bg: #0B0C0E;
|
||
--c-bg-raised: #15171A;
|
||
/* Foreground */
|
||
--c-fg: #ECEEF1;
|
||
--c-muted: #8A95A3;
|
||
/* Family accents */
|
||
--c-lime: #B6FF3C;
|
||
--c-violet: #7C5CFF;
|
||
/* Borders */
|
||
--c-border: rgba(255,255,255,.07);
|
||
--c-border-strong: rgba(255,255,255,.12);
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
|
||
html { background: var(--c-bg); }
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
background: var(--c-bg);
|
||
color: var(--c-fg);
|
||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||
font-size: 16px;
|
||
line-height: 1.6;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ── Post container ── */
|
||
.post {
|
||
background: var(--c-bg);
|
||
max-width: 720px;
|
||
margin: 0 auto;
|
||
padding: 48px 24px 60px;
|
||
}
|
||
|
||
/* ── Back link ── */
|
||
.post__back {
|
||
color: var(--c-muted);
|
||
text-decoration: none;
|
||
font-size: 0.875rem;
|
||
display: inline-block;
|
||
margin-bottom: 24px;
|
||
transition: color 0.15s;
|
||
}
|
||
.post__back:hover { color: var(--c-lime); }
|
||
|
||
/* ── Gradient title — lime → violet ── */
|
||
.post__title {
|
||
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
|
||
background: linear-gradient(90deg, var(--c-lime), var(--c-violet));
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
font-weight: 700;
|
||
font-size: 2.2rem;
|
||
line-height: 1.2;
|
||
margin: 0 0 16px;
|
||
}
|
||
|
||
/* ── Date pill ── */
|
||
.post__date {
|
||
display: inline-block;
|
||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--c-muted);
|
||
border: 1px solid var(--c-border);
|
||
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-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
|
||
font-weight: 600;
|
||
font-size: 1.6rem;
|
||
margin: 54px 0 6px;
|
||
padding-left: 16px;
|
||
border-left: 4px solid var(--c-lime);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.post__body h3 {
|
||
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
|
||
font-weight: 500;
|
||
color: var(--c-violet);
|
||
font-size: 1.15rem;
|
||
margin: 30px 0 4px;
|
||
}
|
||
|
||
.post__body h4, .post__body h5, .post__body h6 {
|
||
margin: 20px 0 4px;
|
||
}
|
||
|
||
/* ── Inline code ── */
|
||
.post__body code {
|
||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
||
font-size: 0.84em;
|
||
background: var(--c-bg-raised);
|
||
color: var(--c-lime);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--c-border);
|
||
}
|
||
|
||
/* ── Code blocks ── */
|
||
.post__body pre {
|
||
background: var(--c-bg);
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 8px;
|
||
padding: 16px 18px;
|
||
overflow-x: auto;
|
||
margin: 14px 0;
|
||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
||
font-size: 0.83rem;
|
||
line-height: 1.55;
|
||
color: var(--c-fg);
|
||
}
|
||
|
||
.post__body pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
border: none;
|
||
font-size: inherit;
|
||
color: inherit;
|
||
border-radius: 0;
|
||
}
|
||
|
||
/* ── Blockquote ── */
|
||
.post__body blockquote {
|
||
background: rgba(124, 92, 255, 0.06);
|
||
border: 1px solid rgba(124, 92, 255, 0.15);
|
||
border-left: 4px solid var(--c-violet);
|
||
border-radius: 0 8px 8px 0;
|
||
padding: 16px 18px;
|
||
margin: 18px 0;
|
||
color: var(--c-fg);
|
||
font-size: 0.94rem;
|
||
}
|
||
|
||
/* ── Links ── */
|
||
.post__body a { color: var(--c-lime); text-decoration: underline; text-underline-offset: 3px; }
|
||
.post__body a:hover { color: var(--c-violet); }
|
||
|
||
/* ── Strong ── */
|
||
.post__body strong { color: var(--c-lime); font-weight: 600; }
|
||
|
||
/* ── 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-violet);
|
||
color: #fff;
|
||
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-fg);
|
||
font-weight: 600;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- POST -->
|
||
<article class="post">
|
||
<a href="/blog/" class="post__back">← All Posts</a>
|
||
<span class="post__date">15 June 2026</span>
|
||
<h1 class="post__title">Zero-CPU Crowd Animation: 1,000 Animals, One Draw Call, No Skeletons</h1>
|
||
<p class="post__lead">Godot gives you one <code>Skeleton3D</code> per character. Want 200 animated animals? That's 200 skeleton nodes, 200 draw calls, and 200 <code>AnimationPlayer</code> ticks every frame. Want 1,000? You're measuring in seconds per frame.</p>
|
||
|
||
<div class="post__body">
|
||
<p>We built a GPU-driven crowd animation platform into Tinqs Engine that doesn't use skeletons at all. It bakes every animation frame into a bone-matrix palette texture once, and the GPU drives every instance's playback from then on. 1,000 animals at 60 FPS on integrated graphics. Each plays its own clip at its own speed and phase. Zero per-frame CPU cost. This is how AAA engines do crowds — and now it runs in our Godot fork.</p>
|
||
<h2>Why not skeletons?</h2>
|
||
<p>The standard approach — one skeleton per character, one <code>AnimationPlayer</code>, one draw call — breaks at crowd scale. Computing <code>global_pose</code> for 1,000 skeletons at 60 bones each is 60,000 matrix multiplications per frame on the main thread. Each is its own draw call. Each <code>AnimationPlayer</code> ticks independently. No CPU can keep up.</p>
|
||
<p>Vertex animation textures (VAT) can solve this — bake every vertex position into a texture and sample it in the shader. But that stores <strong>vertices × frames</strong>, not bones × frames. A 2,500-vertex animal with 500 animation frames needs 14 MB of VAT data. For 30 animal types: 426 MB. That doesn't fit on a Steam Deck. And VAT can't blend frames for smooth playback, can't skin normals for correct lighting, and locks you into one animation per bake.</p>
|
||
<p>Our answer: <strong>bone-matrix palette.</strong> Bake every bone pose into a texture, keep the skinning in the shader. The GPU samples the bone matrices and skins the mesh itself — same 4-bone linear blend as a real skeleton, same correct normals and tangents. But the CPU never touches a bone.</p>
|
||
<h2>How it works</h2>
|
||
<p>At load time, we play every animation clip on a temporary skeleton and record the bone matrices for every frame into a single texture. A Goat with 9 clips at 30 fps produces 496 frames:</p>
|
||
<pre><code>Texture: 212 × 496 pixels, RGBA32F
|
||
VRAM: 212 × 496 × 16 bytes = 1.6 MB</code></pre>
|
||
<p>That's every frame of every clip — walk, run, idle, attack, death, eat, sleep — in 1.6 MB. Across 30 animal types: 48 MB total. Compare to VAT at 426 MB. Bone-matrix is 9× smaller because bones ≪ vertices.</p>
|
||
<p>After the bake, the skeleton is destroyed. It never runs again.</p>
|
||
<p>Each MultiMesh instance gets 4 numbers packed into <code>INSTANCE_CUSTOM</code>:</p>
|
||
<pre><code>.x = which clip (start row in the palette)
|
||
.y = how many frames in this clip
|
||
.z = playback rate (baked-fps × ground speed — foot-sync)
|
||
.w = phase offset (golden-ratio spread — no two adjacent animals share the same frame)</code></pre>
|
||
<p>The vertex shader computes each instance's current frame from TIME:</p>
|
||
<pre><code class="language-glsl">float fpos = mod(TIME * INSTANCE_CUSTOM.z + INSTANCE_CUSTOM.w * INSTANCE_CUSTOM.y,
|
||
INSTANCE_CUSTOM.y);
|
||
int f0 = int(fpos);
|
||
int f1 = int(mod(float(f0) + 1.0, INSTANCE_CUSTOM.y));
|
||
float fr = fpos - float(f0);
|
||
|
||
// Blend between two adjacent frames for smooth playback
|
||
int r0 = int(INSTANCE_CUSTOM.x + 0.5) + f0;
|
||
int r1 = int(INSTANCE_CUSTOM.x + 0.5) + f1;
|
||
|
||
// For each bone, reconstruct mat4 from 4 texels, blend, weight by skin influence
|
||
mat4 m0 = mat4(texelFetch(tex, ivec2(b*4+0, r0), 0), /* ... 3 more columns */);
|
||
mat4 m1 = mat4(texelFetch(tex, ivec2(b*4+1, r1), 0), /* ... */);
|
||
skin += (m0 * (1.0 - fr) + m1 * fr) * weight;</code></pre>
|
||
<p>The blend between two adjacent frames means we can bake at a low fps and stay smooth — the shader interpolates. The golden-ratio phase spread means every animal in a herd reads a different frame. One draw call per animal type. Zero CPU. Per-instance clip, speed, and phase — all in the GPU.</p>
|
||
<h2>The numbers</h2>
|
||
<p>Measured on an M1 Pro MacBook Pro (integrated GPU), not a desktop gaming rig:</p>
|
||
<p>| Agent count | FPS |</p>
|
||
<p>|————|—–|</p>
|
||
<p>| 100 | <strong>60</strong> |</p>
|
||
<p>| 500 | <strong>60</strong> |</p>
|
||
<p>| 1,000 | <strong>60</strong> |</p>
|
||
<p>| 10,000 | 8 (with CPU-side culling, pre-optimization) |</p>
|
||
<p><strong>VRAM:</strong> 1.6 MB per animal type. 30 types = 48 MB total. A Steam Deck with 1 GB shared memory fits the entire roster with room for colonists, terrain, vegetation, and UI.</p>
|
||
<p><strong>Draw calls:</strong> One per animal type. 30 types = 30 draw calls for every animated animal on screen. Add colonists, same deal — one draw call per colonist look.</p>
|
||
<h2>The engine change</h2>
|
||
<p>The module lives in <code>modules/agent_skinned/</code> inside Tinqs Engine — our fork of Godot 4.6. The core is two classes:</p>
|
||
<p><strong><code>MultiSkinnedMeshInstance3D</code></strong> — the data plane. Holds the bone-matrix palette. API: <code>set_max_bones()</code>, <code>set_max_instances()</code>, <code>set_instance_pose_bones()</code>. At bake time, we fill one row per animation frame. At render time, it sits idle — the texture is static.</p>
|
||
<p><strong><code>MultiSkinnedInstance3D</code></strong> — the renderer. A <code>MultiMeshInstance3D</code> subclass. Points its multimesh at the skinned mesh and its <code>data_source_path</code> at the data plane. <code>refresh()</code> uploads the bone texture into the shader's uniform once. The MultiMesh handles instance transforms. The shader handles the rest.</p>
|
||
<p>The shader uses <code>INSTANCE_CUSTOM</code> to pick the palette row — not <code>INSTANCE_ID</code>. This is the key: the texture's rows are baked animation frames, not per-instance slots. Many instances share the same rows (a synchronized airborne flock) or each pick their own (a varied herd). One abstraction, two behaviors.</p>
|
||
<p>The engine change is 40 lines of shader code in <code>multi_skinned_instance_3d.cpp</code>. Engine version: <strong>4.6.5.</strong></p>
|
||
<h2>The production pipeline</h2>
|
||
<p>In Ariki, <code>AnimalHerdRenderer.cs</code> groups sim <code>ViewerState.animals</code> by type, feeds world positions and yaw rotations to <code>skinned_herd.gd</code> — the reusable per-type herd backend. The herd bakes the palette once at setup, then <code>set_positions()</code> updates transforms each sim tick. <code>set_clip_for_state()</code> switches the active clip block in the custom data when the sim FSM changes state (idle → walk → flee → attack). <code>set_speed_scale()</code> adjusts the per-instance playback rate to match ground speed — feet stay planted.</p>
|
||
<p>Bird flocks use the same system. <code>BirdFlock.cs</code> runs boid flocking on top of <code>skinned_herd</code>, sharing the palette with synchronized phases (airborne flapping in unison is intentional). 25 bird species migrated from the Low Poly Bird Ultimate Pack, each a single draw call.</p>
|
||
<p>The sim owns all behavior — 30 data-driven animals with per-animal senses, diet, combat stats, and FSM states. The client just renders. The same system will drive thousands of colonists at launch.</p>
|
||
<h2>Where we stand vs the industry</h2>
|
||
<p>The bone-matrix palette technique is the same architecture used by Assassin's Creed Unity, Total War: Warhammer, and Hitman for their crowd systems. We're using the same core idea, in a Godot fork, with smaller VRAM (our low-poly animals keep textures tiny).</p>
|
||
<p>The platform supports three tiers by distance:</p>
|
||
<ul>
|
||
<li><strong>Crowd tier (palette)</strong> — baked poses, GPU-driven, zero CPU. Thousands of agents.</li>
|
||
<li><strong>Hero tier (real rigs)</strong> — <code>AnimationTree</code> + <code>SkeletonIK3D</code> + <code>PhysicalBone3D</code> for the nearest few. Smooth gait blends, foot-lock, look-at, ragdoll.</li>
|
||
<li><strong>Impostor tier (2D billboards)</strong> — sprite atlas indexed by view-angle and animation-frame, driven by the same <code>(clip, frame, speed, phase)</code> packet. For very far agents.</li>
|
||
</ul>
|
||
<p>The same abstraction — <code>(clip, count, speed, phase)</code> — drives every tier. One packet, three detail levels.</p>
|
||
<h2>Get the build</h2>
|
||
<p>Pre-built editor binaries with <code>agent_skinned</code> and the GPU-driven palette baked in:</p>
|
||
<p>| Platform | Binary |</p>
|
||
<p>|———-|——–|</p>
|
||
<p>| <strong>macOS ARM64</strong> | <a href="https://tinqs.com/tinqs/builds/media/branch/main/engine/macos-arm64/tinqs.macos.editor.arm64.mono" style="color: var(--c-lime);"><code>tinqs.macos.editor.arm64.mono</code></a> |</p>
|
||
<p>| <strong>Windows x64</strong> | <a href="https://tinqs.com/tinqs/builds/media/branch/main/engine/windows-x64/tinqs.windows.editor.x86_64.mono.exe" style="color: var(--c-lime);"><code>tinqs.windows.editor.x86_64.mono.exe</code></a> |</p>
|
||
<p>All builds at <a href="https://tinqs.com/tinqs/builds" style="color: var(--c-lime);"><code>tinqs/builds</code></a>. Engine source at <a href="https://tinqs.com/tinqs/engine" style="color: var(--c-lime);"><code>tinqs/engine</code></a> (private).</p>
|
||
<p>The game's <code>animal_perf_test.tscn</code> spawns 10/100/1,000/10,000 animals and reports live FPS. The <code>animal_viewer.tscn</code> lets you inspect any animal type, toggle clips, and switch between single and herd mode.</p>
|
||
<hr>
|
||
<p><strong>Related:</strong> <a href="gpu-skinned-herds" style="color: var(--c-lime);">GPU-Skinned Herds</a> — the original <code>agent_skinned</code> module design. <a href="fork-dont-build" style="color: var(--c-lime);">Fork, Don't Build</a> — why we modify existing platforms. <a href="godot-optimisation" style="color: var(--c-lime);">Streaming a 12km Archipelago in Godot 4</a> — the terrain and vegetation layers.</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>
|