three-vat 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,14 @@
1
1
  # three-vat
2
2
 
3
- [![CI](https://github.com/MikeFernandez-Pro/three-vat/actions/workflows/ci.yml/badge.svg)](https://github.com/MikeFernandez-Pro/three-vat/actions/workflows/ci.yml)
4
- [![npm version](https://img.shields.io/npm/v/three-vat.svg)](https://www.npmjs.com/package/three-vat)
5
- [![license: MIT](https://img.shields.io/npm/l/three-vat.svg)](./LICENSE)
6
-
7
- Bake a glTF `AnimationClip` into GPU textures and animate **hundreds or thousands of instanced characters with zero per-frame CPU** — one draw call, no `SkinnedMesh` per character.
3
+ ![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](https://raw.githubusercontent.com/MikeFernandez-Pro/three-vat/main/docs/media/hero.gif)
8
4
 
9
- VAT (Vertex Animation Texture) is battle-tested in Unity/Unreal but has been a gap on the three.js side: only scattered demos, no maintained package, nothing in drei. `three-vat` bakes the VAT **at runtime, directly from the glTF** — so any Mixamo/Sketchfab asset works with zero pipeline, and there is exactly one way to produce a VAT.
5
+ **[Open the live demo →](https://mikefernandez-pro.github.io/three-vat/)** and drag the count from 1 robot to 340. The draw calls do not move.
10
6
 
11
- ## Live demos
7
+ 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.
12
8
 
13
- - **[WebGL crowd](https://mikefernandez-pro.github.io/three-vat/webgl_crowd.html)** — 340 robots, mixed clips, one mesh, through the GLSL decode path.
14
- - **[WebGPU crowd](https://mikefernandez-pro.github.io/three-vat/webgpu_crowd.html)** — the same crowd through the TSL decode path, on `WebGPURenderer`.
15
-
16
- Both pages carry a live draw-call counter and a view of the baked textures, with
17
- cursors on the frame rows each instance is sampling. Source in
18
- [`examples/`](./examples); they deploy from `main` on every push.
19
-
20
- > **Status: `1.0.0`** — the npm badge above reads the registry, so it is the one to trust for what is actually published. The library is **three surfaces**, and all three work: the core baker (`three-vat`), the WebGL/GLSL decode (`three-vat/webgl`), and the WebGPU/TSL decode (`three-vat/tsl`). The two decode paths 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. Where the renderers genuinely differ — shadow materials, the `time` clock's type, and what the TSL node builder needs to re-apply instancing — it is called out where it arises. The baker and the WebGL decode are covered by tests; the TSL path is tested structurally, because CI has no GPU, and that the two paths decode *pixel-identically* is a manual release gate ([`pnpm parity`](./docs/releasing.md)). See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale, [What 1.0 does not do](#what-10-does-not-do) for the deferred work, and [`CHANGELOG.md`](./CHANGELOG.md) for release notes.
9
+ [![CI](https://github.com/MikeFernandez-Pro/three-vat/actions/workflows/ci.yml/badge.svg)](https://github.com/MikeFernandez-Pro/three-vat/actions/workflows/ci.yml)
10
+ [![npm version](https://img.shields.io/npm/v/three-vat.svg)](https://www.npmjs.com/package/three-vat)
11
+ [![license: MIT](https://img.shields.io/npm/l/three-vat.svg)](./LICENSE)
21
12
 
22
13
  ## Install
23
14
 
@@ -25,346 +16,211 @@ cursors on the frame rows each instance is sampling. Source in
25
16
  npm install three-vat three
26
17
  ```
27
18
 
28
- `three` (>= 0.185) is a peer dependency.
19
+ `three` (>= 0.186) is a peer dependency.
29
20
 
30
- ## Bake
21
+ ## One crowd, start to finish
31
22
 
32
23
  ```ts
24
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
33
25
  import { bakeVAT } from 'three-vat'
26
+ import { createVATMesh, getMaxTextureSize } from 'three-vat/webgl'
27
+ // WebGPURenderer? `from 'three-vat/tsl'` — that import is the only line that changes.
28
+
29
+ // The parts you already know — renderer, scene, camera, lights, clock — made
30
+ // however you usually make them. Nothing on this line is VAT-specific.
31
+ const { renderer, scene, camera, clock } = setUpYourScene()
32
+
33
+ const gltf = await new GLTFLoader().loadAsync('/robot.glb')
34
+
35
+ // Bake once, at load. Pass the subtree root — `gltf.scene` — and not a mesh
36
+ // inside it: the bake unit is the whole subtree, merged and recorded in root
37
+ // space. This same call takes
38
+ // - a skinned character (Mixamo, Sketchfab, `Soldier.glb`)
39
+ // - a morph-target mesh
40
+ // - a hierarchy of rigid, node-animated parts (three's `RobotExpressive`)
41
+ // - any mix of those in one subtree
42
+ // because a VAT records where a vertex ended up and never how it got there.
43
+ // Nothing to classify, and no variant of this call to go looking for.
44
+ const vat = bakeVAT(gltf.scene, gltf.animations, {
45
+ fps: 30,
46
+ maxTextureSize: getMaxTextureSize(renderer), // this GPU's real ceiling
47
+ })
48
+
49
+ // One entry per character: which clip it plays, when it started, its rate.
50
+ // Everything but `startTime` is optional — a clip baked from a configured
51
+ // `AnimationAction` carries its own loop, repetition count, end behaviour and
52
+ // speed, and an instance overrides only what it wants to differ.
53
+ const instances = Array.from({ length: 500 }, (_, i) => ({
54
+ clip: vat.clips[i % vat.clips.length],
55
+ startTime: -Math.random() * 2, // began a moment ago, so the crowd is not in lockstep
56
+ speed: 0.9 + Math.random() * 0.2,
57
+ }))
58
+
59
+ // The crowd: one InstancedMesh, its geometry and materials already decoding the
60
+ // VAT, plus the clock that drives every instance.
61
+ const { mesh, time } = createVATMesh(vat, instances)
62
+ mesh.castShadow = mesh.receiveShadow = true
63
+ scene.add(mesh)
64
+
65
+ // Where each character stands is yours — the library never guesses a layout.
66
+ for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrixFor(i))
67
+ mesh.instanceMatrix.needsUpdate = true
68
+ mesh.computeBoundingSphere() // or frustumCulled = false, if matrices move every frame
34
69
 
35
- // gltf loaded via GLTFLoader — pass the subtree root, not a mesh.
36
- const clips = gltf.animations.filter((c) => c.name !== 'TPose')
37
- const vat = bakeVAT(gltf.scene, clips, { fps: 30 })
38
- // vat: { positionTexture, normalTexture, geometry, materials, clips, bounds, ... }
70
+ renderer.setAnimationLoop(() => {
71
+ time.value = clock.getElapsedTime() // the whole per-frame cost of the animation
72
+ renderer.render(scene, camera)
73
+ })
39
74
  ```
40
75
 
41
- The bake unit is the **whole subtree**, merged into one vertex set and recorded in root space ([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)). A VAT only records *where a vertex ended up*, never how it got there, so one call handles a single `SkinnedMesh`, a morph-target mesh, a hierarchy of rigid node-animated parts (three.js `RobotExpressive`), or any mix — with no classification by the caller.
76
+ Two things worth knowing the first time:
42
77
 
43
- Two consequences worth knowing up front:
78
+ - **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is
79
+ the baker's, and the textures are indexed by it. `createVATMesh` does this for
80
+ you; by hand, clone that geometry and no other.
81
+ - **Materials are never merged.** A 500-robot crowd with 3 materials is 3 draw
82
+ calls — not 1, and not 500. VAT collapses instance count, not material count.
44
83
 
45
- - **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is the baker's, and the textures are indexed by it.
46
- - **Materials are never merged.** `vat.materials` lines up with `vat.geometry.groups`, giving one draw call per material. VAT collapses *instance* count, not *material* count — a 500-robot crowd with 3 materials is 3 draw calls, not 1 and not 500.
84
+ <details>
85
+ <summary><b>Does it work with my model?</b></summary>
47
86
 
48
- The VAT is a flat `vertexCount` × `totalFrames` texture pair, so **both** axes are bounded by the GPU's max texture dimension. The baker is renderer-agnostic and defaults to a conservative `16384`; pass the real limit whenever you have a renderer, or a bake that allocates on desktop can fail on mobile (commonly 4096–8192):
87
+ If `GLTFLoader` loads it and it has an `AnimationClip`, yes — the four shapes
88
+ listed in the snippet above, and any mix of them, through that one call.
49
89
 
50
- ```ts
51
- import { getMaxTextureSize } from 'three-vat/webgl' // or 'three-vat/tsl'
90
+ The bake unit is the **subtree**, not the mesh
91
+ ([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)),
92
+ which is where the one real mistake lives: pass `gltf.scene`, or the node you
93
+ want animated, never a `SkinnedMesh` you fished out of it.
52
94
 
53
- const vat = bakeVAT(gltf.scene, clips, {
54
- fps: 30,
55
- maxTextureSize: getMaxTextureSize(renderer),
56
- })
57
- ```
95
+ Positions bake exactly under any rig; normals match what three's own skinning
96
+ shader draws, which is approximate under non-uniform bone scale — `bakeVAT`
97
+ warns once and names the bone. A rest-pose track such as Mixamo's `TPose` bakes
98
+ to a frozen band and reports it as a near-zero `clip.maxDelta`: filter those out
99
+ of `gltf.animations` rather than spending texture rows on them.
58
100
 
59
- ## Render a crowd
101
+ </details>
60
102
 
61
- One call on either renderer. The import line is the only difference:
103
+ <details>
104
+ <summary><b>Per-clip playback defaults</b></summary>
62
105
 
63
- ```ts
64
- import { createVATMesh } from 'three-vat/webgl' // WebGLRenderer
65
- // import { createVATMesh } from 'three-vat/tsl' ← WebGPURenderer: the only line that changes
66
- ```
106
+ `bakeVAT` takes an `AnimationClip` **or** an `AnimationAction`, in the same
107
+ array. Configure the action the way three already taught you, and every instance
108
+ of that clip inherits it — and overrides any field it names.
67
109
 
68
110
  ```ts
69
- // instances: { clip, timeOffset, speed }[] — one per character.
70
- const { mesh, time } = createVATMesh(vat, instances)
71
- mesh.castShadow = mesh.receiveShadow = true
72
- scene.add(mesh)
111
+ const death = mixer.clipAction(deathClip)
112
+ death.loop = THREE.LoopOnce
73
113
 
74
- // place the crowd
75
- for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrix)
76
- mesh.instanceMatrix.needsUpdate = true
77
- mesh.computeBoundingSphere() // or frustumCulled = false, if matrices change every frame
78
-
79
- // per frame:
80
- time.value = clock.elapsedTime
114
+ const vat = bakeVAT(gltf.scene, [walkClip, death, idleClip])
81
115
  ```
82
116
 
83
- The call clones the baked geometry, writes the per-instance playback attributes, and prepares one material per material group — so a crowd mixes clips, phases and playback rates on either renderer, from the same `instances` array ([ADR-0009](./docs/adr/0009-both-decode-paths-read-one-instance-playback-contract.md)).
117
+ `loop`, `repetitions` and `timeScale` are read; `time` and `paused` are ignored,
118
+ because a VAT has no playhead of its own to seed; a non-unit `weight` or an
119
+ additive `blendMode` throws, because one baked band cannot be several actions
120
+ blended at once.
84
121
 
85
- Shadows are the one place the renderers genuinely differ, and the call absorbs it: on WebGL it attaches the patched depth and distance materials the shadow passes need (miss them by hand and the crowd's body animates while its shadow stays in the bind pose); on TSL it attaches nothing, because `positionNode` already feeds the depth pass. Either way you write `castShadow` and nothing else.
122
+ **Both inputs clamp, where three rewinds.** `clampWhenFinished` defaults to
123
+ `false` in three, which means an untouched action says nothing about the end
124
+ either — and a crowd's answer to nothing is to hold the last frame, because a
125
+ corpse standing back up is the worse default. So the end mode is not read off
126
+ the action; an instance asks for three's rewind with `endMode: EndMode.Rewind`,
127
+ which is the finer grain anyway.
86
128
 
87
- Instance matrices and `castShadow`/`receiveShadow` stay yours: only you know where the crowd stands and whether the scene has shadows at all.
129
+ [Declaring the defaults at the bake](./docs/usage.md#declaring-the-defaults-at-the-bake).
88
130
 
89
- The returned `time` is the same `{ value }` clock on both paths. The one other place the signatures differ is supplying your own: `options.time` is a `THREE.IUniform` on the WebGL path and a TSL `uniform(0)` on the TSL path, because that is what each renderer's material can read. Let the call make its own — as above — and even that line is identical.
131
+ </details>
90
132
 
91
133
  <details>
92
- <summary>By hand on WebGL, when you are not rendering onto a plain <code>InstancedMesh</code></summary>
93
-
94
- ```ts
95
- import { addVATInstanceAttributes } from 'three-vat'
96
- import { createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
97
-
98
- const uniforms = createVATUniforms()
99
-
100
- // vat.geometry already carries the all-frames bounding box/sphere, so instances
101
- // never cull mid-animation.
102
- const geometry = vat.geometry.clone()
103
- // Instance playback — `{ clip, timeOffset, speed }` per instance — is a core
104
- // contract both decode paths read, not a WebGL-only concept.
105
- addVATInstanceAttributes(geometry, instances)
106
-
107
- // One patched material per source material, sharing one clock.
108
- const materials = vat.materials.map((source) => {
109
- const material = source.clone()
110
- patchVATMaterial(material, vat, uniforms)
111
- return material
112
- })
134
+ <summary><b>Upgrading from 1.x</b></summary>
113
135
 
114
- const mesh = new THREE.InstancedMesh(geometry, materials, instances.length)
115
- // Correct instanced shadows: depth for directional/spot lights, distance for point lights.
116
- mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms)
117
- mesh.customDistanceMaterial = patchVATMaterial(new THREE.MeshDistanceMaterial(), vat, uniforms)
136
+ The breaks that reach a 1.x caller, no shims — the package had no users on the
137
+ 1.x playback contract, so 2.0 spells it one way rather than two. The full list
138
+ is [the CHANGELOG's 2.0.0 entry](./CHANGELOG.md).
118
139
 
119
- // per frame:
120
- uniforms.uVatTime.value = clock.elapsedTime
140
+ ```ts
141
+ { clip, timeOffset: 1.4, speed: 1 } // 1.x — every field required
142
+ { clip, startTime: -1.4 } // 2.0 — desync is a start time in the past
121
143
  ```
122
144
 
145
+ `timeOffset` is gone (`startTime: -timeOffset / speed`), and with it
146
+ `aTimeOffset`. The pack is three `vec4`s — clip, playback, fade — in a
147
+ **playback texture** keyed by the instance's logical index, not instanced
148
+ attributes, which are indexed by the *drawn* slot:
149
+ `addInstancedVATAttributes` is removed, `createVATPlaybackTexture(instances)`
150
+ replaces `addVATInstanceAttributes`, and `setVATInstance(playback, id, instance)`
151
+ takes that texture — `createVATMesh` returns it as `playback` — rather than a
152
+ geometry. A VAT texture's `image.data` is now opaque. Node 20, where
153
+ 1.x said 18, and `three >= 0.186`, where `batchIndirectIndex` is exported;
154
+ browsers are unaffected.
155
+
156
+ New, and none of it breaking: per-instance loop modes, one-shots,
157
+ `setVATInstance` after the crowd is built, and a crowd on a `BatchedMesh`.
158
+ [By hand, on either path](./docs/usage.md#by-hand-on-either-path).
159
+
123
160
  </details>
124
161
 
125
162
  <details>
126
- <summary>By hand on TSL, and the zero-config default</summary>
163
+ <summary><b>Going bigger: texture limits, bake cost, Web Workers</b></summary>
127
164
 
128
- ```ts
129
- import { MeshStandardNodeMaterial } from 'three/webgpu'
130
- import { addVATInstanceAttributes } from 'three-vat'
131
- import { vatNodes } from 'three-vat/tsl'
132
-
133
- const geometry = vat.geometry.clone()
134
- addVATInstanceAttributes(geometry, instances)
135
-
136
- // The mesh is built first, because the decode is built from it.
137
- const material = new MeshStandardNodeMaterial()
138
- const mesh = new THREE.InstancedMesh(geometry, material, instances.length)
139
-
140
- // `geometry`: each instance plays its own clip, phase and rate.
141
- // `instancedMesh`: the decode re-applies this mesh's instancing itself, because
142
- // three applies the instance matrix to `positionLocal` *before* it reads
143
- // `positionNode` — so the delta has to be added in the geometry's own space and
144
- // instanced afterwards. Omit it only for a single, non-instanced mesh.
145
- const { positionNode, time } = vatNodes(vat, { geometry, instancedMesh: mesh })
146
- material.positionNode = positionNode
147
-
148
- // per frame:
149
- time.value = clock.elapsedTime
150
- ```
165
+ The VAT is one `vertexCount` × `totalFrames` texture pair, so both axes hit the
166
+ GPU's texture ceiling. Always pass `maxTextureSize: getMaxTextureSize(renderer)`
167
+ as above: the default is a desktop-shaped guess, and mobile is often 4096.
151
168
 
152
- There is deliberately no `normalNode`: the decode writes `normalLocal` from
153
- inside the vertex stage, exactly as the GLSL path writes `objectNormal`, and
154
- three transforms and interpolates it from there. A material's `normalNode` is
155
- built in the *fragment* stage and expected in view space, which is neither where
156
- nor what a per-vertex, object-space VAT normal is.
169
+ The bake is CPU work done once at load — about 100 ms for the demo's robot, and
170
+ seconds for a 20k-vertex skinned character with many clips. It never touches the
171
+ renderer, so it moves into a Web Worker as-is.
157
172
 
158
- Omit `geometry` and you get the zero-config default instead: every instance plays `clipIndex`, phase-desynced by `desync` seconds hashed from `instanceIndex`, with no attributes to write.
173
+ **[docs/usage.md](./docs/usage.md)** has the measured bake-cost table, the
174
+ worker recipe, the draw-call arithmetic, and the primitives underneath
175
+ `createVATMesh` for when you are not rendering onto a plain `InstancedMesh`.
159
176
 
160
177
  </details>
161
178
 
162
- ## Bake cost, and baking in a Web Worker
163
-
164
- A VAT is produced exactly one way — `bakeVAT` at runtime
165
- ([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md)).
166
- There is no file format to write or load, so the one cost to budget is the bake
167
- itself, once at load. Measured on `three@0.185.1` (Node, Apple Silicon, mean of
168
- 3 runs after warm-up):
169
-
170
- | Asset | Clips | fps | Rows | Bake |
171
- |---|---|---|---|---|
172
- | `RobotExpressive` (rigid, 7 214 v) | 3 (the demo) | 30 | 158 | 97 ms |
173
- | `RobotExpressive` | 5 | 30 | 313 | 178 ms |
174
- | `RobotExpressive` | 14 (all) | 30 | 585 | 330 ms |
175
- | `RobotExpressive` | 14 (all) | 60 | 1 168 | 659 ms |
176
- | `Soldier` (skinned, 7 434 v) | 4 (all) | 30 | 113 | 250 ms |
177
- | `Soldier` (skinned) | 4 (all) | 60 | 224 | 492 ms |
178
-
179
- Cost is linear in `vertices × frames`, and **a skinned vertex costs ~4× per
180
- frame row what a rigid one does** (2.2 ms/row here vs 0.56) — the four-weight
181
- bone blend is the hot loop. So budget by rows, and halve `fps` before you cut
182
- clips. A 20k-vertex skinned character with 6 clips at 30 fps extrapolates to
183
- ~1.8 s on this machine and several seconds on a mid-range phone — enough to
184
- matter, and the point at which the bake belongs off the main thread.
185
-
186
- It can go there as-is: **the baker is pure CPU and never touches the renderer**,
187
- so it runs in a Web Worker, with the texel buffers transferred back at no copy
188
- cost. The worker loads the glTF and bakes; the main thread rebuilds the
189
- textures and the geometry from plain buffers.
179
+ <details>
180
+ <summary><b>What it does not do</b></summary>
190
181
 
191
- ```ts
192
- // bake.worker.ts
193
- import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
194
- import { bakeVAT } from 'three-vat'
182
+ No clip crossfade (an instance blends out of a frozen pose, not between two
183
+ clips that are both playing), no LOD, no baking CLI or file
184
+ format, no React/drei binding, glTF input only. Each is a decision rather than a
185
+ gap, and each is written up with its reasoning in
186
+ **[docs/usage.md](./docs/usage.md#what-10-does-not-do)**, alongside the
187
+ trade-offs against `SkinnedMesh` and bone-texture instancing.
195
188
 
196
- self.onmessage = async ({ data: { url, fps, maxTextureSize } }) => {
197
- const gltf = await new GLTFLoader().loadAsync(url)
198
- const vat = bakeVAT(gltf.scene, gltf.animations, { fps, maxTextureSize })
199
-
200
- const position = vat.positionTexture.image.data as Float32Array
201
- const normal = vat.normalTexture.image.data as Float32Array
202
- const index = vat.geometry.getIndex()
203
-
204
- self.postMessage(
205
- {
206
- position,
207
- normal,
208
- // Every attribute the merge produced — position, normal, and uv/color
209
- // when the source had them. Carry each itemSize rather than guessing it.
210
- attributes: Object.fromEntries(
211
- Object.entries(vat.geometry.attributes).map(([name, a]) => [
212
- name,
213
- { array: a.array, itemSize: a.itemSize },
214
- ]),
215
- ),
216
- index: index?.array,
217
- groups: vat.geometry.groups,
218
- clips: vat.clips,
219
- bounds: { min: vat.bounds.min.toArray(), max: vat.bounds.max.toArray() },
220
- vertexCount: vat.vertexCount,
221
- totalFrames: vat.totalFrames,
222
- },
223
- // Transferred, not copied — the texel buffers are the large part.
224
- [position.buffer, normal.buffer],
225
- )
226
- }
227
- ```
189
+ Both decode paths — GLSL on `WebGLRenderer`, TSL on `WebGPURenderer` — read one
190
+ shared instance-playback contract and export the same `createVATMesh`, so
191
+ nothing documented here is true on one renderer and false on the other. That the
192
+ two decode *pixel-identically* is a manual gate before every release
193
+ ([docs/releasing.md](./docs/releasing.md)).
228
194
 
229
- ```ts
230
- // main thread
231
- import * as THREE from 'three'
232
- import { makeVATTexture } from 'three-vat'
233
- import { createVATMesh, getMaxTextureSize } from 'three-vat/webgl' // or 'three-vat/tsl'
234
- import type { VAT } from 'three-vat'
235
-
236
- // `renderer` is your WebGLRenderer (or WebGPURenderer), already created — only
237
- // the main thread can ask the GPU for its limits.
238
- const worker = new Worker(new URL('./bake.worker.ts', import.meta.url), { type: 'module' })
239
-
240
- function bakeInWorker(url: string, fps = 30): Promise<VAT> {
241
- return new Promise((resolve) => {
242
- worker.onmessage = ({ data: d }) => {
243
- const geometry = new THREE.BufferGeometry()
244
- for (const [name, { array, itemSize }] of Object.entries(d.attributes)) {
245
- geometry.setAttribute(name, new THREE.BufferAttribute(array, itemSize))
246
- }
247
- if (d.index) geometry.setIndex(new THREE.BufferAttribute(d.index, 1))
248
- for (const g of d.groups) geometry.addGroup(g.start, g.count, g.materialIndex)
249
-
250
- const bounds = new THREE.Box3(
251
- new THREE.Vector3(...d.bounds.min),
252
- new THREE.Vector3(...d.bounds.max),
253
- )
254
- // The union-of-all-frames volume, or a deformed crowd culls mid-animation.
255
- geometry.boundingBox = bounds.clone()
256
- geometry.boundingSphere = bounds.getBoundingSphere(new THREE.Sphere())
257
-
258
- resolve({
259
- positionTexture: makeVATTexture(d.position, d.vertexCount, d.totalFrames),
260
- normalTexture: makeVATTexture(d.normal, d.vertexCount, d.totalFrames),
261
- geometry,
262
- // One per group, in `materialIndex` order — see the note below.
263
- materials: d.groups.map(() => new THREE.MeshStandardMaterial()),
264
- clips: d.clips,
265
- bounds,
266
- vertexCount: d.vertexCount,
267
- totalFrames: d.totalFrames,
268
- encoding: 'delta',
269
- })
270
- }
271
- worker.postMessage({ url, fps, maxTextureSize: getMaxTextureSize(renderer) })
272
- })
273
- }
274
-
275
- const { mesh, time } = createVATMesh(await bakeInWorker('/robot.glb'), instances)
195
+ </details>
196
+
197
+ <details>
198
+ <summary><b>Development</b></summary>
199
+
200
+ ```bash
201
+ pnpm i
202
+ pnpm run dev # opens the demo — this and the line above are the whole setup
276
203
  ```
277
204
 
278
- Two things do not cross the wire, both by nature rather than by omission:
279
-
280
- - **Materials.** A `Material` holds textures and GPU state, so it has to be
281
- built on the main thread. The placeholder above is a stand-in: `materials`
282
- must have one entry per `geometry.groups[].materialIndex`, or every group past
283
- the first renders undefined. For a glTF's real materials, load it a second
284
- time on the main thread (the browser serves it from cache) and take
285
- `gltf.scene`'s materials in the same order the merge recorded them.
286
- - **The renderer's `maxTextureSize`**, which only the main thread can ask for —
287
- read it there and pass it in, as the snippet does.
288
-
289
- A `bakeVATInWorker` helper is deferred, for the reason in
290
- [What 1.0 does not do](#what-10-does-not-do).
291
-
292
- ## Trade-offs
293
-
294
- - **vs N × `SkinnedMesh`:** N draw calls + per-frame CPU skeletons → VAT is 1 draw call, zero per-frame CPU, 2 texel fetches per vertex. The headline.
295
- - **vs bone-texture instancing:** smaller textures and supports blending, but more fetches per vertex. VAT also captures morph/non-skeletal deformation for free.
296
- - **VAT limits:** no runtime IK/blending, discrete frames, memory cost (`verts × frames × 16 B × 2` textures). No clip crossfade — see [What 1.0 does not do](#what-10-does-not-do).
297
- - **Skinned normals:** positions bake exactly under any rig. Normals reproduce what three's own skinning shader renders — linear-blend skinning transforms a normal by the skin matrix rather than its inverse-transpose, exact for rigid and uniformly-scaled bones, an approximation otherwise. Non-uniform bone scale is where that shows, so `bakeVAT` warns once, naming the bone.
298
-
299
- ## What 1.0 does not do
300
-
301
- Named rather than left to be discovered. None of these is a known defect; each
302
- is a decision, with the reasoning recorded where it was made.
303
-
304
- - **No clip crossfade.** An instance cuts between clips, it does not blend.
305
- Crossfade doubles the per-vertex texel fetches (2 → 4) and adds per-instance
306
- transition state, which is not worth spending before the single-clip decode is
307
- proven on both paths ([ADR-0007](./docs/adr/0007-v1-scope-library-only.md)).
308
- The instance-attribute layout reserves room for a second clip index, so it
309
- stays a non-breaking addition.
310
- - **No LOD.** Every instance samples the VAT at full vertex count, whatever its
311
- distance.
312
- - **No `npx vat-bake` CLI, and no file format for it to write.** The offline
313
- format was removed in 1.0 and the runtime bake is the only way to produce a
314
- VAT ([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md));
315
- the design a CLI would build on is kept on record in the superseded
316
- [ADR-0003](./docs/adr/0003-offline-format-float16-bin-plus-manifest.md), so
317
- reviving it is a decision rather than a fresh design problem.
318
- - **No React/drei hook or component.** A downstream contribution rather than a
319
- library surface, and `createVATMesh` is what makes it thin enough to be one.
320
- - **No `bakeVATInWorker` helper.** 1.0 ships the [recipe](#bake-cost-and-baking-in-a-web-worker)
321
- instead: adding a second, async way to bake while the API stabilizes is the
322
- two-entry-point split [ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)
323
- refused, and demand should decide it.
324
- - **glTF/GLB input only**, and multi-material meshes are rejected rather than
325
- split by geometry group — `GLTFLoader` emits one mesh per primitive, so the
326
- case is unreachable through the only input surface there is.
327
-
328
- ## Development
205
+ Three more verbs, and that is the table:
329
206
 
330
207
  ```bash
331
- pnpm install
332
- pnpm fetch:test-assets # Soldier.glb — too big for git, so the skinned real-asset tests skip without it
333
- pnpm test # baker core — pure CPU, no GPU needed
334
- pnpm test:examples # the demo's own suite (crowd layout, page/bundle shape)
335
- pnpm typecheck
336
- pnpm typecheck:examples
337
- pnpm build
338
- pnpm example # serves the demo pages in examples/ (model bundled)
339
- pnpm parity # cross-path pixel-diff gate — needs a GPU and a WebGPU browser
208
+ pnpm test # every suite: the baker core, the release suite, the demo's own
209
+ pnpm typecheck # all three tsconfigs: library, release suite, demo
210
+ pnpm build # the published library (`pnpm run build:watch` to watch)
340
211
  ```
341
212
 
342
- `examples/` is a workspace package, so one `pnpm install` at the root covers
343
- both it and the library. It is a multi-page app: a landing `index.html` plus one
344
- page per renderer (`webgl_crowd.html`, `webgpu_crowd.html`), each self-contained
345
- by design
346
- ([ADR-0011](./docs/adr/0011-one-example-per-renderer-duplicated-on-purpose.md)).
347
- Both the build entries and the landing page's list are globbed from those HTML
348
- files, so adding a demo is adding a file. `pnpm build:examples` produces the
349
- static site that `.github/workflows/pages.yml` publishes from `main`; it builds
350
- with a relative base, so it also runs from any subpath or a `file://` open.
351
-
352
- The WebGPU page checks for an adapter before it loads anything else and points
353
- at the WebGL demo when there is none — `WebGPURenderer` would otherwise fall
354
- back to its WebGL backend and quietly draw the WebGPU demo through GLSL.
355
-
356
- `pnpm parity` is the one check that is not in CI and not optional. It renders
357
- one bake through both decode paths at the same camera, lights and time and
358
- compares the frames pixel by pixel — the only thing that can catch a decode
359
- subtly wrong on one path only, and the only thing that needs a real GPU on both
360
- backends. It is a **required gate before publishing**, not a test; see
361
- [docs/releasing.md](./docs/releasing.md) for what it checks and how to read a
362
- failure.
363
-
364
- The suite is green on a fresh clone with no network: the real-asset tests skip
365
- when their asset is missing. Run `pnpm fetch:test-assets` before touching the
366
- baker, so a real skinned character is actually being baked — see
367
- [docs/test-assets.md](./docs/test-assets.md).
213
+ `examples/` is the demo — one page per renderer, self-contained on purpose
214
+ ([ADR-0011](./docs/adr/0011-one-example-per-renderer-duplicated-on-purpose.md)),
215
+ deployed from `main` on every push. Release steps are `node` invocations rather
216
+ than table entries, the parity gate among them and required
217
+ ([docs/releasing.md](./docs/releasing.md)). The suite is green on a fresh clone
218
+ with no network: real-asset tests skip when their asset is missing, so fetch it
219
+ before touching the baker ([docs/test-assets.md](./docs/test-assets.md)).
220
+
221
+ </details>
222
+
223
+ Deeper: [docs/](./docs) · [CHANGELOG.md](./CHANGELOG.md) · [live WebGPU demo](https://mikefernandez-pro.github.io/three-vat/webgpu_crowd.html)
368
224
 
369
225
  ## License
370
226