DRAFT — NOT PUBLISHED. This page is noindex, nofollow and is not in the sitemap. Before publishing: fill the measurement block below, set the byline, decide “I” versus “we”, then remove this notice and the robots tag.

← ALL POSTS

Vertex animation textures in Unity: a whole crowd in one draw call

Skinning a few hundred characters on the CPU is where a horde game stalls. Here is how that work moves to the GPU, and what it costs to put it there.

You have a few hundred enemies on screen and they all need to walk. You put a SkinnedMeshRenderer on each one, press play, and the profiler fills up with mesh skinning before anything interesting has happened. The GPU is barely working. The main thread is doing all of it.

This is the wall every horde game hits. Skinning on the CPU costs you per character, per frame, forever, and each character is its own draw call on top. Nobody’s project falls over because of it, but you end up choosing between the number of enemies you wanted and the framerate you wanted.

Vertex animation textures move that work to the GPU. I use them in Kelomins, which is at the end of this post. Everything before it works on its own.

What a vertex animation texture is

A skinned mesh recomputes vertex positions every frame from bones and weights. Most of the time, for a looping walk cycle, those positions are the same on every playthrough of the loop. So compute them once, offline, and store them.

The store is a texture. One row per animation frame, one column per vertex, and the pixel holds that vertex’s position on that frame. At runtime the vertex shader looks up its own row and column and moves itself there. No bones, no weights, no CPU.

Baking it

The baker is an editor window. It instantiates the prefab, walks the clip one sample at a time, and calls BakeMesh to snapshot the deformed vertices.

int frameCount  = (int)(_clipToBake.length * _sampleRate);
int vertexCount = smr.sharedMesh.vertexCount;

// Width = vertex count, height = frame count.
Texture2D tex = new Texture2D(vertexCount, frameCount, TextureFormat.RGBAFloat, false);
tex.filterMode = FilterMode.Point;
tex.wrapMode   = TextureWrapMode.Clamp;

for (int f = 0; f < frameCount; f++)
{
    _clipToBake.SampleAnimation(instance, f * timeStep);
    smr.BakeMesh(tempMesh);

    Vector3[] verts = tempMesh.vertices;
    for (int v = 0; v < vertexCount; v++)
    {
        Vector3 pos = verts[v];
        tex.SetPixel(v, f, new Color(pos.x, pos.y, pos.z, 1.0f));
    }
}

Three details in there matter more than they look.

FilterMode.Point is not optional. Bilinear filtering would blend neighbouring vertices together, and vertex 400 has nothing to do with vertex 401. You want the exact texel or nothing.

RGBAFloat stores positions as raw local-space floats, so there is no normalisation step and no precision loss to reason about. It is also sixteen bytes per texel, which is where the file sizes below come from.

The sample rate is 30. That is the animation’s real framerate from now on, regardless of what the game runs at.

Reading it back

The vertex shader gets the vertex ID and the instance ID. It reads the row for the current frame and the column for the vertex.

float safeLen = max(AnimLen, 1.0);
float currentFrame = (_Time.y * data.animSpeed * 30.0f) + (data.animFrameOffset * safeLen);
currentFrame = fmod(currentFrame, safeLen);

float u       = (VertexID + 0.5) * texelSize.x;
float v_coord = (floor(currentFrame) + 0.5) * texelSize.y;

float4 animatedPos = SAMPLE_TEXTURE2D_LOD(AnimMap, AnimMapSS, float2(u, v_coord), 0);
OutPosition = mul(data.objectToWorld, float4(animatedPos.xyz, 1.0)).xyz;

The + 0.5 on both axes lands the sample in the centre of the texel instead of on its edge, where rounding could pick the neighbour. Positions come out of the texture in object space, so the instance matrix still has to put them in the world.

The line that makes it look like a crowd

This one:

float currentFrame = (_Time.y * data.animSpeed * 30.0f) + (data.animFrameOffset * safeLen);

animFrameOffset is per instance. Without it, every creature reads the same row of the same texture at the same moment, and two hundred of them step in perfect unison like a chorus line. One float per instance, randomised on spawn, and the same two hundred creatures become a crowd.

It is the cheapest thing in the whole system and the first thing anyone notices.

Drawing them

Per-instance data goes in a structured buffer that the shader reads directly:

struct InstanceData
{
    float4x4 objectToWorld;
    float animFrameOffset;
    float animSpeed;
    float scaleMultiplier;
    float2 _pad;
    float4 statusTint;
};

StructuredBuffer<InstanceData> _InstanceBuffer;

The whole crowd then goes out through DrawMeshInstancedIndirect, one call per submesh rather than one per creature:

_buffer.SetData(_data, 0, 0, activeCount);
Graphics.DrawMeshInstancedIndirect(_mesh, s, _materials[s], b, _argsBuffers[s]);

A creature with one material is one draw call no matter how many of it are alive. A creature with three materials is three.

There is a small confession in that struct. _pad exists to keep the stride at a multiple of sixteen bytes, and padding is dead weight, so the hit-flash lives in it now: _pad.x is the flash amount and _pad.y picks the flash colour. Two floats that were already being paid for.

What it costs

This is the part these posts usually skip.

Memory. The baked textures for the current roster come to about 141 MB. Individually they run from 0.7 MB to 5.8 MB per creature per animation, and the multi-part atlases are 32 MB each. RGBAFloat is generous, and RGBAHalf would halve it. I have not done that yet.

One clip per texture. A walk cycle is one bake. Idle is another. Every animation you want is another texture, and the memory is linear in animations, not in creatures on screen.

No blending between frames. floor(currentFrame) snaps to the nearest baked row, so animation plays at a hard 30 steps per second. On small creatures at gameplay distance this reads as fine. On anything large or close it would not, and blending two rows would cost a second sample.

Normals do not deform. The bake stores positions only. The shader passes the bind-pose normal through and rotates it by the instance matrix, so lighting does not follow the deformation. I get away with this because the art is toon-shaded and the lighting is flat by design. A realistic renderer would show it immediately.

Bounds are not computed. The batch hands the renderer a fixed 1000-unit box rather than working out where the crowd actually is, which means no per-frame bounds pass and no culling from it either.

A bake step in the pipeline. Change an animation and the texture is stale until somebody rebakes it. That is a person remembering to do a thing, which is the least reliable part of any pipeline.

Is it worth it

For a game whose whole premise is a screen full of enemies, yes. The cost moved from the main thread, where it was competing with gameplay, to the GPU, where there was room.

MEASURE BEFORE PUBLISHING — replace this block with real profiler numbers: enemies on screen before and after, draw calls before and after, main-thread ms before and after, and the build / VRAM delta. If a number was not measured, say so rather than estimating it.

Why I’m building Kelomins

I wanted a survivors-like where the screen gets genuinely crowded, and the crowd is the reason any of the above exists.

Kelomins is a survivors-like about outlasting the forest. You survive waves that keep getting harder, upgrade your abilities between them, and collect trinkets that change how you fight.

It is not out yet. If you want to know when it is, there is a signup on the front page and I’ll send one email when you can play it. Nothing else.

In the meantime, everything above works on its own, in any Unity project, with no part of my game attached.

Common questions

What is a vertex animation texture?

A texture that stores the position of every vertex on every frame of an animation. One column per vertex, one row per frame. The vertex shader reads its own position out of it instead of computing it from bones.

Is VAT better than GPU skinning?

It is cheaper and less flexible. Compute-shader skinning keeps bones, so you keep blending, retargeting and procedural adjustment. VAT throws all of that away and buys a texture lookup. For a fixed loop on a small creature that is a good trade. For a player character it usually is not.

Do vertex animation textures work with normal maps and lighting?

Only if you bake normals too, in a second texture. If you bake positions alone, lighting will not follow the deformation. Flat or toon shading hides this. Most other setups will not.

How big do the textures get?

Width is your vertex count and height is your frame count, so cost is vertices times frames times the bytes per texel. At RGBAFloat that is sixteen bytes, and a mid-size creature’s walk cycle lands in the low megabytes. Half-precision halves it.

Can you blend between two animations with VAT?

Not by default, because there are no bones left to blend. You can sample two textures and interpolate the results, which costs a second lookup and only looks right for animations that are already close. Snapping between them is the honest cheap option.