three-vat 0.3.0 → 1.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,5 +1,6 @@
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)
3
4
  [![npm version](https://img.shields.io/npm/v/three-vat.svg)](https://www.npmjs.com/package/three-vat)
4
5
  [![license: MIT](https://img.shields.io/npm/l/three-vat.svg)](./LICENSE)
5
6
 
@@ -7,7 +8,16 @@ Bake a glTF `AnimationClip` into GPU textures and animate **hundreds or thousand
7
8
 
8
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.
9
10
 
10
- > **Status: early release — `0.3.0`, published on npm.** The baker core (skinning, morph targets **and** rigid node-animated subtrees) and WebGL decode are covered by tests. The TSL/WebGPU path ships but is verified visually, not yet by automated tests. See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale, and [`CHANGELOG.md`](./CHANGELOG.md) for release notes.
11
+ ## Live demos
12
+
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.
11
21
 
12
22
  ## Install
13
23
 
@@ -46,17 +56,53 @@ const vat = bakeVAT(gltf.scene, clips, {
46
56
  })
47
57
  ```
48
58
 
49
- ## Render a crowd — WebGL (`WebGLRenderer`)
59
+ ## Render a crowd
60
+
61
+ One call on either renderer. The import line is the only difference:
62
+
63
+ ```ts
64
+ import { createVATMesh } from 'three-vat/webgl' // WebGLRenderer
65
+ // import { createVATMesh } from 'three-vat/tsl' ← WebGPURenderer: the only line that changes
66
+ ```
67
+
68
+ ```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)
73
+
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
81
+ ```
82
+
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)).
84
+
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.
86
+
87
+ Instance matrices and `castShadow`/`receiveShadow` stay yours: only you know where the crowd stands and whether the scene has shadows at all.
88
+
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.
90
+
91
+ <details>
92
+ <summary>By hand on WebGL, when you are not rendering onto a plain <code>InstancedMesh</code></summary>
50
93
 
51
94
  ```ts
52
- import { addInstancedVATAttributes, createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
95
+ import { addVATInstanceAttributes } from 'three-vat'
96
+ import { createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
53
97
 
54
98
  const uniforms = createVATUniforms()
55
99
 
56
100
  // vat.geometry already carries the all-frames bounding box/sphere, so instances
57
101
  // never cull mid-animation.
58
102
  const geometry = vat.geometry.clone()
59
- addInstancedVATAttributes(geometry, instances) // instances: { clip, timeOffset, speed }[]
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)
60
106
 
61
107
  // One patched material per source material, sharing one clock.
62
108
  const materials = vat.materials.map((source) => {
@@ -66,62 +112,260 @@ const materials = vat.materials.map((source) => {
66
112
  })
67
113
 
68
114
  const mesh = new THREE.InstancedMesh(geometry, materials, instances.length)
69
- mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms) // correct instanced shadows
70
- mesh.castShadow = mesh.receiveShadow = true
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)
71
118
 
72
119
  // per frame:
73
120
  uniforms.uVatTime.value = clock.elapsedTime
74
121
  ```
75
122
 
76
- ## Render a crowd — TSL (`WebGPURenderer`)
123
+ </details>
124
+
125
+ <details>
126
+ <summary>By hand on TSL, and the zero-config default</summary>
77
127
 
78
128
  ```ts
79
129
  import { MeshStandardNodeMaterial } from 'three/webgpu'
130
+ import { addVATInstanceAttributes } from 'three-vat'
80
131
  import { vatNodes } from 'three-vat/tsl'
81
132
 
82
- const { positionNode, normalNode, time } = vatNodes(vat, { clipIndex: 0, desync: 10 })
133
+ const geometry = vat.geometry.clone()
134
+ addVATInstanceAttributes(geometry, instances)
135
+
136
+ // The mesh is built first, because the decode is built from it.
83
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 })
84
146
  material.positionNode = positionNode
85
- material.normalNode = normalNode
86
147
 
87
148
  // per frame:
88
149
  time.value = clock.elapsedTime
89
150
  ```
90
151
 
91
- Shadows just work on the TSL path (`positionNode` feeds the depth pass). v1 plays one clip per material with per-instance phase desync; use the WebGL path for mixed-clip crowds.
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.
157
+
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.
159
+
160
+ </details>
161
+
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.
92
190
 
93
- ## Offline format — deprecated, removed in 1.0
191
+ ```ts
192
+ // bake.worker.ts
193
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
194
+ import { bakeVAT } from 'three-vat'
195
+
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
+ ```
94
228
 
95
- > **Do not use `serializeVAT` / `loadVAT`.** They still ship in `0.3.0` for
96
- > compatibility and are removed in `1.0`
97
- > ([ADR-0010](./docs/adr/0010-drop-the-offline-format-runtime-bake-is-the-library.md)).
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)
276
+ ```
98
277
 
99
- The format stores the texel buffers and a manifest, but *not* the geometry. Since
100
- `0.3.0` a bake merges the whole subtree into a new vertex set and the textures are
101
- indexed by that ordering, so a serialized VAT can only be rendered by reloading the
102
- source glTF and re-running the merge — the work the file existed to save. A VAT
103
- restored by `loadVAT` has no `geometry` or `materials`, so it is **not**
104
- interchangeable with a freshly-baked one, whatever earlier releases claimed.
278
+ Two things do not cross the wire, both by nature rather than by omission:
105
279
 
106
- Bake at runtime instead. The baker is pure CPU and touches no renderer, so if bake
107
- time hurts on load, run `bakeVAT` in a Web Worker and transfer the texel buffers back.
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).
108
291
 
109
292
  ## Trade-offs
110
293
 
111
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.
112
295
  - **vs bone-texture instancing:** smaller textures and supports blending, but more fetches per vertex. VAT also captures morph/non-skeletal deformation for free.
113
- - **VAT limits:** no runtime IK/blending, discrete frames, memory cost (`verts × frames × 16 B × 2` textures). No clip crossfade in v1.
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.
114
327
 
115
328
  ## Development
116
329
 
117
330
  ```bash
118
331
  pnpm install
119
- pnpm test # baker core — pure CPU, no GPU needed
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)
120
335
  pnpm typecheck
336
+ pnpm typecheck:examples
121
337
  pnpm build
122
- pnpm example # runs the robot-crowd demo in examples/ (model bundled)
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
123
340
  ```
124
341
 
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).
368
+
125
369
  ## License
126
370
 
127
371
  MIT
@@ -0,0 +1,40 @@
1
+ import { InstancedBufferAttribute } from 'three';
2
+
3
+ // src/instance-playback.ts
4
+ var PLAYBACK_ATTRIBUTES = {
5
+ clipStart: "aClipStart",
6
+ clipFrames: "aClipFrames",
7
+ clipFps: "aClipFps",
8
+ timeOffset: "aTimeOffset",
9
+ speed: "aSpeed"
10
+ };
11
+ function addVATInstanceAttributes(geometry, instances) {
12
+ geometry.morphAttributes = {};
13
+ geometry.morphTargetsRelative = false;
14
+ const n = instances.length;
15
+ const clipStart = new Float32Array(n);
16
+ const clipFrames = new Float32Array(n);
17
+ const clipFps = new Float32Array(n);
18
+ const timeOffset = new Float32Array(n);
19
+ const speed = new Float32Array(n);
20
+ for (let i = 0; i < n; i++) {
21
+ const inst = instances[i];
22
+ clipStart[i] = inst.clip.startFrame;
23
+ clipFrames[i] = inst.clip.frames;
24
+ clipFps[i] = inst.clip.fps;
25
+ timeOffset[i] = inst.timeOffset;
26
+ speed[i] = inst.speed;
27
+ }
28
+ geometry.setAttribute(PLAYBACK_ATTRIBUTES.clipStart, new InstancedBufferAttribute(clipStart, 1));
29
+ geometry.setAttribute(PLAYBACK_ATTRIBUTES.clipFrames, new InstancedBufferAttribute(clipFrames, 1));
30
+ geometry.setAttribute(PLAYBACK_ATTRIBUTES.clipFps, new InstancedBufferAttribute(clipFps, 1));
31
+ geometry.setAttribute(PLAYBACK_ATTRIBUTES.timeOffset, new InstancedBufferAttribute(timeOffset, 1));
32
+ geometry.setAttribute(PLAYBACK_ATTRIBUTES.speed, new InstancedBufferAttribute(speed, 1));
33
+ }
34
+ function createCrowdGeometry(vat, instances) {
35
+ const geometry = vat.geometry.clone();
36
+ addVATInstanceAttributes(geometry, instances);
37
+ return geometry;
38
+ }
39
+
40
+ export { PLAYBACK_ATTRIBUTES, addVATInstanceAttributes, createCrowdGeometry };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Object3D, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
2
- import { B as BakedVAT, V as VATClip, a as VAT } from './types-wVmIj2tC.js';
2
+ import { V as VAT } from './instance-playback-BrGBIKLe.js';
3
+ export { a as VATClip, b as VATClock, c as VATCrowd, d as VATInstance, e as addVATInstanceAttributes } from './instance-playback-BrGBIKLe.js';
3
4
 
4
5
  /**
5
6
  * Conservative fallback texture-dimension cap, used when the caller does not
@@ -33,10 +34,18 @@ interface BakeOptions {
33
34
  * how it got there.
34
35
  *
35
36
  * Positions are stored as deltas from the merged rest pose; normals absolute.
37
+ * Positions are exact for skinning, morph targets, node animation and any mix
38
+ * of them. A normal follows the same stages its vertex does — morph targets
39
+ * (`morphAttributes.normal`, where the asset carries them), then the skin
40
+ * matrix, then the part matrix. Normals reproduce what three's own skinning
41
+ * shader renders: linear blend skinning transforms a normal by the skin matrix
42
+ * rather than its inverse-transpose, which is exact for rigid and
43
+ * uniformly-scaled bones and an approximation for anything else. Non-uniform bone scale is where that
44
+ * approximation becomes visible, so the bake warns once, naming the bone.
36
45
  * Renderer-agnostic — touches no WebGL/WebGPU context — so it runs identically
37
46
  * at runtime, in a Web Worker, and in Node.
38
47
  */
39
- declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextureSize }?: BakeOptions): BakedVAT;
48
+ declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextureSize }?: BakeOptions): VAT;
40
49
  /**
41
50
  * Build a VAT `DataTexture` with the fixed sampling flags every path relies on:
42
51
  * RGBA, nearest filtering, no mipmaps. Frame interpolation is done manually in
@@ -44,48 +53,4 @@ declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextu
44
53
  */
45
54
  declare function makeVATTexture(data: TypedArray, width: number, height: number, type?: TextureDataType): DataTexture;
46
55
 
47
- type VATPrecision = 'float16' | 'float32';
48
- /**
49
- * The versioned descriptor of an offline-baked VAT. The manifest *is* the
50
- * format — bump `version` on any breaking layout change.
51
- */
52
- interface VATManifest {
53
- version: 1;
54
- vertexCount: number;
55
- totalFrames: number;
56
- encoding: 'delta';
57
- precision: VATPrecision;
58
- clips: VATClip[];
59
- bounds: {
60
- min: [number, number, number];
61
- max: [number, number, number];
62
- };
63
- }
64
- /** A serialized VAT: manifest plus the two raw texel buffers. */
65
- interface SerializedVAT {
66
- manifest: VATManifest;
67
- /** Interleaved RGBA position deltas, `float16` or `float32` per `manifest.precision`. */
68
- position: ArrayBuffer;
69
- /** Interleaved RGBA absolute normals, same precision. */
70
- normal: ArrayBuffer;
71
- }
72
- interface SerializeOptions {
73
- /** On-disk texel precision. Default `'float16'` (half the bytes, uploads directly). */
74
- precision?: VATPrecision;
75
- }
76
- /**
77
- * Serialize a baked VAT to raw texel buffers + a versioned manifest. This is
78
- * the on-disk format; the runtime object is always reconstructed with
79
- * {@link loadVAT}.
80
- */
81
- /** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
82
- declare function serializeVAT(vat: VAT, { precision }?: SerializeOptions): SerializedVAT;
83
- /**
84
- * Reconstruct a runtime VAT from serialized buffers. `float16` uploads as
85
- * `HalfFloatType`; `float32` as `FloatType`. The result is interchangeable with
86
- * a {@link bakeVAT} result.
87
- */
88
- /** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
89
- declare function loadVAT(serialized: SerializedVAT): VAT;
90
-
91
- export { type BakeOptions, BakedVAT, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
56
+ export { type BakeOptions, MAX_TEXTURE_SIZE, VAT, bakeVAT, makeVATTexture };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { FloatType, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, DataTexture, RGBAFormat, NearestFilter, DataUtils, HalfFloatType, BufferGeometry, BufferAttribute } from 'three';
1
+ export { addVATInstanceAttributes } from './chunk-SXTVASKG.js';
2
+ import { FloatType, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, DataTexture, RGBAFormat, NearestFilter, BufferGeometry, BufferAttribute } from 'three';
2
3
 
3
- // src/bake.ts
4
4
  var MAX_TEXTURE_SIZE = 16384;
5
5
  function asAttribute(value, mesh, name) {
6
6
  if (!(value instanceof BufferAttribute)) {
@@ -47,6 +47,7 @@ function collectParts(root) {
47
47
  skinIndex: geometry.attributes.skinIndex,
48
48
  skinWeight: geometry.attributes.skinWeight,
49
49
  morphPos: geometry.morphAttributes.position,
50
+ morphNrm: geometry.morphAttributes.normal,
50
51
  morphRelative: geometry.morphTargetsRelative
51
52
  });
52
53
  }
@@ -160,6 +161,9 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
160
161
  const _n = new Vector3();
161
162
  const _mt = new Vector3();
162
163
  const _mb = new Vector3();
164
+ const _mbn = new Vector3();
165
+ const influencers = parts.filter((p) => p.isSkinned && p.skeleton).map(influencedBones);
166
+ let warnedNonUniformScale = false;
163
167
  let rowOffset = 0;
164
168
  clips.forEach((clip, ci) => {
165
169
  const frames = frameCounts[ci];
@@ -170,22 +174,38 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
170
174
  mixer.setTime(f / frames * clip.duration);
171
175
  root.updateMatrixWorld(true);
172
176
  const row = rowOffset + f;
177
+ if (!warnedNonUniformScale) {
178
+ warnedNonUniformScale = warnOnNonUniformBoneScale(influencers, _bone);
179
+ }
173
180
  for (const part of parts) {
174
181
  _partMatrix.multiplyMatrices(rootInverse, part.mesh.matrixWorld);
175
182
  const influences = part.mesh.morphTargetInfluences;
176
- const { morphPos, morphRelative, isSkinned, skeleton, skinIndex, skinWeight } = part;
183
+ const { morphPos, morphNrm, morphRelative, isSkinned, skeleton, skinIndex, skinWeight } = part;
184
+ const morphCount = influences ? Math.min(influences.length, Math.max(morphPos?.length ?? 0, morphNrm?.length ?? 0)) : 0;
177
185
  for (let v = 0; v < part.vertexCount; v++) {
178
186
  const vi = part.vertexStart + v;
179
187
  _p.fromBufferAttribute(part.basePos, v);
180
188
  _n.fromBufferAttribute(part.baseNrm, v);
181
- if (morphPos && influences) {
182
- if (!morphRelative) _mb.fromBufferAttribute(part.basePos, v);
183
- for (let t = 0; t < morphPos.length; t++) {
189
+ if (influences && morphCount > 0) {
190
+ if (!morphRelative) {
191
+ _mb.fromBufferAttribute(part.basePos, v);
192
+ _mbn.fromBufferAttribute(part.baseNrm, v);
193
+ }
194
+ for (let t = 0; t < morphCount; t++) {
184
195
  const w = influences[t];
185
196
  if (w === 0) continue;
186
- _mt.fromBufferAttribute(morphPos[t], v);
187
- if (!morphRelative) _mt.sub(_mb);
188
- _p.addScaledVector(_mt, w);
197
+ const targetPos = morphPos?.[t];
198
+ if (targetPos) {
199
+ _mt.fromBufferAttribute(targetPos, v);
200
+ if (!morphRelative) _mt.sub(_mb);
201
+ _p.addScaledVector(_mt, w);
202
+ }
203
+ const targetNrm = morphNrm?.[t];
204
+ if (targetNrm) {
205
+ _mt.fromBufferAttribute(targetNrm, v);
206
+ if (!morphRelative) _mt.sub(_mbn);
207
+ _n.addScaledVector(_mt, w);
208
+ }
189
209
  }
190
210
  }
191
211
  if (isSkinned && skeleton) {
@@ -256,6 +276,39 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
256
276
  materials
257
277
  };
258
278
  }
279
+ function influencedBones(part) {
280
+ const used = /* @__PURE__ */ new Set();
281
+ const index = part.skinIndex;
282
+ const weight = part.skinWeight;
283
+ for (let v = 0; v < part.vertexCount; v++) {
284
+ for (let i = 0; i < 4; i++) {
285
+ if (weight.getComponent(v, i) !== 0) used.add(index.getComponent(v, i));
286
+ }
287
+ }
288
+ return { skeleton: part.skeleton, bones: [...used] };
289
+ }
290
+ var SCALE_UNIFORMITY_EPSILON = 1e-4;
291
+ function hasNonUniformScale(m) {
292
+ const e = m.elements;
293
+ const x = e[0] * e[0] + e[1] * e[1] + e[2] * e[2];
294
+ const y = e[4] * e[4] + e[5] * e[5] + e[6] * e[6];
295
+ const z = e[8] * e[8] + e[9] * e[9] + e[10] * e[10];
296
+ const max = Math.max(x, y, z);
297
+ return max - Math.min(x, y, z) > SCALE_UNIFORMITY_EPSILON * max;
298
+ }
299
+ function warnOnNonUniformBoneScale(influencers, scratch) {
300
+ for (const { skeleton, bones } of influencers) {
301
+ for (const b of bones) {
302
+ scratch.multiplyMatrices(skeleton.bones[b].matrixWorld, skeleton.boneInverses[b]);
303
+ if (!hasNonUniformScale(scratch)) continue;
304
+ console.warn(
305
+ `three-vat: bone "${skeleton.bones[b].name || "(unnamed)"}" animates with non-uniform scale; baked normals under it are approximate, because linear-blend skinning transforms a normal by the skin matrix rather than its inverse-transpose \u2014 the same shortcut three's own skinning shader takes. Positions are exact.`
306
+ );
307
+ return true;
308
+ }
309
+ }
310
+ return false;
311
+ }
259
312
  function makeVATTexture(data, width, height, type = FloatType) {
260
313
  const tex = new DataTexture(data, width, height, RGBAFormat, type);
261
314
  tex.minFilter = NearestFilter;
@@ -264,50 +317,5 @@ function makeVATTexture(data, width, height, type = FloatType) {
264
317
  tex.needsUpdate = true;
265
318
  return tex;
266
319
  }
267
- function serializeVAT(vat, { precision = "float16" } = {}) {
268
- const pos = vat.positionTexture.image.data;
269
- const nrm = vat.normalTexture.image.data;
270
- const manifest = {
271
- version: 1,
272
- vertexCount: vat.vertexCount,
273
- totalFrames: vat.totalFrames,
274
- encoding: vat.encoding,
275
- precision,
276
- clips: vat.clips,
277
- bounds: {
278
- min: [vat.bounds.min.x, vat.bounds.min.y, vat.bounds.min.z],
279
- max: [vat.bounds.max.x, vat.bounds.max.y, vat.bounds.max.z]
280
- }
281
- };
282
- return { manifest, position: encode(pos, precision), normal: encode(nrm, precision) };
283
- }
284
- function encode(src, precision) {
285
- if (precision === "float32") {
286
- return src.slice().buffer;
287
- }
288
- const half = new Uint16Array(src.length);
289
- for (let i = 0; i < src.length; i++) half[i] = DataUtils.toHalfFloat(src[i]);
290
- return half.buffer;
291
- }
292
- function loadVAT(serialized) {
293
- const { manifest, position, normal } = serialized;
294
- const { vertexCount, totalFrames, precision } = manifest;
295
- const type = precision === "float32" ? FloatType : HalfFloatType;
296
- const posData = precision === "float32" ? new Float32Array(position) : new Uint16Array(position);
297
- const nrmData = precision === "float32" ? new Float32Array(normal) : new Uint16Array(normal);
298
- const bounds = new Box3(
299
- new Vector3().fromArray(manifest.bounds.min),
300
- new Vector3().fromArray(manifest.bounds.max)
301
- );
302
- return {
303
- positionTexture: makeVATTexture(posData, vertexCount, totalFrames, type),
304
- normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames, type),
305
- clips: manifest.clips,
306
- bounds,
307
- vertexCount,
308
- totalFrames,
309
- encoding: manifest.encoding
310
- };
311
- }
312
320
 
313
- export { MAX_TEXTURE_SIZE, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
321
+ export { MAX_TEXTURE_SIZE, bakeVAT, makeVATTexture };
@@ -0,0 +1,109 @@
1
+ import { DataTexture, BufferGeometry, Material, Box3, InstancedMesh } from 'three';
2
+
3
+ /** One baked animation range within a VAT's stacked frame rows. */
4
+ interface VATClip {
5
+ /** Clip name, taken from the source `AnimationClip`. */
6
+ name: string;
7
+ /** First frame row (y) of this clip in the texture. */
8
+ startFrame: number;
9
+ /** Number of frame rows baked for this clip. */
10
+ frames: number;
11
+ /** Effective frames-per-second of the bake (`frames / duration`). */
12
+ fps: number;
13
+ /** Source clip duration in seconds. */
14
+ duration: number;
15
+ /**
16
+ * Largest per-vertex position-delta magnitude (metres) across the clip.
17
+ * Near-zero means the clip baked as a frozen pose — the diagnostic for a
18
+ * mis-targeted or genuinely static clip.
19
+ */
20
+ maxDelta: number;
21
+ }
22
+ /**
23
+ * A baked Vertex Animation Texture: the position/normal `DataTexture`s, the
24
+ * geometry they are indexed by, and the clip table and bounds needed to decode
25
+ * and render them. Produced exactly one way — {@link bakeVAT}, at runtime, from
26
+ * a loaded glTF (ADR-0010).
27
+ *
28
+ * The merged vertex ordering is the baker's own invention and the textures are
29
+ * indexed by it (`x = gl_VertexID`), so the caller cannot bring its own
30
+ * geometry — it must render the one baked here. `materials` is ordered to match
31
+ * `geometry.groups[].materialIndex`, giving one draw call per material.
32
+ */
33
+ interface VAT {
34
+ /** RGBA float texture of per-vertex position deltas (`x = vertex`, `y = frame`). */
35
+ positionTexture: DataTexture;
36
+ /** RGBA float texture of per-vertex absolute normals (`x = vertex`, `y = frame`). */
37
+ normalTexture: DataTexture;
38
+ /** Merged, root-space rest-pose geometry. Its `position` is the delta reference. */
39
+ geometry: BufferGeometry;
40
+ /** Source materials, indexed by `geometry.groups[].materialIndex`. */
41
+ materials: Material[];
42
+ /** Clip table: name → `{ startFrame, frames, fps, ... }`. */
43
+ clips: VATClip[];
44
+ /** Union of every baked frame's bounds; use as the geometry bounding box. */
45
+ bounds: Box3;
46
+ /** Vertex count (texture width). */
47
+ vertexCount: number;
48
+ /** Total frame rows across all clips (texture height). */
49
+ totalFrames: number;
50
+ /** Position encoding. Only `'delta'` in v1. */
51
+ encoding: 'delta';
52
+ }
53
+ /**
54
+ * The shared playback clock: one `{ value }` in seconds, read by every material
55
+ * of every VAT mesh driven by it. Set it once per frame. Deliberately the
56
+ * narrowest shape both decode paths satisfy — a WebGL `IUniform<number>` and a
57
+ * TSL uniform node are both one of these — so `createVATMesh` returns the same
58
+ * thing on either renderer.
59
+ */
60
+ interface VATClock {
61
+ value: number;
62
+ }
63
+ /**
64
+ * A **crowd** ready to render: the mesh to add to the scene, and the clock to
65
+ * advance. What `createVATMesh` returns on either decode path, so moving a
66
+ * crowd between renderers is an import change and nothing else. Named for what
67
+ * it is rather than for its `mesh` field — the clock is half of it.
68
+ */
69
+ interface VATCrowd {
70
+ /** Add to the scene. Its instance matrices are yours to write. */
71
+ mesh: InstancedMesh;
72
+ /** The shared playback clock — set `.value` once per frame. */
73
+ time: VATClock;
74
+ }
75
+
76
+ /** Per-instance playback state consumed by both decode paths. */
77
+ interface VATInstance {
78
+ clip: Pick<VAT['clips'][number], 'startFrame' | 'frames' | 'fps'>;
79
+ /** Phase offset in seconds — desyncs the crowd. */
80
+ timeOffset: number;
81
+ /** Playback rate multiplier. */
82
+ speed: number;
83
+ }
84
+ /**
85
+ * Attach the instance-playback attributes to an instanced geometry. Call once
86
+ * before rendering, on the geometry you hand to the `InstancedMesh`.
87
+ *
88
+ * The attribute names and layout below are the shared contract, spelled once in
89
+ * {@link PLAYBACK_ATTRIBUTES}. Both decode paths read exactly these five —
90
+ * `DECODE_PRELUDE` in `src/webgl.ts` as GLSL attributes, `vatNodes` in
91
+ * `src/tsl.ts` as TSL attribute nodes, when it is handed this geometry.
92
+ *
93
+ * | Attribute | Type | Source |
94
+ * | ------------- | ------------- | --------------------- |
95
+ * | `aClipStart` | `float` (x 1) | `instance.clip.startFrame` — first texture row of the clip's frame band |
96
+ * | `aClipFrames` | `float` (x 1) | `instance.clip.frames` — rows in the band |
97
+ * | `aClipFps` | `float` (x 1) | `instance.clip.fps` — with `frames`, the clip's duration |
98
+ * | `aTimeOffset` | `float` (x 1) | `instance.timeOffset` — phase, in seconds |
99
+ * | `aSpeed` | `float` (x 1) | `instance.speed` — rate multiplier |
100
+ *
101
+ * Every entry is a one-component `InstancedBufferAttribute` of `Float32Array`,
102
+ * one element per instance, in instance order. Adding a field — crossfade's
103
+ * reserved second clip index being the known case (ADR-0007) — means adding it
104
+ * here, in the table above, and in each decode path's own attribute
105
+ * declarations.
106
+ */
107
+ declare function addVATInstanceAttributes(geometry: BufferGeometry, instances: VATInstance[]): void;
108
+
109
+ export { type VAT as V, type VATClip as a, type VATClock as b, type VATCrowd as c, type VATInstance as d, addVATInstanceAttributes as e };
package/dist/tsl.d.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { BufferGeometry, InstancedMesh } from 'three';
1
2
  import { Node } from 'three/webgpu';
2
- import { a as VAT } from './types-wVmIj2tC.js';
3
- import 'three';
3
+ import { b as VATClock, V as VAT, d as VATInstance, c as VATCrowd } from './instance-playback-BrGBIKLe.js';
4
4
 
5
5
  /**
6
6
  * The real maximum texture dimension this renderer accepts, for
@@ -16,39 +16,149 @@ declare function getMaxTextureSize(renderer: object): number;
16
16
  type FloatNode = Node<'float'>;
17
17
  /** A fluent TSL vec3 node. */
18
18
  type Vec3Node = Node<'vec3'>;
19
+ /**
20
+ * A TSL float uniform: a node the graph reads, and a `{ value }` clock the
21
+ * caller sets per frame. Both halves matter — the node is what the decode
22
+ * samples against, the clock is what the render loop writes — which is why
23
+ * `createVATMesh` can hand the same object back as a {@link VATClock} and have
24
+ * it mean the same thing as the WebGL path's uniform.
25
+ */
26
+ type VATTimeUniform = FloatNode & VATClock;
19
27
  interface VATNodeOptions {
20
28
  /**
21
- * Elapsed-time uniform node (seconds). Create once with `uniform(0)` and set
29
+ * Elapsed-time uniform (seconds). Create once with `uniform(0)` and set
22
30
  * `.value` per frame. Defaults to a fresh `uniform(0)` you can read back.
23
31
  */
24
- time?: FloatNode;
25
- /** Which clip to play (index into `vat.clips`). Default `0`. */
32
+ time?: VATTimeUniform;
33
+ /**
34
+ * The geometry these nodes will render. When it carries the instance-playback
35
+ * attributes — write them with `addVATInstanceAttributes` from `three-vat`
36
+ * *before* calling this — each instance plays its own clip, at its own phase
37
+ * and rate. Without them, every instance plays `clipIndex`, phase-desynced by
38
+ * `desync`.
39
+ *
40
+ * Which decode the graph compiles is decided here, at build time: a TSL
41
+ * attribute that is missing from the geometry reads as a constant, so the
42
+ * fallback cannot be a shader-side branch.
43
+ */
44
+ geometry?: BufferGeometry;
45
+ /**
46
+ * The `InstancedMesh` these nodes will render, when there is one.
47
+ *
48
+ * Required for a crowd, and for one reason: three applies the instance matrix
49
+ * to `positionLocal` *before* it reads `positionNode`, so the decode has to
50
+ * displace in the geometry's own space and then re-apply the instancing
51
+ * itself. Without this the delta is added in instance space — unrotated and
52
+ * unscaled — and every instance deforms according to its own matrix.
53
+ *
54
+ * Omit it for a single, non-instanced mesh, where `positionLocal` is the
55
+ * geometry position and there is nothing to re-apply.
56
+ */
57
+ instancedMesh?: InstancedMesh;
58
+ /**
59
+ * Which clip to play (index into `vat.clips`). Ignored — along with
60
+ * `desync` — when `geometry` carries instance playback, which says all of this
61
+ * per instance. Default `0`.
62
+ */
26
63
  clipIndex?: number;
27
64
  /**
28
65
  * Max random per-instance time offset in seconds, hashed from `instanceIndex`.
29
- * `0` (default) plays every instance in lockstep.
66
+ * `0` (default) plays every instance in lockstep. Ignored when `geometry`
67
+ * carries instance playback.
30
68
  */
31
69
  desync?: number;
32
70
  }
33
71
  /** Position/normal nodes to assign onto a `MeshStandardNodeMaterial` (or similar). */
34
72
  interface VATNodes {
73
+ /**
74
+ * Assign to `material.positionNode`. It carries the whole decode — the normal
75
+ * with it.
76
+ *
77
+ * There is deliberately no `normalNode`. A material's `normalNode` is built in
78
+ * the *fragment* stage (three reaches it from `normalView` through
79
+ * `builder.context.setupNormal()`) and is expected in **view** space, whereas a
80
+ * VAT's baked normals are per-vertex and in the geometry's own space. Handing
81
+ * an object-space normal to a fragment-stage node skipped both the instance
82
+ * matrix and the normal matrix, and took `vertexIndex` into the fragment stage
83
+ * with it — where `IndexNode` does not give you the vertex index at all, but
84
+ * quietly turns itself into a varying, so every fragment read a linearly
85
+ * *interpolated* index that addresses neither of the vertices it lies between.
86
+ *
87
+ * Writing `normalLocal` inside the vertex-stage decode instead is what the
88
+ * GLSL path does when it sets `objectNormal` in `beginnormal_vertex`: three
89
+ * then transforms it by the instance and normal matrices and interpolates the
90
+ * result, on both paths, for free.
91
+ */
35
92
  positionNode: Vec3Node;
36
- normalNode: Vec3Node;
37
93
  /** The time uniform in use — set `.value` each frame. */
38
- time: FloatNode;
94
+ time: VATTimeUniform;
39
95
  }
40
96
  /**
41
97
  * Build TSL decode nodes for a baked VAT, for the WebGPU/TSL renderer path.
42
- * Per-instance desync comes from `hash(instanceIndex)` — no instanced
43
- * attributes needed. Shadows work automatically because `positionNode` also
44
- * feeds the depth pass.
98
+ * Shadows work automatically because `positionNode` also feeds the depth pass.
45
99
  *
46
- * v1 limitation: a single clip per material (all instances share `clipIndex`,
47
- * only their phase is desynced). Per-instance clip variety is future work; use
48
- * the `three-vat/webgl` path for mixed-clip crowds today.
100
+ * Pass the `geometry` you are about to render and each instance plays the clip,
101
+ * phase and rate written into it by `addVATInstanceAttributes` — the same
102
+ * instance-playback contract the WebGL path reads (ADR-0009), so a mixed-clip
103
+ * crowd renders identically on either renderer. Without those attributes every
104
+ * instance plays `clipIndex`, desynced by a phase hashed from `instanceIndex`.
49
105
  *
50
- * NOTE: the TSL path is verified visually/manually in v1 (no automated GPU test).
106
+ * Coverage note: the node graph is tested structurally in CI (no GPU); that the
107
+ * two paths decode *identically* is a pixel-diff release gate.
51
108
  */
52
109
  declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
110
+ /**
111
+ * The decode's arithmetic: the position delta and the normal this instance reads
112
+ * at this moment, as nodes — before the vertex-stage writes that place them.
113
+ *
114
+ * @internal Split out and exported for the structural tests. A `Fn` body is
115
+ * opaque to graph traversal (its statements are not built until the shader is),
116
+ * and structural assertions are the only TSL coverage CI can run without a GPU —
117
+ * so the arithmetic that matters stays reachable as a graph. Not re-exported
118
+ * from `three-vat`; nothing outside this package should build against it.
119
+ */
120
+ declare function vatDecode(vat: VAT, options?: VATNodeOptions): {
121
+ position: Vec3Node;
122
+ normal: Vec3Node;
123
+ };
124
+ /** Options for {@link createVATMesh}. */
125
+ interface CreateVATMeshOptions {
126
+ /**
127
+ * The playback clock to drive this crowd from, in seconds. Pass one — from
128
+ * `uniform(0)` — to run several VAT meshes off a single time value, or to
129
+ * keep the node for wiring elsewhere in a graph, which the returned `time`
130
+ * gives back as a plain clock. Defaults to a fresh `uniform(0)`.
131
+ *
132
+ * The one place the two paths' signatures differ: `three-vat/webgl` takes a
133
+ * `THREE.IUniform` here. Both are `{ value }` clocks, and code that lets the
134
+ * call make its own is identical on either path.
135
+ */
136
+ time?: VATTimeUniform;
137
+ }
138
+ /**
139
+ * Turn a baked VAT and a list of instances into a crowd ready to render: an
140
+ * `InstancedMesh` whose geometry carries the instance-playback contract and
141
+ * whose materials decode the VAT on the vertex stage.
142
+ *
143
+ * ```ts
144
+ * const { mesh, time } = createVATMesh(vat, instances)
145
+ * mesh.castShadow = mesh.receiveShadow = true
146
+ * scene.add(mesh)
147
+ * // per frame:
148
+ * time.value = clock.elapsedTime
149
+ * ```
150
+ *
151
+ * The same call, the same signature and the same return as `three-vat/webgl`:
152
+ * a crowd moves between `WebGLRenderer` and `WebGPURenderer` by changing the
153
+ * import line and nothing else. The one asymmetry is absorbed here rather than
154
+ * passed on — this path attaches **no depth material**, because `positionNode`
155
+ * already feeds the depth pass, whereas the WebGL path must patch one by hand
156
+ * or cast bind-pose shadows.
157
+ *
158
+ * Instance matrices and `castShadow`/`receiveShadow` stay yours, as on the
159
+ * WebGL path: `mesh.setMatrixAt` then `mesh.computeBoundingSphere()`, or
160
+ * `frustumCulled = false` when the matrices change every frame.
161
+ */
162
+ declare function createVATMesh(vat: VAT, instances: VATInstance[], options?: CreateVATMeshOptions): VATCrowd;
53
163
 
54
- export { type VATNodeOptions, type VATNodes, getMaxTextureSize, vatNodes };
164
+ export { type CreateVATMeshOptions, type VATNodeOptions, type VATNodes, type VATTimeUniform, createVATMesh, getMaxTextureSize, vatDecode, vatNodes };
package/dist/tsl.js CHANGED
@@ -1,6 +1,7 @@
1
- import { uniform, float, int, hash, instanceIndex, vertexIndex, positionLocal, textureLoad, ivec2, mix } from 'three/tsl';
1
+ import { createCrowdGeometry, PLAYBACK_ATTRIBUTES } from './chunk-SXTVASKG.js';
2
+ import { InstancedMesh } from 'three';
3
+ import { uniform, Fn, positionLocal, positionGeometry, normalLocal, instancedMesh, int, vertexIndex, textureLoad, ivec2, mix, float, hash, instanceIndex, attribute } from 'three/tsl';
2
4
 
3
- // src/tsl.ts
4
5
  function getMaxTextureSize(renderer) {
5
6
  const backend = renderer.backend;
6
7
  const device = backend?.["device"];
@@ -9,30 +10,78 @@ function getMaxTextureSize(renderer) {
9
10
  if (gl) return gl.getParameter(gl.MAX_TEXTURE_SIZE);
10
11
  throw new Error("three-vat: renderer has no initialized backend \u2014 call `await renderer.init()` first");
11
12
  }
12
- function vatNodes(vat, options = {}) {
13
- const { time = uniform(0), clipIndex = 0, desync = 0 } = options;
13
+ var floatAttribute = (name) => attribute(name, "float");
14
+ function attributePlayback() {
15
+ const frames = floatAttribute(PLAYBACK_ATTRIBUTES.clipFrames);
16
+ return {
17
+ startFrame: int(floatAttribute(PLAYBACK_ATTRIBUTES.clipStart)),
18
+ frames,
19
+ duration: frames.div(floatAttribute(PLAYBACK_ATTRIBUTES.clipFps)),
20
+ timeOffset: floatAttribute(PLAYBACK_ATTRIBUTES.timeOffset),
21
+ speed: floatAttribute(PLAYBACK_ATTRIBUTES.speed)
22
+ };
23
+ }
24
+ function clipAt(vat, clipIndex) {
14
25
  const clip = vat.clips[clipIndex];
15
26
  if (!clip) {
16
27
  throw new Error(`three-vat: clipIndex ${clipIndex} out of range (${vat.clips.length} clips)`);
17
28
  }
18
- const frames = float(clip.frames);
19
- const startFrame = int(clip.startFrame);
20
- const duration = float(clip.frames / clip.fps);
21
- const offset = hash(instanceIndex).mul(desync);
29
+ return clip;
30
+ }
31
+ function hashedPlayback(clip, desync) {
32
+ return {
33
+ startFrame: int(clip.startFrame),
34
+ frames: float(clip.frames),
35
+ duration: float(clip.frames / clip.fps),
36
+ timeOffset: hash(instanceIndex).mul(desync),
37
+ speed: float(1)
38
+ };
39
+ }
40
+ function checkPlaybackAttributes(geometry) {
41
+ const names = Object.values(PLAYBACK_ATTRIBUTES);
42
+ const missing = names.filter((name) => geometry.getAttribute(name) === void 0);
43
+ if (missing.length === 0) return true;
44
+ if (missing.length === names.length) return false;
45
+ throw new Error(
46
+ `three-vat: geometry carries only part of the instance-playback contract (missing ${missing.join(", ")}) \u2014 write all of it with \`addVATInstanceAttributes\` from \`three-vat\`, or pass no geometry for the hashed default`
47
+ );
48
+ }
49
+ function vatNodes(vat, options = {}) {
50
+ const { time = uniform(0), instancedMesh: instanced } = options;
51
+ const { position, normal } = vatDecode(vat, options);
52
+ const decode = Fn(() => {
53
+ positionLocal.assign((instanced ? positionGeometry : positionLocal).add(position));
54
+ normalLocal.assign(normal);
55
+ if (instanced) instancedMesh(instanced);
56
+ return positionLocal;
57
+ }, "vec3");
58
+ return { positionNode: decode(), time };
59
+ }
60
+ function vatDecode(vat, options = {}) {
61
+ const { time = uniform(0), geometry, clipIndex = 0, desync = 0 } = options;
62
+ const playback = geometry && checkPlaybackAttributes(geometry) ? attributePlayback() : hashedPlayback(clipAt(vat, clipIndex), desync);
22
63
  const vertexRow = int(vertexIndex);
23
64
  const sample = (tex) => {
24
- const t = time.add(offset).div(duration).fract().mul(frames);
65
+ const t = time.mul(playback.speed).add(playback.timeOffset).div(playback.duration).fract().mul(playback.frames);
25
66
  const f0 = int(t);
26
- const f1 = int(f0.add(1).toFloat().mod(frames));
27
- const s0 = textureLoad(tex, ivec2(vertexRow, f0.add(startFrame))).xyz;
28
- const s1 = textureLoad(tex, ivec2(vertexRow, f1.add(startFrame))).xyz;
67
+ const f1 = int(f0.add(1).toFloat().mod(playback.frames));
68
+ const s0 = textureLoad(tex, ivec2(vertexRow, f0.add(playback.startFrame))).xyz;
69
+ const s1 = textureLoad(tex, ivec2(vertexRow, f1.add(playback.startFrame))).xyz;
29
70
  return mix(s0, s1, t.fract());
30
71
  };
31
72
  return {
32
- positionNode: positionLocal.add(sample(vat.positionTexture)),
33
- normalNode: sample(vat.normalTexture).normalize(),
34
- time
73
+ position: sample(vat.positionTexture),
74
+ normal: sample(vat.normalTexture).normalize()
35
75
  };
36
76
  }
77
+ function createVATMesh(vat, instances, options = {}) {
78
+ const time = options.time ?? uniform(0);
79
+ const geometry = createCrowdGeometry(vat, instances);
80
+ const materials = vat.materials.map((source) => source.clone());
81
+ const mesh = new InstancedMesh(geometry, materials, instances.length);
82
+ const { positionNode } = vatNodes(vat, { time, geometry, instancedMesh: mesh });
83
+ for (const material of materials) material.positionNode = positionNode;
84
+ return { mesh, time };
85
+ }
37
86
 
38
- export { getMaxTextureSize, vatNodes };
87
+ export { createVATMesh, getMaxTextureSize, vatDecode, vatNodes };
package/dist/webgl.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { IUniform, BufferGeometry, MeshDepthMaterial, WebGLRenderer, Material } from 'three';
2
- import { a as VAT } from './types-wVmIj2tC.js';
1
+ import { IUniform, MeshDepthMaterial, WebGLRenderer, Material } from 'three';
2
+ import { d as VATInstance$1, e as addVATInstanceAttributes, V as VAT, c as VATCrowd } from './instance-playback-BrGBIKLe.js';
3
3
 
4
4
  /**
5
5
  * The real maximum texture dimension this GPU accepts, for
@@ -16,19 +16,20 @@ interface VATUniforms {
16
16
  }
17
17
  /** Create the shared time uniform. Update `uVatTime.value` once per frame. */
18
18
  declare function createVATUniforms(time?: number): VATUniforms;
19
- /** Per-instance playback state consumed by the patched shader. */
20
- interface VATInstance {
21
- clip: Pick<VAT['clips'][number], 'startFrame' | 'frames' | 'fps'>;
22
- /** Phase offset in seconds — desyncs the crowd. */
23
- timeOffset: number;
24
- /** Playback rate multiplier. */
25
- speed: number;
26
- }
27
19
  /**
28
- * Attach the per-instance attributes the WebGL decode reads: clip band, fps,
29
- * time offset, and speed. Call on the instanced geometry before rendering.
20
+ * The instance-playback contract now lives in the core entry point, so both
21
+ * decode paths can read it (ADR-0009).
22
+ *
23
+ * @deprecated Renamed to `addVATInstanceAttributes` and moved to `three-vat`.
24
+ * Removed from `three-vat/webgl` in the next minor version — import it from
25
+ * `three-vat` instead.
26
+ */
27
+ declare const addInstancedVATAttributes: typeof addVATInstanceAttributes;
28
+ /**
29
+ * @deprecated Moved to `three-vat`. Removed from `three-vat/webgl` in the next
30
+ * minor version — import `VATInstance` from `three-vat` instead.
30
31
  */
31
- declare function addInstancedVATAttributes(geometry: BufferGeometry, instances: VATInstance[]): void;
32
+ type VATInstance = VATInstance$1;
32
33
  /**
33
34
  * Patch any built-in material so its vertex stage samples the VAT instead of
34
35
  * skinning. Works on the render material and on `MeshDepthMaterial` (needed for
@@ -43,5 +44,45 @@ declare function patchVATMaterial<T extends Material>(material: T, vat: VAT, uni
43
44
  * `MeshDistanceMaterial`).
44
45
  */
45
46
  declare function createVATDepthMaterial(vat: VAT, uniforms: VATUniforms): MeshDepthMaterial;
47
+ /** Options for {@link createVATMesh}. */
48
+ interface CreateVATMeshOptions {
49
+ /**
50
+ * The playback clock to drive this crowd from, in seconds. Pass one — from
51
+ * {@link createVATUniforms} or any `{ value }` — to run several VAT meshes off
52
+ * a single time value. Defaults to a fresh clock at `0`, returned to you as
53
+ * `time`. The TSL path's `vatNodes` takes its clock the same way.
54
+ */
55
+ time?: IUniform<number>;
56
+ }
57
+ /**
58
+ * Turn a baked VAT and a list of instances into a crowd ready to render: an
59
+ * `InstancedMesh` whose geometry carries the instance-playback contract, whose
60
+ * materials decode the VAT, and whose shadows are deformed rather than frozen
61
+ * in the bind pose.
62
+ *
63
+ * ```ts
64
+ * const { mesh, time } = createVATMesh(vat, instances)
65
+ * mesh.castShadow = mesh.receiveShadow = true
66
+ * scene.add(mesh)
67
+ * // per frame:
68
+ * time.value = clock.elapsedTime
69
+ * ```
70
+ *
71
+ * Two things stay yours, because only you can know them:
72
+ *
73
+ * - **Instance matrices.** Write them with `mesh.setMatrixAt`, then
74
+ * `mesh.instanceMatrix.needsUpdate = true`. An `InstancedMesh` caches the
75
+ * bounding sphere it culls against, so call `mesh.computeBoundingSphere()`
76
+ * after placing the crowd, or set `mesh.frustumCulled = false` when the
77
+ * matrices change every frame.
78
+ * - **`castShadow` / `receiveShadow`**, which are scene decisions. The depth and
79
+ * distance materials the shadow passes need are already attached either way.
80
+ *
81
+ * Everything here is the exported primitives — {@link addVATInstanceAttributes},
82
+ * {@link patchVATMaterial}, {@link createVATDepthMaterial} — composed in the one
83
+ * order that is correct. Reach for them directly only when rendering onto
84
+ * something other than a plain `InstancedMesh`.
85
+ */
86
+ declare function createVATMesh(vat: VAT, instances: VATInstance$1[], options?: CreateVATMeshOptions): VATCrowd;
46
87
 
47
- export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
88
+ export { type CreateVATMeshOptions, type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATMesh, createVATUniforms, getMaxTextureSize, patchVATMaterial };
package/dist/webgl.js CHANGED
@@ -1,35 +1,13 @@
1
- import { InstancedBufferAttribute, MeshDepthMaterial, RGBADepthPacking } from 'three';
1
+ import { addVATInstanceAttributes, createCrowdGeometry } from './chunk-SXTVASKG.js';
2
+ import { MeshDepthMaterial, RGBADepthPacking, InstancedMesh, MeshDistanceMaterial } from 'three';
2
3
 
3
- // src/webgl.ts
4
4
  function getMaxTextureSize(renderer) {
5
5
  return renderer.capabilities.maxTextureSize;
6
6
  }
7
7
  function createVATUniforms(time = 0) {
8
8
  return { uVatTime: { value: time } };
9
9
  }
10
- function addInstancedVATAttributes(geometry, instances) {
11
- geometry.morphAttributes = {};
12
- geometry.morphTargetsRelative = false;
13
- const n = instances.length;
14
- const clipStart = new Float32Array(n);
15
- const clipFrames = new Float32Array(n);
16
- const clipFps = new Float32Array(n);
17
- const timeOffset = new Float32Array(n);
18
- const speed = new Float32Array(n);
19
- for (let i = 0; i < n; i++) {
20
- const inst = instances[i];
21
- clipStart[i] = inst.clip.startFrame;
22
- clipFrames[i] = inst.clip.frames;
23
- clipFps[i] = inst.clip.fps;
24
- timeOffset[i] = inst.timeOffset;
25
- speed[i] = inst.speed;
26
- }
27
- geometry.setAttribute("aClipStart", new InstancedBufferAttribute(clipStart, 1));
28
- geometry.setAttribute("aClipFrames", new InstancedBufferAttribute(clipFrames, 1));
29
- geometry.setAttribute("aClipFps", new InstancedBufferAttribute(clipFps, 1));
30
- geometry.setAttribute("aTimeOffset", new InstancedBufferAttribute(timeOffset, 1));
31
- geometry.setAttribute("aSpeed", new InstancedBufferAttribute(speed, 1));
32
- }
10
+ var addInstancedVATAttributes = addVATInstanceAttributes;
33
11
  var DECODE_PRELUDE = (
34
12
  /* glsl */
35
13
  `
@@ -82,5 +60,14 @@ function createVATDepthMaterial(vat, uniforms) {
82
60
  patchVATMaterial(depth, vat, uniforms);
83
61
  return depth;
84
62
  }
63
+ function createVATMesh(vat, instances, options = {}) {
64
+ const uniforms = options.time ? { uVatTime: options.time } : createVATUniforms();
65
+ const geometry = createCrowdGeometry(vat, instances);
66
+ const materials = vat.materials.map((source) => patchVATMaterial(source.clone(), vat, uniforms));
67
+ const mesh = new InstancedMesh(geometry, materials, instances.length);
68
+ mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms);
69
+ mesh.customDistanceMaterial = patchVATMaterial(new MeshDistanceMaterial(), vat, uniforms);
70
+ return { mesh, time: uniforms.uVatTime };
71
+ }
85
72
 
86
- export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
73
+ export { addInstancedVATAttributes, createVATDepthMaterial, createVATMesh, createVATUniforms, getMaxTextureSize, patchVATMaterial };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-vat",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "Bake glTF animation clips into vertex animation textures for zero-CPU instanced crowds in three.js.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,9 +65,14 @@
65
65
  "dev": "tsup --watch",
66
66
  "test": "vitest run",
67
67
  "test:watch": "vitest",
68
+ "fetch:test-assets": "node scripts/fetch-test-assets.mjs",
68
69
  "typecheck": "tsc --noEmit",
69
- "release": "pnpm publish --otp=\"${NPM_OTP:?set NPM_OTP=<code from your authenticator>}\" && pnpm verify:published",
70
+ "release": "pnpm publish ${NPM_OTP:+--otp=\"$NPM_OTP\"} && pnpm verify:published",
70
71
  "verify:published": "node scripts/verify-published.mjs",
71
- "example": "pnpm --dir examples dev"
72
+ "example": "pnpm --filter three-vat-example dev",
73
+ "build:examples": "pnpm --filter three-vat-example build",
74
+ "test:examples": "pnpm --filter three-vat-example test",
75
+ "parity": "pnpm --filter three-vat-example parity",
76
+ "typecheck:examples": "pnpm --filter three-vat-example typecheck"
72
77
  }
73
78
  }
@@ -1,63 +0,0 @@
1
- import { DataTexture, Box3, BufferGeometry, Material } from 'three';
2
-
3
- /** One baked animation range within a VAT's stacked frame rows. */
4
- interface VATClip {
5
- /** Clip name, taken from the source `AnimationClip`. */
6
- name: string;
7
- /** First frame row (y) of this clip in the texture. */
8
- startFrame: number;
9
- /** Number of frame rows baked for this clip. */
10
- frames: number;
11
- /** Effective frames-per-second of the bake (`frames / duration`). */
12
- fps: number;
13
- /** Source clip duration in seconds. */
14
- duration: number;
15
- /**
16
- * Largest per-vertex position-delta magnitude (metres) across the clip.
17
- * Near-zero means the clip baked as a frozen pose — the diagnostic for a
18
- * mis-targeted or genuinely static clip.
19
- */
20
- maxDelta: number;
21
- }
22
- /**
23
- * A baked Vertex Animation Texture: the position/normal `DataTexture`s plus the
24
- * clip table and bounds needed to decode and render them. Produced by
25
- * {@link bakeVAT} (runtime) or `loadVAT` (offline); the two paths yield
26
- * identical objects.
27
- */
28
- interface VAT {
29
- /** RGBA float texture of per-vertex position deltas (`x = vertex`, `y = frame`). */
30
- positionTexture: DataTexture;
31
- /** RGBA float texture of per-vertex absolute normals (`x = vertex`, `y = frame`). */
32
- normalTexture: DataTexture;
33
- /** Clip table: name → `{ startFrame, frames, fps, ... }`. */
34
- clips: VATClip[];
35
- /** Union of every baked frame's bounds; use as the geometry bounding box. */
36
- bounds: Box3;
37
- /** Vertex count (texture width). */
38
- vertexCount: number;
39
- /** Total frame rows across all clips (texture height). */
40
- totalFrames: number;
41
- /** Position encoding. Only `'delta'` in v1. */
42
- encoding: 'delta';
43
- }
44
- /**
45
- * What {@link bakeVAT} returns: a VAT plus the geometry it was baked against.
46
- *
47
- * The merged vertex ordering is the baker's own invention and the textures are
48
- * indexed by it (`x = gl_VertexID`), so the caller can no longer bring its own
49
- * geometry — it must render the one baked here. `materials` is ordered to match
50
- * `geometry.groups[].materialIndex`, giving one draw call per material.
51
- *
52
- * `loadVAT` returns a plain {@link VAT} without these, which is precisely why the
53
- * offline format is deprecated and removed in 1.0 (ADR-0010): a serialized VAT
54
- * cannot be rendered without re-running the merge that produced its ordering.
55
- */
56
- interface BakedVAT extends VAT {
57
- /** Merged, root-space rest-pose geometry. Its `position` is the delta reference. */
58
- geometry: BufferGeometry;
59
- /** Source materials, indexed by `geometry.groups[].materialIndex`. */
60
- materials: Material[];
61
- }
62
-
63
- export type { BakedVAT as B, VATClip as V, VAT as a };