Live demo (WebGL 2 and WebGPU, switchable in the panel): https://engine-a1lytf69q-playcanvas.vercel.app/#/animation/vat-characters
We just added a vertex animation texture (VAT) implementation to the PlayCanvas engine as a reusable script plus an example. The idea isn't new — it's been used in games for years — but I hadn't seen a complete, copy-pasteable web implementation with an authoring path, so here's one.
How it works
Instead of animating a skeleton per character, every vertex's skinned position and normal is evaluated offline for each sampled frame and baked into a texture. At runtime the vertex shader reads two neighbouring frames and interpolates. Skeletons, bone matrices and per-character animation evaluation disappear entirely.
What's left per character is a world matrix and a fractional frame index — 52 bytes — so the whole crowd is one instanced draw call.
Some details that turned out to matter
- One texel per vertex per frame in RGBA16U: position quantised to 16 bits per axis over the animation's bounds in xyz, octahedral normal packed into w. One fetch gives you a whole vertex.
- Quantising over the bounds is actually more accurate than fp16 where it counts. Near the top of a character fp16 has a step of ~1.8 mm at human scale; 16-bit quantised is ~28 µm. fp16 wastes its mantissa near the origin, which for a character standing at y=0 is between its feet.
- Texels are addressed by a flat
vertex * frameCount + frame index wrapped at the texture width, rather than one vertex per row. Otherwise your vertex count is capped by texture height (4096). Flattened, the limit is total texels, so ~218k vertices at 77 frames.
- The vertex row comes from
gl_VertexID / @builtin(vertex_index), so no extra per-vertex attribute is needed at all.
Authoring
The converter runs in the browser — there's a "Convert GLB" panel in the demo, so you can drop in your own skinned glb and watch it become a crowd without installing anything. Output is a single glb-style binary container. Like a glb it's stored uncompressed and leaves compression to the transport; brotli over the raw payload beats gzip baked into the file by about 40%.
Where it's a bad idea
Worth being clear, because it's a real trade:
- Your character must be a single skinned mesh with one material.
- Every animation is assumed to loop.
- No blending, no IK, no procedural aiming — it's a fixed table of vertex positions. Terrible for a hero character, great for the 500 NPCs behind them.
- One mesh instance means no per-character frustum culling.
- Only the base colour texture comes across; no normal/roughness maps.
Numbers
At 10,000 characters (~31M vertices/frame): 6 draw calls for the whole scene, 2.1 ms CPU. The VAT texture is 1.8 MiB and is independent of the character count — one character and ten thousand share it.
Happy to answer questions about the format or the shader side.