npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

three-vat

v4.2.0

Published

Bake glTF animation clips into vertex animation textures for zero-CPU instanced crowds in three.js.

Readme

three-vat

The demo's count dragged from a single robot up to 340, the crowd filling the screen while the draw-call counter holds still at three — one per material

Open the live demo → and drag the count from 1 robot to 340. The draw calls do not move.

Bake a glTF AnimationClip into GPU textures and animate hundreds or thousands of instanced characters with zero per-frame CPU — one draw call per material, no SkinnedMesh per character. Works on WebGLRenderer and WebGPURenderer, from the same baked VAT.

CI npm version license: MIT

Install

npm install three-vat three

three (>= 0.186) is a peer dependency.

One crowd, start to finish

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { bakeVAT } from 'three-vat'
import { createVATMesh, getMaxTextureSize } from 'three-vat/webgl'
// WebGPURenderer? `from 'three-vat/tsl'` — that import is the only line that changes.

// The parts you already know — renderer, scene, camera, lights, clock — made
// however you usually make them. Nothing on this line is VAT-specific.
const { renderer, scene, camera, clock } = setUpYourScene()

const gltf = await new GLTFLoader().loadAsync('/robot.glb')

// Bake once, at load. Pass the subtree root — `gltf.scene` — and not a mesh
// inside it: the bake unit is the whole subtree, merged and recorded in root
// space. This same call takes
//   - a skinned character (Mixamo, Sketchfab, `Soldier.glb`)
//   - a morph-target mesh
//   - a hierarchy of rigid, node-animated parts (three's `RobotExpressive`)
//   - any mix of those in one subtree
// because a VAT records where a vertex ended up and never how it got there.
// Nothing to classify, and no variant of this call to go looking for.
const vat = bakeVAT(gltf.scene, gltf.animations, {
  fps: 30,
  maxTextureSize: getMaxTextureSize(renderer), // this GPU's real ceiling
})

// One entry per character: which clip it plays, when it started, its rate.
// Only `clip` and `startTime` are required — a clip baked from a configured
// `AnimationAction` carries its own loop, repetition count, end behaviour and
// speed, and an instance overrides only what it wants to differ.
const instances = Array.from({ length: 500 }, (_, i) => ({
  clip: vat.clips[i % vat.clips.length],
  startTime: -Math.random() * 2, // began a moment ago, so the crowd is not in lockstep
  speed: 0.9 + Math.random() * 0.2,
}))

// The crowd: one InstancedMesh, its geometry and materials already decoding the
// VAT, plus the clock that drives every instance.
const { mesh, time } = createVATMesh(vat, instances)
mesh.castShadow = mesh.receiveShadow = true
scene.add(mesh)

// Where each character stands is yours — the library never guesses a layout.
for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrixFor(i))
mesh.instanceMatrix.needsUpdate = true
mesh.computeBoundingSphere() // or frustumCulled = false, if matrices move every frame

renderer.setAnimationLoop(() => {
  time.value = clock.getElapsedTime() // the whole per-frame cost of the animation
  renderer.render(scene, camera)
})

Two things worth knowing the first time:

  • Render vat.geometry, not your source mesh. The merged vertex ordering is the baker's, and the textures are indexed by it. createVATMesh does this for you; by hand, clone that geometry and no other.
  • Materials are never merged unless you ask. A 500-robot crowd with 3 materials is 3 draw calls — not 1, and not 500. mergeFlatMaterials: true makes flat colours one material, and the crowd one draw call.

A skinned character? The bake picks the rig encoding for it by itself: the posed rig instead of the posed vertices, for two orders of magnitude less texture, a bake in milliseconds, and a texture whose width is the rig's, not the mesh's. Assets a rig cannot express fall back to vertices, and vat.fallback says why. The rig encoding.

If GLTFLoader or FBXLoader loads it and it has an AnimationClip, yes — the four shapes listed in the snippet above, and any mix of them, through that one call. Run an FBX mesh's geometry through mergeVertices first: the loader never indexes it (Loading FBX).

The bake unit is the subtree, not the mesh (ADR-0008), which is where the one real mistake lives: pass gltf.scene, or the node you want animated, never a SkinnedMesh you fished out of it.

Positions bake exactly under any rig; normals match what three's own skinning shader draws, which is approximate under non-uniform bone scale — bakeVAT warns once and names the bone. A rest-pose track such as Mixamo's TPose bakes to a frozen band and reports it as a near-zero clip.maxDelta: filter those out of gltf.animations rather than spending texture rows on them, as you would the empty Take 001 a Mixamo FBX carries.

bakeVAT takes an AnimationClip or an AnimationAction, in the same array. Configure the action the way three already taught you, and every instance of that clip inherits it — and overrides any field it names.

const death = mixer.clipAction(deathClip)
death.loop = THREE.LoopOnce

const vat = bakeVAT(gltf.scene, [walkClip, death, idleClip])

loop, repetitions and timeScale are read; time and paused are ignored, because a VAT has no playhead of its own to seed; a non-unit weight or an additive blendMode throws, because one baked band cannot be several actions blended at once.

Both inputs clamp, where three rewinds. clampWhenFinished defaults to false in three, which means an untouched action says nothing about the end either — and a crowd's answer to nothing is to hold the last frame, because a corpse standing back up is the worse default. So the end mode is not read off the action; an instance asks for three's rewind with endMode: EndMode.Rewind, which is the finer grain anyway.

Declaring the defaults at the bake.

A negative rate plays a clip backwards, from the band already baked, with nothing extra in the texture. Two spellings:

  • One instance: speed: -1 on its entry, or on a setVATInstance write.
  • Every instance of a clip: action.timeScale = -1 on the action handed to bakeVAT, the way three already spells it. An instance's own speed still overrides it, in either direction.

A reversed one-shot starts on its last frame, with no action.time to move first. Playing backwards.

The breaks that reach a 1.x caller, no shims — the package had no users on the 1.x playback contract, so 2.0 spells it one way rather than two. The full list is the CHANGELOG's 2.0.0 entry.

{ clip, timeOffset: 1.4, speed: 1 }  // 1.x — every field required
{ clip, startTime: -1.4 }            // 2.0 — desync is a start time in the past

timeOffset is gone (startTime: -timeOffset / speed), and with it aTimeOffset. The pack moved into a playback texture, five texels a row since 3.0 (clip, playback, crossfade, outgoing clip, outgoing playback), keyed by the instance's logical index rather than the drawn slot. So addInstancedVATAttributes is removed, createVATPlaybackTexture(instances) replaces addVATInstanceAttributes, and setVATInstance(playback, id, instance) takes that texture — createVATMesh returns it as playback — rather than a geometry. A VAT texture's image.data is now opaque. Node 20, where 1.x said 18, and three >= 0.186, where batchIndirectIndex is exported; browsers are unaffected.

New, and none of it breaking: per-instance loop modes, one-shots, setVATInstance after the crowd is built, and a crowd on a BatchedMesh. By hand, on either path.

A vertex-encoded VAT is one vertexCount × totalFrames texture pair, so both axes hit the GPU's texture ceiling; a rig-encoded one is two texels a bone wide, so in practice only its frames do. Always pass maxTextureSize: getMaxTextureSize(renderer) as above: the default is a desktop-shaped guess, and mobile is often 4096.

The bake is CPU work done once at load. Under the vertex encoding that is about 100 ms for the demo's robot, and seconds for a 20k-vertex skinned character with many clips; the rig encoding bakes several to a few hundred times faster. It never touches the renderer, so bakeVATInWorker runs it in a Web Worker as-is.

Or bake once at build time, with three-vat bake --out, and loadVAT reads the baked file back as the VAT bakeVAT would have returned:

import { loadVAT } from 'three-vat'

const vat = await loadVAT('/model.vat.glb')
const { mesh, time } = createVATMesh(vat, instances)

docs/usage.md has the measured bake-cost table, the worker bake, baking at build time, the draw-call arithmetic, and the primitives underneath createVATMesh for when you are not rendering onto a plain InstancedMesh.

No LOD, no React/drei binding, glTF and FBX input only. Each is a decision rather than a gap, and each is written up with its reasoning in docs/usage.md, alongside the trade-offs against SkinnedMesh and bone-texture instancing.

Both decode paths — GLSL on WebGLRenderer, TSL on WebGPURenderer — read one shared instance-playback contract and export the same createVATMesh, so nothing documented here is true on one renderer and false on the other. That the two decode pixel-identically is a manual gate before every release (docs/releasing.md).

pnpm i
pnpm run dev    # opens the demo — this and the line above are the whole setup

Three more verbs, and that is the table:

pnpm test       # every suite: the baker core, the release suite, the demo's own
pnpm typecheck  # all three tsconfigs: library, release suite, demo
pnpm build      # the published library (`pnpm run build:watch` to watch)

examples/ is the demo — one page per renderer, self-contained on purpose (ADR-0011), deployed from main on every push. Release steps are node invocations rather than table entries, the parity gate among them and required (docs/releasing.md). The suite is green on a fresh clone with no network: real-asset tests skip when their asset is missing, so fetch it before touching the baker (docs/test-assets.md).

Deeper: docs/ · CHANGELOG.md · live WebGPU demo

License

MIT