three-vat 0.3.0 → 1.0.1

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,13 +1,14 @@
1
1
  # three-vat
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/three-vat.svg)](https://www.npmjs.com/package/three-vat)
4
- [![license: MIT](https://img.shields.io/npm/l/three-vat.svg)](./LICENSE)
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)
5
4
 
6
- 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.
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.
7
6
 
8
- 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.
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.
9
8
 
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.
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)
11
12
 
12
13
  ## Install
13
14
 
@@ -17,111 +18,147 @@ npm install three-vat three
17
18
 
18
19
  `three` (>= 0.185) is a peer dependency.
19
20
 
20
- ## Bake
21
+ ## One crowd, start to finish
21
22
 
22
23
  ```ts
24
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
23
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
+ })
24
48
 
25
- // gltf loaded via GLTFLoader — pass the subtree root, not a mesh.
26
- const clips = gltf.animations.filter((c) => c.name !== 'TPose')
27
- const vat = bakeVAT(gltf.scene, clips, { fps: 30 })
28
- // vat: { positionTexture, normalTexture, geometry, materials, clips, bounds, ... }
29
- ```
30
-
31
- 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.
32
-
33
- Two consequences worth knowing up front:
34
-
35
- - **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is the baker's, and the textures are indexed by it.
36
- - **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.
49
+ // One entry per character: which clip it plays, its phase, its rate.
50
+ const instances = Array.from({ length: 500 }, (_, i) => ({
51
+ clip: vat.clips[i % vat.clips.length],
52
+ timeOffset: Math.random() * 2, // desync, so the crowd is not in lockstep
53
+ speed: 0.9 + Math.random() * 0.2,
54
+ }))
37
55
 
38
- 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):
56
+ // The crowd: one InstancedMesh, its geometry and materials already decoding the
57
+ // VAT, plus the clock that drives every instance.
58
+ const { mesh, time } = createVATMesh(vat, instances)
59
+ mesh.castShadow = mesh.receiveShadow = true
60
+ scene.add(mesh)
39
61
 
40
- ```ts
41
- import { getMaxTextureSize } from 'three-vat/webgl' // or 'three-vat/tsl'
62
+ // Where each character stands is yours — the library never guesses a layout.
63
+ for (let i = 0; i < instances.length; i++) mesh.setMatrixAt(i, matrixFor(i))
64
+ mesh.instanceMatrix.needsUpdate = true
65
+ mesh.computeBoundingSphere() // or frustumCulled = false, if matrices move every frame
42
66
 
43
- const vat = bakeVAT(gltf.scene, clips, {
44
- fps: 30,
45
- maxTextureSize: getMaxTextureSize(renderer),
67
+ renderer.setAnimationLoop(() => {
68
+ time.value = clock.getElapsedTime() // the whole per-frame cost of the animation
69
+ renderer.render(scene, camera)
46
70
  })
47
71
  ```
48
72
 
49
- ## Render a crowd — WebGL (`WebGLRenderer`)
73
+ Two things worth knowing the first time:
50
74
 
51
- ```ts
52
- import { addInstancedVATAttributes, createVATUniforms, createVATDepthMaterial, patchVATMaterial } from 'three-vat/webgl'
75
+ - **Render `vat.geometry`, not your source mesh.** The merged vertex ordering is
76
+ the baker's, and the textures are indexed by it. `createVATMesh` does this for
77
+ you; by hand, clone that geometry and no other.
78
+ - **Materials are never merged.** A 500-robot crowd with 3 materials is 3 draw
79
+ calls — not 1, and not 500. VAT collapses instance count, not material count.
53
80
 
54
- const uniforms = createVATUniforms()
81
+ <details>
82
+ <summary><b>Does it work with my model?</b></summary>
55
83
 
56
- // vat.geometry already carries the all-frames bounding box/sphere, so instances
57
- // never cull mid-animation.
58
- const geometry = vat.geometry.clone()
59
- addInstancedVATAttributes(geometry, instances) // instances: { clip, timeOffset, speed }[]
84
+ If `GLTFLoader` loads it and it has an `AnimationClip`, yes — the four shapes
85
+ listed in the snippet above, and any mix of them, through that one call.
60
86
 
61
- // One patched material per source material, sharing one clock.
62
- const materials = vat.materials.map((source) => {
63
- const material = source.clone()
64
- patchVATMaterial(material, vat, uniforms)
65
- return material
66
- })
87
+ The bake unit is the **subtree**, not the mesh
88
+ ([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)),
89
+ which is where the one real mistake lives: pass `gltf.scene`, or the node you
90
+ want animated, never a `SkinnedMesh` you fished out of it.
67
91
 
68
- const mesh = new THREE.InstancedMesh(geometry, materials, instances.length)
69
- mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms) // correct instanced shadows
70
- mesh.castShadow = mesh.receiveShadow = true
92
+ Positions bake exactly under any rig; normals match what three's own skinning
93
+ shader draws, which is approximate under non-uniform bone scale — `bakeVAT`
94
+ warns once and names the bone. A rest-pose track such as Mixamo's `TPose` bakes
95
+ to a frozen band and reports it as a near-zero `clip.maxDelta`: filter those out
96
+ of `gltf.animations` rather than spending texture rows on them.
71
97
 
72
- // per frame:
73
- uniforms.uVatTime.value = clock.elapsedTime
74
- ```
98
+ </details>
75
99
 
76
- ## Render a crowd — TSL (`WebGPURenderer`)
100
+ <details>
101
+ <summary><b>Going bigger: texture limits, bake cost, Web Workers</b></summary>
77
102
 
78
- ```ts
79
- import { MeshStandardNodeMaterial } from 'three/webgpu'
80
- import { vatNodes } from 'three-vat/tsl'
103
+ The VAT is one `vertexCount` × `totalFrames` texture pair, so both axes hit the
104
+ GPU's texture ceiling. Always pass `maxTextureSize: getMaxTextureSize(renderer)`
105
+ as above: the default is a desktop-shaped guess, and mobile is often 4096.
81
106
 
82
- const { positionNode, normalNode, time } = vatNodes(vat, { clipIndex: 0, desync: 10 })
83
- const material = new MeshStandardNodeMaterial()
84
- material.positionNode = positionNode
85
- material.normalNode = normalNode
107
+ The bake is CPU work done once at load — about 100 ms for the demo's robot, and
108
+ seconds for a 20k-vertex skinned character with many clips. It never touches the
109
+ renderer, so it moves into a Web Worker as-is.
86
110
 
87
- // per frame:
88
- time.value = clock.elapsedTime
89
- ```
111
+ **[docs/usage.md](./docs/usage.md)** has the measured bake-cost table, the
112
+ worker recipe, the draw-call arithmetic, and the primitives underneath
113
+ `createVATMesh` for when you are not rendering onto a plain `InstancedMesh`.
90
114
 
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.
115
+ </details>
92
116
 
93
- ## Offline format — deprecated, removed in 1.0
117
+ <details>
118
+ <summary><b>What it does not do</b></summary>
94
119
 
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)).
120
+ No clip crossfade (instances cut between clips), no LOD, no baking CLI or file
121
+ format, no React/drei binding, glTF input only. Each is a decision rather than a
122
+ gap, and each is written up with its reasoning in
123
+ **[docs/usage.md](./docs/usage.md#what-10-does-not-do)**, alongside the
124
+ trade-offs against `SkinnedMesh` and bone-texture instancing.
98
125
 
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.
126
+ Both decode paths — GLSL on `WebGLRenderer`, TSL on `WebGPURenderer` — read one
127
+ shared instance-playback contract and export the same `createVATMesh`, so
128
+ nothing documented here is true on one renderer and false on the other. That the
129
+ two decode *pixel-identically* is a manual gate before every release
130
+ ([docs/releasing.md](./docs/releasing.md)).
105
131
 
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.
132
+ </details>
108
133
 
109
- ## Trade-offs
134
+ <details>
135
+ <summary><b>Development</b></summary>
110
136
 
111
- - **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
- - **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.
137
+ ```bash
138
+ pnpm i
139
+ pnpm run dev # opens the demo — this and the line above are the whole setup
140
+ ```
114
141
 
115
- ## Development
142
+ Three more verbs, and that is the table:
116
143
 
117
144
  ```bash
118
- pnpm install
119
- pnpm test # baker core — pure CPU, no GPU needed
120
- pnpm typecheck
121
- pnpm build
122
- pnpm example # runs the robot-crowd demo in examples/ (model bundled)
145
+ pnpm test # every suite: the baker core, the release suite, the demo's own
146
+ pnpm typecheck # all three tsconfigs: library, release suite, demo
147
+ pnpm build # the published library (`pnpm run build:watch` to watch)
123
148
  ```
124
149
 
150
+ `examples/` is the demo — one page per renderer, self-contained on purpose
151
+ ([ADR-0011](./docs/adr/0011-one-example-per-renderer-duplicated-on-purpose.md)),
152
+ deployed from `main` on every push. Release steps are `node` invocations rather
153
+ than table entries, the parity gate among them and required
154
+ ([docs/releasing.md](./docs/releasing.md)). The suite is green on a fresh clone
155
+ with no network: real-asset tests skip when their asset is missing, so fetch it
156
+ before touching the baker ([docs/test-assets.md](./docs/test-assets.md)).
157
+
158
+ </details>
159
+
160
+ Deeper: [docs/](./docs) · [CHANGELOG.md](./CHANGELOG.md) · [live WebGPU demo](https://mikefernandez-pro.github.io/three-vat/webgpu_crowd.html)
161
+
125
162
  ## License
126
163
 
127
164
  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.1",
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",
@@ -52,22 +52,24 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/three": "^0.185.0",
55
+ "gifenc": "1.0.3",
56
+ "playwright-core": "1.63.0",
57
+ "pngjs": "7.0.0",
55
58
  "three": "^0.185.0",
56
59
  "tsup": "^8.3.0",
57
60
  "typescript": "^5.6.0",
61
+ "vite": "^7.1.0",
58
62
  "vitest": "^2.1.0"
59
63
  },
60
64
  "engines": {
61
65
  "node": ">=18"
62
66
  },
63
67
  "scripts": {
68
+ "dev": "pnpm --filter three-vat-example dev",
69
+ "test": "vitest run && pnpm --filter three-vat-example test",
64
70
  "build": "tsup",
65
- "dev": "tsup --watch",
66
- "test": "vitest run",
67
- "test:watch": "vitest",
68
- "typecheck": "tsc --noEmit",
69
- "release": "pnpm publish --otp=\"${NPM_OTP:?set NPM_OTP=<code from your authenticator>}\" && pnpm verify:published",
70
- "verify:published": "node scripts/verify-published.mjs",
71
- "example": "pnpm --dir examples dev"
71
+ "typecheck": "tsc --noEmit && tsc --noEmit -p release/tsconfig.json && pnpm --filter three-vat-example typecheck",
72
+ "build:watch": "tsup --watch",
73
+ "test:watch": "vitest"
72
74
  }
73
75
  }
@@ -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 };