Module: Vertex Animation Texture (VAT)
Package paths:
packages/babylon-lite/src/vat/(baker + runtime),packages/babylon-lite/src/material/pbr/fragments/vat-fragment.ts(GPU vertex path)
Purpose
Pre-evaluates a skinned mesh's skeletal animation on the CPU and bakes every frame's bone matrices into ONE rgba32float texture (the Vertex Animation Texture), then renders the mesh by reading bone matrices from that texture at the current frame row — with no live CPU skeleton and no per-frame bone upload. Because the per-frame skeleton work is gone, a baked mesh can be GPU thin-instanced: each instance plays its own clip + phase (and can blend two clips for smooth cross-fades) from the one shared texture, so a whole crowd of animated meshes renders in a single draw call and casts CSM shadows.
Strictly opt-in and lazily imported: a scene that never bakes a VAT never loads this module, and the shared PBR renderable carries no VAT-specific code (the VAT extension self-registers when attachVat runs), so non-VAT scenes are byte-identical.
Mirrors the Babylon.js VertexAnimationBaker / BakedVertexAnimationManager API shape, adapted to Lite/WebGPU.
Public API Surface
Functions (vat/vat-baker.ts)
export function bakeVat(engine: EngineContext, mesh: Mesh, groups: AnimationGroup[], opts?: VatBakeOptions): VatBakeResult;export function bakeVatMany(engine: EngineContext, targets: readonly VatBakeTarget[], groups: readonly AnimationGroup[]): VatBakeResult[];export function prepareVat(mesh: Mesh, groups: AnimationGroup[], opts?: VatBakeOptions): PreparedVatBakeResult;export function prepareVatMany(targets: readonly VatBakeTarget[], groups: readonly AnimationGroup[]): PreparedVatBakeResult[];export function createVatBakeResult(engine: EngineContext, prepared: PreparedVatBakeResult): VatBakeResult;export function createVatBakeResults(engine: EngineContext, prepared: readonly PreparedVatBakeResult[]): VatBakeResult[];export function attachVat(engine: EngineContext, mesh: Mesh, baked: VatBakeResult, clip?: string): VatHandle;export function setVatInstanceStorage(engine: EngineContext, mesh: Mesh, buffer: StorageBuffer | null): void;export function setVatTime(engine: EngineContext, mesh: Mesh, seconds: number): void;bakeVat— evaluates each clip ingroupsframe-by-frame (the mesh must still have its livemesh.skeleton), stacking the bone matrices into onergba32floattexture: widthboneCount*4, height = total frame count, one animation frame per row. Returns the texture + a per-clip row map.prepareVat/prepareVatMany— perform the same CPU evaluation without a GPU device or texture allocation, producing persistableFloat32Arraypayloads for offline preparation.createVatBakeResult/createVatBakeResults— validate and upload prepared payloads. Byte-identical batch entries share one ref-counted texture; partial upload failures destroy every texture allocated by the call.attachVat— setsmesh.vat, NULLSmesh.skeleton(no more live skinning), builds the 32-byte settings UBO, self-registers the VATpbrExt, and returns aVatHandle.clipselects the initial clip (defaults to the first baked clip).
Types (vat/vat-baker.ts)
export interface VatClip { readonly fromRow: number; readonly frameCount: number; readonly fps: number;}
export interface VatBakeResult { readonly texture: GPUTexture; // rgba32float, (boneCount*4) x frameCount readonly boneCount: number; readonly frameCount: number; readonly clips: Record<string, VatClip>; // clip name -> row range readonly boneOrigins?: Record<number, Float32Array>; readonly boneMatrices?: Record<number, Float32Array>; /** @internal */ readonly _textureResource: { readonly texture: GPUTexture; _refCount?: number };}
export interface PreparedVatBakeResult { readonly boneCount: number; readonly frameCount: number; readonly data: Float32Array; readonly clips: Readonly<Record<string, VatClip>>; readonly boneOrigins?: Readonly<Record<number, Float32Array>>; readonly boneMatrices?: Readonly<Record<number, Float32Array>>;}
export interface VatHandle { readonly mesh: Mesh; readonly clips: Record<string, VatClip>; play(clip: string, opts?: { offset?: number; fps?: number }): void; // shared (non-instanced) playback update(dtSeconds: number): void; // advance the shared clock setInstances(params: Float32Array): void; // per-instance single clip (4 floats/inst) setInstancesBlend(params: Float32Array): void; // per-instance dual-clip blend (8 floats/inst)}Internal type (animation/types.ts, not exported — like SkeletonData)
export interface VatData { boneCount: number; texture: GPUTexture; // the baked bone texture frameCount: number; settingsBuffer: GPUBuffer; // 32-byte UBO: params vec4 + clock vec4 jointsBuffer: GPUBuffer; // reused from the (now-dropped) skeleton weightsBuffer: GPUBuffer; joints1Buffer: GPUBuffer | null; // 8-bone weights1Buffer: GPUBuffer | null; instanceTexture?: GPUTexture | null; // per-instance params ((2*instanceCount) x 1, two texels/instance)}Mesh feature bits (material/mesh-features.ts)
export const MSH_VAT = 1 << 9; // mesh.vat present — the ONLY VAT mesh-feature bit// "Instanced VAT" needs NO dedicated bit: it is derived in the fragment from// (MSH_VAT && MSH_HAS_THIN_INSTANCES)// so mesh-features.ts (a shared chunk fetched by every scene) is byte-identical for non-VAT scenes// (zero bundle movement). 8-bone uses the existing MSH_HAS_SKELETON_8.Internal Architecture
Baked texture format (identical to the live bone texture)
The per-row layout matches skeleton/create-skeleton.ts exactly — 4 texels per bone (one mat4 column each), so VAT(frame N) reproduces the live pose at frame N to full float precision (parity MAD ~= 0.000 vs the live skeleton):
- Dimensions:
[boneCount * 4, frameCount](width × height),rgba32float. - Row r = the bone matrices for animation frame
r(16 floats/bone = 4 texels). - Usage:
TEXTURE_BINDING | COPY_DST. Uploaded once viawriteTexture(bytesPerRow =boneCount*4*16).
Preparation and upload
bakeVat and bakeVatMany compose the two explicit phases below:
prepareVat/prepareVatManyevaluate animation entirely on the CPU and return the matrix rows plus clip/capture metadata. They do not allocate or upload GPU resources.createVatBakeResult/createVatBakeResultsvalidate prepared dimensions, clips, and optional capture tracks, then uploaddatawith its exact typed-array byte offset. ReturnedVatBakeResultobjects do not retain the CPUdatapayload.
This split lets build tools persist the prepared bytes and defer GPU upload to runtime without changing the texture layout consumed by attachVat.
Baking (bakeVat)
clipFrameCount(group) = round(group.duration * group.frameRate) + 1. Clips are laid out as contiguous row blocks (clip 0 first); clips[name] = { fromRow, frameCount, fps }. For each frame: goToFrame(group, f, engine) ticks the controller, then the frame's matrices are read from the SkeletonBinding (group._gltfMixer[2][0].boneMatrices) and copied into the row. stopAnimation(group) after each clip (VAT replaces live playback).
Settings UBO (32 bytes, attachVat)
params: vec4<f32> = (fromRow, toRow, frameOffset, fps) // shared/non-instanced playbackclock: vec4<f32> = (elapsedSeconds, _, _, _) // advanced by update()play(name, opts) writes params; update(dt) accumulates clock.x. Both writeBuffer the 32-byte UBO.
Per-instance params texture (setInstances / setInstancesBlend)
A separate rgba32float texture of texels × 1, read in the vertex shader by @builtin(instance_index). Chosen over a storage buffer / uniform array / vertex attribute so there is no new binding kind, no pipeline fragmentation, and zero touch to the shared instancing/draw path — it rides the existing VAT bind hook exactly like the bone texture.
setInstances(single clip):params.length = 4 * instanceCount=(fromRow, toRow, timeOffset, fps)per instance. Internally expanded to the dual-clip layout (clip B == A, blend 0), so there is ONE instanced shader variant.setInstancesBlend(dual clip):params.length = 8 * instanceCount— texel2i= A(fromRow, toRow, offset, fpsA), texel2i+1= B(fromRow, toRow, blend, fpsB)(B reuses A's offset).blendin[0,1]lerps A→B. The texture is always TWO texels per instance.
The texture grows in place (capacity in texels); the shared clock still drives the crowd while per-instance offsets stagger the phase.
Pipeline Configuration
The VAT pbrExt (vat-fragment.ts) is a vertex-phase PBR extension. It is self-registered by attachVat via _registerPbrExt (mirroring enableMaterialPlugins) — the shared pbr-renderable has no VAT import. pbrExt.frag(ctx) is gated on MSH_VAT and returns the fragment for the variant (has8Bones, instanced), where instanced = (ctx._meshFeatures & MSH_HAS_THIN_INSTANCES) (no dedicated VAT-instanced bit).
Group-1 vertex bindings (pushed by pbrExt.bind, in declaration order)
| binding | resource | when |
|---|---|---|
| b | vatSampler — texture_2d<f32> (unfilterable-float), vertex-visible | always (MSH_VAT) |
| b+1 | vat — uniform buffer (settings UBO) | always |
| b+2 | vatInstanceTex — texture_2d<f32> (unfilterable-float) | MSH_VAT && MSH_HAS_THIN_INSTANCES (instanceTexture set) |
Vertex attributes / builtins
- Reuses the skeleton vertex attributes:
joints/weights(uint32x4/float32x4), plusjoints1/weights1for 8-bone. - Instanced variants request
@builtin(instance_index)via_vertexBuiltins(only present in the instanced fragment, so non-instanced VAT is unchanged). - The instanced fragment declares
_dependencies: ["thin-instance"]so it composes AFTER the thin-instance fragment in the sharedVWvertex slot — otherwise thin-instance'sfinalWorldwrite would clobber the skinned+instanced transform. It reads the per-instance world matrix fromworld0..3(declared by the thin-instance fragment).
Shadow casting (free)
The CSM caster (shadow/csm-shadow-task-hooks.ts) renders each caster mesh through its OWN material renderable with a no-color view. That renderable composes the VAT ext from the mesh features, so a VAT (including instanced) mesh produces correct, per-pose depth in the shadow map with no shadow-pass changes.
Shader Logic (vertex VW slot, WGSL outline)
fn vatFrameRow(p: vec4f, t: f32) -> i32: // p = (fromRow, toRow, offset, fps) span = max(1, p.y - p.x + 1) raw = p.z + t * p.w return i32(p.x + (raw - floor(raw / span) * span)) // wrap into [fromRow, toRow]
fn readMatrixFromVat(smp, index, row) -> mat4x4f: // 4 texels at (index*4 + col, row)
// Variant A — non-instanced (shared UBO):vatRow = vatFrameRow(vat.params, vat.clock.x)influence = sum_k readMatrixFromVat(vatSampler, joints[k], vatRow) * weights[k] // 4 or 8 bonesfinalWorld = mesh.world * influence
// Variant B — instanced (ALWAYS the dual-clip path; single-clip is the blend==0 case):A = textureLoad(vatInstanceTex, (2*instance_index, 0))B = textureLoad(vatInstanceTex, (2*instance_index+1, 0))rowA = vatFrameRow(A, clock); rowB = vatFrameRow((B.xy, A.z, B.w), clock)infA = sum_k read(joints[k], rowA)*w[k]; infB = sum_k read(joints[k], rowB)*w[k]influence = infA*(1 - B.z) + infB*B.z // weighted bone-matrix blend == gait cross-fadefinalWorld = mat4(world0..3) * mesh.world * influence // instance OUTERMOST = world-space placementBecause skinned-mesh mesh.world is identity (glTF loader, see 12-thin-instances.md §world-matrix), the instance matrix carries all placement (position/yaw/scale). The single instanced variant always runs the 2-clip blend (single-clip = blend 0), so there is no extra mesh-feature bit and zero bundle movement on non-VAT scenes; the 2× bone reads are negligible vs the one-draw-call win.
State Machine / Lifecycle
- Load + animate a skinned glTF (live skeleton present).
- Either
bakeVat(engine, mesh, groups)directly, orprepareVat(mesh, groups)followed later bycreateVatBakeResult(engine, prepared). attachVat(engine, mesh, baked, clip?)→mesh.vatset,mesh.skeleton = null, VAT ext registered.- (optional)
setThinInstances(mesh, matrices, count)+handle.setInstances/setInstancesBlend(params)BEFOREregisterScene(the first call setsinstanceTexture, selecting the instanced shader variant). Per-instance params may be re-uploaded in place later. - Per frame:
handle.update(dt)advances the shared clock; per-instance matrices/params are updated via the thin-instance/VAT setters as the simulation changes.
Babylon.js Equivalence Map
| Babylon.js | Lite VAT |
|---|---|
VertexAnimationBaker.bakeVertexData(ranges) | bakeVat(engine, mesh, groups) |
BakedVertexAnimationManager (manager.time, setAnimationParameters) | VatHandle (update, play) |
mesh.bakedVertexAnimationManager | mesh.vat (VatData) |
manager.texture (VAT texture) | VatBakeResult.texture / VatData.texture |
Per-instance via bakedVertexAnimationSettingsInstanced buffer | setInstances / setInstancesBlend → instanceTexture (read by instance_index) |
Dependencies
animation/animation-group.ts—goToFrame,stopAnimation, theSkeletonBinding(read at bake).skeleton/create-skeleton.ts— the live bone texture whose per-row layout VAT mirrors; VAT reuses the skeleton's joints/weights vertex buffers.material/pbr/pbr-flags.ts—_registerPbrExt,PbrExt(frag/bind).material/mesh-features.ts—MSH_VAT*detection.shader/fragments/thin-instance-fragment.ts— declaresworld0..3(instanced variants depend on it).- The CSM caster path for shadow casting (no code dependency; works via material renderable reuse).
Test Specification
- Parity (
tests/lite/parity/scenes/scene218-vat.spec.ts) — scene 218 bakes the scene-11 shark and renders it through the VAT path with NO live skeleton; the golden is the BJS live-skeleton oracle frozen at the same integer frame (?seekTime). Asserts full-image MAD ≤scene-config.maxMad(0.02). VAT(frame N) == live(frame N), so MAD ~= 0.000. - Unit (
tests/lite/unit/vat-baker.test.ts) — covers CPU-only preparation, prepared-payload validation, non-zero typed-array upload offsets, capture round-trips, texture deduplication/ref-counting, upload-failure cleanup, and confirms uploaded results do not retain the CPU payload. - Manual / lab (
lab/lite/scene219.ts) — 36 baked sharks thin-instanced, blending swimming↔circling across the grid and casting CSM shadows, in one draw call per pass. (Dev scene; not a parity scene — instanced VAT has no single-mesh BJS oracle.) - Bundle size — non-VAT scenes are byte-unchanged (self-registration); VAT scene bundles include only the lazily-imported
vatchunk.
File Manifest
packages/babylon-lite/src/vat/vat-baker.ts # prepare/upload/bake APIs, attachVat, VatHandlepackages/babylon-lite/src/material/pbr/fragments/vat-fragment.ts # pbrExt + 2 vertex variants (non-instanced / instanced dual-clip)packages/babylon-lite/src/animation/types.ts # VatData (internal)packages/babylon-lite/src/material/mesh-features.ts # MSH_VAT (the only VAT feature bit; instanced derived from MSH_HAS_THIN_INSTANCES)packages/babylon-lite/src/mesh/mesh.ts # mesh.vat fieldpackages/babylon-lite/src/index.ts # exports all public VAT preparation, upload, bake, and runtime APIslab/lite/scene218.html + src/lite/scene218.ts # single-mesh VAT parity scenelab/lite/scene219.html + src/lite/scene219.ts # instanced + blended + shadowed crowd (dev)tests/lite/parity/scenes/scene218-vat.spec.ts # parity testtests/lite/unit/vat-baker.test.ts # prepared-payload and runtime ownership tests