title: "GPU-Skinned Herds: One Draw Call for 1,000 Animated Characters in Godot"
slug: gpu-skinned-herds
date: "2026-06-14"
description: "Godot has no built-in way to render 1,000 skinned characters in one draw call. We built a GPU skinned-instance renderer into Tinqs Engine that does — 25 crocodiles verified, 1,000+ projected. Pre-built binaries for macOS and Windows."
og_description: "One draw call, 1,000 animated characters. GPU-skinned herd renderer built into the Tinqs Engine fork of Godot."
excerpt: "Godot can't batch-render 1,000 animated characters. We built a GPU skinned-instance herd renderer into the engine itself — already driving crocodile herds in Ariki. Pre-built editor binaries for macOS and Windows."
author: "Ozan Bozkurt"
author_initials: "OB"
author_role: "CTO & Developer, Tinqs"
---
Godot gives you one `Skeleton3D` per character. Want 200 animals in a herd? That's 200 skeleton nodes, 200 draw calls, and 200 `AnimationPlayer` ticks every frame. Want 1,000? Now you're measuring in seconds per frame, not frames per second.
We built a GPU skinned-instance renderer into Tinqs Engine that packs every pose into a single texture, uploads once, and draws every instance in one call. 25 crocodiles confirmed first. Then we threw 1,000 animals — 12 types mixed, random-walking — at it and the GPU didn't flinch. Same bone count, same animation fidelity, a tiny fraction of the cost.
The standard Godot approach — one `Skeleton3D` + one `MeshInstance3D` per character — works for a handful of animated entities. It breaks down hard at crowd scale:
- **CPU bone transforms.** Computing `global_pose` for 200 skeletons × 100 bones each = 20,000 matrix multiplies per frame, all on the main thread.
- **Draw call explosion.** Each `MeshInstance3D` is its own draw call. Even with MultiMesh, there's no built-in path for skinned meshes — `MultiMeshInstance3D` only handles static geometry.
- **AnimationPlayer sprawl.** Each skeleton needs its own `AnimationPlayer` and its own `process()` tick.
The alternative — baking animations to vertex textures — works for static crowds but locks you out of per-instance variation. No blending, no phase offsets, no reactive behaviour.
What we need is simpler: **share the skeleton, drive per-instance poses from a single animation, batch the draw call.** That's what `agent_skinned` does.
## How it works: two classes, one texture
The module lives in `modules/agent_skinned/` inside [Tinqs Engine](https://tinqs.com/tinqs/engine). Two classes, one job:
### `MultiSkinnedMeshInstance3D` — the data plane
Holds the CPU-side bone matrices. Allocates an `ImageTexture` of size `[4 × max_bones, max_instances]` in RGBA32F — each texel is one column of a 4×4 bone matrix. For a 130-bone crocodile with 256 instances:
```
Texture: 520 × 256 RGBA32F ≈ 2 MB
```
That's the entire pose state for 256 animated crocodiles in a single GPU texture. The API is simple:
```gdscript
vardata:=MultiSkinnedMeshInstance3D.new()
data.set_mesh(crocodile_mesh)
data.set_skeleton(skeleton)# rest pose + bone hierarchy
data.set_max_instances(256)
data.set_max_bones(130)
# Each frame: push poses from the animated skeleton
data.update()# upload only dirty instances, not the whole texture
```
### `MultiSkinnedInstance3D` — the renderer
A `MultiMeshInstance3D` subclass. Set its multimesh with the skinned mesh and instance transforms, point it at the data plane, call `refresh()` — it uploads the bone texture into the shader material's `bone_matrices_tex` uniform and the mesh is drawn in one call.
The shader does 4-bone linear-blend skinning on the GPU:
`INSTANCE_ID` is a Godot built-in — the GPU already knows which instance it's rendering. We just use it to index into the bone texture. No uniform arrays, no SSBOs, no compute shaders. Just a 2D texture and a custom vertex shader.
## Two bugs we shipped and fixed
The module had data-plane doctests from day one — round-trip pose get/set, dirty tracking, size clamping, AABB. All green. Then we put it on screen for the first time and the crocodiles looked... wrong.
**Bug 1: Shader compile failure.** The default skinning shader compared `TANGENT` as `vec4`. Godot 4 exposes it as `vec3`. Fixed in one line, added `albedo_tex` uniform so herds texture out of the box.
**Bug 2: Bone matrices stored transposed.** The data plane wrote basis rows (standard Godot `Transform3D.basis` is row-major), but the shader unpacked as columns. Every bone matrix was transposed — the mesh crumpled. Not a scale bug, not an orientation bug — a layout mismatch. Fixed by storing column-major, with a doctest to prevent regression.
The lesson: doctests catch logic. Rendering catches truth. You need both.
## What's driving it
In [Ariki](https://www.arikigame.com), the sim tracks animal migration across a 12km archipelago. `AnimalHerdRenderer.cs` groups sim `ViewerState.animals` by type, feeds positions to `skinned_herd.gd` (a reusable per-type herd backend), which drives the renderer. One `AnimationPlayer` animates a single driver skeleton; poses propagate to every instance.
The crocodile herd scene was 25 instances, one draw call. The perf test scene does 1,000 animals across 12 types — Boar, Cow, Crab, Crocodile, Deer, Fish, Goat, Hen, Pig, Rabbit, Sheep, Tiger — each type its own GPU herd, all mixed, all random-walking, FPS holding steady.
Pre-built editor binaries with `agent_skinned` baked in — no engine compile required. The game's `animal_perf_test.tscn` lets you toggle 10 / 100 / 1000 animals and read live FPS:
All builds live in the public [`tinqs/builds`](https://tinqs.com/tinqs/builds) repo — engine source is private, but the binaries are yours. See [`manifest.json`](https://tinqs.com/tinqs/builds/src/branch/main/manifest.json) for checksums and build details.
The engine source lives in [`tinqs/engine`](https://tinqs.com/tinqs/engine) (private). Module docs: `modules/agent_skinned/README.md` and `.agents/wiki/agent-skinned-gpu-herd.md`.
---
**Related:** [Fork, Don't Build](fork-dont-build) — why we modify existing platforms instead of building new ones. [Streaming a 12km Archipelago in Godot 4](godot-optimisation) — the terrain and vegetation streaming layers that work alongside this.