three-vat 0.1.0 → 0.3.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,10 +1,13 @@
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)
5
+
3
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.
4
7
 
5
- 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 — with an optional offline path that produces the identical texture.
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.
6
9
 
7
- > **Status: v1 / pre-release.** The baker core and WebGL decode are ported from a browser-validated prototype. The TSL path is present but verified visually, not yet by automated tests. See [`docs/DESIGN.md`](./docs/DESIGN.md) and [`docs/adr/`](./docs/adr) for the full rationale.
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.
8
11
 
9
12
  ## Install
10
13
 
@@ -19,10 +22,28 @@ npm install three-vat three
19
22
  ```ts
20
23
  import { bakeVAT } from 'three-vat'
21
24
 
22
- // gltf loaded via GLTFLoader; sourceMesh is the SkinnedMesh in it.
25
+ // gltf loaded via GLTFLoader — pass the subtree root, not a mesh.
23
26
  const clips = gltf.animations.filter((c) => c.name !== 'TPose')
24
- const vat = bakeVAT(gltf.scene, sourceMesh, clips, { fps: 30 })
25
- // vat: { positionTexture, normalTexture, clips, bounds, vertexCount, totalFrames, encoding }
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.
37
+
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):
39
+
40
+ ```ts
41
+ import { getMaxTextureSize } from 'three-vat/webgl' // or 'three-vat/tsl'
42
+
43
+ const vat = bakeVAT(gltf.scene, clips, {
44
+ fps: 30,
45
+ maxTextureSize: getMaxTextureSize(renderer),
46
+ })
26
47
  ```
27
48
 
28
49
  ## Render a crowd — WebGL (`WebGLRenderer`)
@@ -32,15 +53,19 @@ import { addInstancedVATAttributes, createVATUniforms, createVATDepthMaterial, p
32
53
 
33
54
  const uniforms = createVATUniforms()
34
55
 
35
- const geometry = sourceMesh.geometry.clone()
56
+ // vat.geometry already carries the all-frames bounding box/sphere, so instances
57
+ // never cull mid-animation.
58
+ const geometry = vat.geometry.clone()
36
59
  addInstancedVATAttributes(geometry, instances) // instances: { clip, timeOffset, speed }[]
37
- geometry.boundingBox = vat.bounds.clone() // union of all frames — avoids culling pops
38
- geometry.boundingSphere = vat.bounds.getBoundingSphere(new THREE.Sphere())
39
60
 
40
- const material = sourceMesh.material.clone()
41
- patchVATMaterial(material, vat, uniforms)
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
+ })
42
67
 
43
- const mesh = new THREE.InstancedMesh(geometry, material, instances.length)
68
+ const mesh = new THREE.InstancedMesh(geometry, materials, instances.length)
44
69
  mesh.customDepthMaterial = createVATDepthMaterial(vat, uniforms) // correct instanced shadows
45
70
  mesh.castShadow = mesh.receiveShadow = true
46
71
 
@@ -65,17 +90,21 @@ time.value = clock.elapsedTime
65
90
 
66
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.
67
92
 
68
- ## Offline (bake once, ship the texture)
93
+ ## Offline format — deprecated, removed in 1.0
69
94
 
70
- ```ts
71
- import { serializeVAT, loadVAT } from 'three-vat'
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)).
72
98
 
73
- const { manifest, position, normal } = serializeVAT(vat, { precision: 'float16' })
74
- // write manifest as JSON, position/normal as .bin — then later:
75
- const vat = loadVAT({ manifest, position, normal })
76
- ```
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.
77
105
 
78
- The canonical format is a raw Float16 `.bin` + versioned JSON manifest (KTX2 rejected as default — its GPU compression doesn't apply to float data). The loaded VAT is interchangeable with a freshly-baked one.
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.
79
108
 
80
109
  ## Trade-offs
81
110
 
@@ -90,7 +119,7 @@ pnpm install
90
119
  pnpm test # baker core — pure CPU, no GPU needed
91
120
  pnpm typecheck
92
121
  pnpm build
93
- pnpm example # runs the crowd demo in examples/ (needs a Soldier.glb)
122
+ pnpm example # runs the robot-crowd demo in examples/ (model bundled)
94
123
  ```
95
124
 
96
125
  ## License
package/dist/index.d.ts CHANGED
@@ -1,19 +1,42 @@
1
- import { Object3D, SkinnedMesh, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
2
- import { V as VAT, a as VATClip } from './types-DVtQvVjk.js';
1
+ import { Object3D, AnimationClip, TypedArray, TextureDataType, DataTexture } from 'three';
2
+ import { B as BakedVAT, V as VATClip, a as VAT } from './types-wVmIj2tC.js';
3
3
 
4
- /** Maximum texture dimension we support; `vertexCount` must not exceed it. */
4
+ /**
5
+ * Conservative fallback texture-dimension cap, used when the caller does not
6
+ * pass `maxTextureSize`. This is the WebGL2 *spec floor for high-end desktop*,
7
+ * not a guarantee — plenty of mobile GPUs report 4096 or 8192. The baker is
8
+ * renderer-agnostic by design (it runs in Node, and in a Web Worker), so it cannot
9
+ * query the real limit itself: pass `getMaxTextureSize(renderer)` from
10
+ * `three-vat/webgl` or `three-vat/tsl` whenever a renderer exists.
11
+ */
5
12
  declare const MAX_TEXTURE_SIZE = 16384;
6
13
  interface BakeOptions {
7
14
  /** Sample rate in frames per second. Default `30`. */
8
15
  fps?: number;
16
+ /**
17
+ * Largest texture dimension the target GPU accepts. Both VAT axes are checked
18
+ * against it: `vertexCount` (width) and `totalFrames` (height). Defaults to
19
+ * {@link MAX_TEXTURE_SIZE}; pass the renderer's real limit to avoid baking a
20
+ * VAT that allocates on your desktop and fails on a phone.
21
+ */
22
+ maxTextureSize?: number;
9
23
  }
10
24
  /**
11
- * Bake skinned `AnimationClip`s into a VAT by sampling the posed mesh frame by
12
- * frame on the CPU. Positions are stored as deltas from the bind pose; normals
13
- * are stored absolute. Renderer-agnostic — touches no WebGL/WebGPU context — so
14
- * it runs identically at runtime and offline in Node.
25
+ * Bake `AnimationClip`s into a VAT by sampling the posed subtree frame by frame
26
+ * on the CPU.
27
+ *
28
+ * The unit of a bake is the whole subtree under `root`, merged into one vertex
29
+ * set and recorded in root space (ADR-0008) — so it handles a single
30
+ * `SkinnedMesh`, a morph-target mesh, a hierarchy of rigid node-animated parts
31
+ * (three.js `RobotExpressive`), or any mix of them, without the caller having
32
+ * to classify the asset. A VAT only records *where a vertex ended up*, never
33
+ * how it got there.
34
+ *
35
+ * Positions are stored as deltas from the merged rest pose; normals absolute.
36
+ * Renderer-agnostic — touches no WebGL/WebGPU context — so it runs identically
37
+ * at runtime, in a Web Worker, and in Node.
15
38
  */
16
- declare function bakeVAT(root: Object3D, skinnedMesh: SkinnedMesh, clips: AnimationClip[], { fps }?: BakeOptions): VAT;
39
+ declare function bakeVAT(root: Object3D, clips: AnimationClip[], { fps, maxTextureSize }?: BakeOptions): BakedVAT;
17
40
  /**
18
41
  * Build a VAT `DataTexture` with the fixed sampling flags every path relies on:
19
42
  * RGBA, nearest filtering, no mipmaps. Frame interpolation is done manually in
@@ -55,12 +78,14 @@ interface SerializeOptions {
55
78
  * the on-disk format; the runtime object is always reconstructed with
56
79
  * {@link loadVAT}.
57
80
  */
81
+ /** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
58
82
  declare function serializeVAT(vat: VAT, { precision }?: SerializeOptions): SerializedVAT;
59
83
  /**
60
84
  * Reconstruct a runtime VAT from serialized buffers. `float16` uploads as
61
85
  * `HalfFloatType`; `float32` as `FloatType`. The result is interchangeable with
62
86
  * a {@link bakeVAT} result.
63
87
  */
88
+ /** @deprecated Removed in 1.0 — see ADR-0010. Bake at runtime instead. */
64
89
  declare function loadVAT(serialized: SerializedVAT): VAT;
65
90
 
66
- export { type BakeOptions, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
91
+ export { type BakeOptions, BakedVAT, MAX_TEXTURE_SIZE, type SerializeOptions, type SerializedVAT, VAT, VATClip, type VATManifest, type VATPrecision, bakeVAT, loadVAT, makeVATTexture, serializeVAT };
package/dist/index.js CHANGED
@@ -1,24 +1,151 @@
1
- import { FloatType, AnimationMixer, Box3, Vector4, Matrix4, Vector3, DataTexture, RGBAFormat, NearestFilter, DataUtils, HalfFloatType } from 'three';
1
+ import { FloatType, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, DataTexture, RGBAFormat, NearestFilter, DataUtils, HalfFloatType, BufferGeometry, BufferAttribute } from 'three';
2
2
 
3
3
  // src/bake.ts
4
4
  var MAX_TEXTURE_SIZE = 16384;
5
- function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
6
- const geometry = skinnedMesh.geometry;
7
- const vertexCount = geometry.attributes.position.count;
8
- if (vertexCount > MAX_TEXTURE_SIZE) {
5
+ function asAttribute(value, mesh, name) {
6
+ if (!(value instanceof BufferAttribute)) {
9
7
  throw new Error(
10
- `three-vat: vertexCount ${vertexCount} exceeds MAX_TEXTURE_SIZE ${MAX_TEXTURE_SIZE}; row wrapping is not implemented`
8
+ `three-vat: mesh "${mesh.name || "(unnamed)"}" has an interleaved or unsupported "${name}" attribute; VAT bakes plain BufferAttributes`
9
+ );
10
+ }
11
+ return value;
12
+ }
13
+ function collectParts(root) {
14
+ const found = [];
15
+ root.traverse((o) => {
16
+ const mesh = o;
17
+ if (mesh.isMesh && mesh.geometry) found.push(mesh);
18
+ });
19
+ if (found.length === 0) {
20
+ throw new Error("three-vat: no Mesh found under root; nothing to bake");
21
+ }
22
+ const materials = [];
23
+ const parts = [];
24
+ for (const mesh of found) {
25
+ if (Array.isArray(mesh.material)) {
26
+ throw new Error(
27
+ `three-vat: mesh "${mesh.name || "(unnamed)"}" uses a material array; split it into one mesh per material before baking`
28
+ );
29
+ }
30
+ const geometry = mesh.geometry;
31
+ if (!geometry.attributes.normal) geometry.computeVertexNormals();
32
+ const material = mesh.material;
33
+ let materialIndex = materials.indexOf(material);
34
+ if (materialIndex === -1) materialIndex = materials.push(material) - 1;
35
+ const skinned = mesh;
36
+ parts.push({
37
+ mesh,
38
+ material,
39
+ materialIndex,
40
+ vertexStart: 0,
41
+ // assigned below, after material sorting
42
+ vertexCount: geometry.attributes.position.count,
43
+ basePos: asAttribute(geometry.attributes.position, mesh, "position"),
44
+ baseNrm: asAttribute(geometry.attributes.normal, mesh, "normal"),
45
+ isSkinned: !!geometry.attributes.skinWeight && !!skinned.skeleton,
46
+ skeleton: skinned.skeleton,
47
+ skinIndex: geometry.attributes.skinIndex,
48
+ skinWeight: geometry.attributes.skinWeight,
49
+ morphPos: geometry.morphAttributes.position,
50
+ morphRelative: geometry.morphTargetsRelative
51
+ });
52
+ }
53
+ parts.sort((a, b) => a.materialIndex - b.materialIndex);
54
+ let offset = 0;
55
+ for (const part of parts) {
56
+ part.vertexStart = offset;
57
+ offset += part.vertexCount;
58
+ }
59
+ return parts;
60
+ }
61
+ function mergeGeometry(parts, restMatrices, total) {
62
+ const position = new Float32Array(total * 3);
63
+ const normal = new Float32Array(total * 3);
64
+ const wantUV = parts.every((p) => !!p.mesh.geometry.attributes.uv);
65
+ const wantColor = parts.every((p) => !!p.mesh.geometry.attributes.color);
66
+ const uv = wantUV ? new Float32Array(total * 2) : null;
67
+ const color = wantColor ? new Float32Array(total * 3) : null;
68
+ const indices = [];
69
+ const groups = [];
70
+ const _v = new Vector3();
71
+ const _n = new Vector3();
72
+ parts.forEach((part, pi) => {
73
+ const m = restMatrices[pi];
74
+ const geometry = part.mesh.geometry;
75
+ const start = part.vertexStart;
76
+ const srcUV = uv ? asAttribute(geometry.attributes.uv, part.mesh, "uv") : null;
77
+ const srcColor = color ? asAttribute(geometry.attributes.color, part.mesh, "color") : null;
78
+ for (let v = 0; v < part.vertexCount; v++) {
79
+ _v.fromBufferAttribute(part.basePos, v).applyMatrix4(m);
80
+ _n.fromBufferAttribute(part.baseNrm, v).transformDirection(m);
81
+ const o3 = (start + v) * 3;
82
+ position[o3] = _v.x;
83
+ position[o3 + 1] = _v.y;
84
+ position[o3 + 2] = _v.z;
85
+ normal[o3] = _n.x;
86
+ normal[o3 + 1] = _n.y;
87
+ normal[o3 + 2] = _n.z;
88
+ if (uv && srcUV) {
89
+ uv[(start + v) * 2] = srcUV.getX(v);
90
+ uv[(start + v) * 2 + 1] = srcUV.getY(v);
91
+ }
92
+ if (color && srcColor) {
93
+ color[o3] = srcColor.getX(v);
94
+ color[o3 + 1] = srcColor.getY(v);
95
+ color[o3 + 2] = srcColor.getZ(v);
96
+ }
97
+ }
98
+ const groupStart = indices.length;
99
+ const index = geometry.index;
100
+ if (index) {
101
+ for (let i = 0; i < index.count; i++) indices.push(start + index.getX(i));
102
+ } else {
103
+ for (let v = 0; v < part.vertexCount; v++) indices.push(start + v);
104
+ }
105
+ const last = groups[groups.length - 1];
106
+ if (last && last.materialIndex === part.materialIndex) {
107
+ last.count += indices.length - groupStart;
108
+ } else {
109
+ groups.push({
110
+ start: groupStart,
111
+ count: indices.length - groupStart,
112
+ materialIndex: part.materialIndex
113
+ });
114
+ }
115
+ });
116
+ const merged = new BufferGeometry();
117
+ merged.setAttribute("position", new BufferAttribute(position, 3));
118
+ merged.setAttribute("normal", new BufferAttribute(normal, 3));
119
+ if (uv) merged.setAttribute("uv", new BufferAttribute(uv, 2));
120
+ if (color) merged.setAttribute("color", new BufferAttribute(color, 3));
121
+ merged.setIndex(indices);
122
+ for (const g of groups) merged.addGroup(g.start, g.count, g.materialIndex);
123
+ return merged;
124
+ }
125
+ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } = {}) {
126
+ root.updateMatrixWorld(true);
127
+ const parts = collectParts(root);
128
+ const vertexCount = parts.reduce((n, p) => n + p.vertexCount, 0);
129
+ if (vertexCount > maxTextureSize) {
130
+ throw new Error(
131
+ `three-vat: vertexCount ${vertexCount} exceeds maxTextureSize ${maxTextureSize}; row wrapping is not implemented`
11
132
  );
12
133
  }
13
134
  const frameCounts = clips.map((c) => Math.max(2, Math.round(c.duration * fps)));
14
135
  const totalFrames = frameCounts.reduce((a, b) => a + b, 0);
136
+ if (totalFrames > maxTextureSize) {
137
+ throw new Error(
138
+ `three-vat: totalFrames ${totalFrames} exceeds maxTextureSize ${maxTextureSize}; lower fps or bake fewer clips`
139
+ );
140
+ }
141
+ const rootInverse = root.matrixWorld.clone().invert();
142
+ const restMatrices = parts.map(
143
+ (p) => new Matrix4().multiplyMatrices(rootInverse, p.mesh.matrixWorld)
144
+ );
145
+ const geometry = mergeGeometry(parts, restMatrices, vertexCount);
146
+ const mergedBase = geometry.attributes.position;
15
147
  const posData = new Float32Array(vertexCount * totalFrames * 4);
16
148
  const nrmData = new Float32Array(vertexCount * totalFrames * 4);
17
- const basePos = geometry.attributes.position;
18
- const baseNrm = geometry.attributes.normal;
19
- const skinIndex = geometry.attributes.skinIndex;
20
- const skinWeight = geometry.attributes.skinWeight;
21
- const skeleton = skinnedMesh.skeleton;
22
149
  const mixer = new AnimationMixer(root);
23
150
  const bounds = new Box3();
24
151
  const clipTable = [];
@@ -27,9 +154,12 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
27
154
  const _bone = new Matrix4();
28
155
  const _acc = new Matrix4();
29
156
  const _skin = new Matrix4();
157
+ const _partMatrix = new Matrix4();
30
158
  const _p = new Vector3();
31
159
  const _bp = new Vector3();
32
160
  const _n = new Vector3();
161
+ const _mt = new Vector3();
162
+ const _mb = new Vector3();
33
163
  let rowOffset = 0;
34
164
  clips.forEach((clip, ci) => {
35
165
  const frames = frameCounts[ci];
@@ -40,37 +170,60 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
40
170
  mixer.setTime(f / frames * clip.duration);
41
171
  root.updateMatrixWorld(true);
42
172
  const row = rowOffset + f;
43
- for (let v = 0; v < vertexCount; v++) {
44
- _si.fromBufferAttribute(skinIndex, v);
45
- _sw.fromBufferAttribute(skinWeight, v);
46
- _acc.elements.fill(0);
47
- for (let i = 0; i < 4; i++) {
48
- const w = _sw.getComponent(i);
49
- if (w === 0) continue;
50
- const bi = _si.getComponent(i);
51
- _bone.multiplyMatrices(skeleton.bones[bi].matrixWorld, skeleton.boneInverses[bi]);
52
- const ae = _acc.elements;
53
- const be = _bone.elements;
54
- for (let e = 0; e < 16; e++) ae[e] += be[e] * w;
173
+ for (const part of parts) {
174
+ _partMatrix.multiplyMatrices(rootInverse, part.mesh.matrixWorld);
175
+ const influences = part.mesh.morphTargetInfluences;
176
+ const { morphPos, morphRelative, isSkinned, skeleton, skinIndex, skinWeight } = part;
177
+ for (let v = 0; v < part.vertexCount; v++) {
178
+ const vi = part.vertexStart + v;
179
+ _p.fromBufferAttribute(part.basePos, v);
180
+ _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++) {
184
+ const w = influences[t];
185
+ if (w === 0) continue;
186
+ _mt.fromBufferAttribute(morphPos[t], v);
187
+ if (!morphRelative) _mt.sub(_mb);
188
+ _p.addScaledVector(_mt, w);
189
+ }
190
+ }
191
+ if (isSkinned && skeleton) {
192
+ const skinned = part.mesh;
193
+ _si.fromBufferAttribute(skinIndex, v);
194
+ _sw.fromBufferAttribute(skinWeight, v);
195
+ _acc.elements.fill(0);
196
+ for (let i = 0; i < 4; i++) {
197
+ const w = _sw.getComponent(i);
198
+ if (w === 0) continue;
199
+ const bi = _si.getComponent(i);
200
+ _bone.multiplyMatrices(skeleton.bones[bi].matrixWorld, skeleton.boneInverses[bi]);
201
+ const ae = _acc.elements;
202
+ const be = _bone.elements;
203
+ for (let e = 0; e < 16; e++) ae[e] += be[e] * w;
204
+ }
205
+ _skin.multiplyMatrices(_acc, skinned.bindMatrix).premultiply(skinned.bindMatrixInverse);
206
+ _p.applyMatrix4(_skin);
207
+ _n.transformDirection(_skin);
208
+ }
209
+ _p.applyMatrix4(_partMatrix);
210
+ _n.transformDirection(_partMatrix);
211
+ bounds.expandByPoint(_p);
212
+ _bp.fromBufferAttribute(mergedBase, vi);
213
+ const o = (row * vertexCount + vi) * 4;
214
+ const dx = _p.x - _bp.x;
215
+ const dy = _p.y - _bp.y;
216
+ const dz = _p.z - _bp.z;
217
+ maxDeltaSq = Math.max(maxDeltaSq, dx * dx + dy * dy + dz * dz);
218
+ posData[o] = dx;
219
+ posData[o + 1] = dy;
220
+ posData[o + 2] = dz;
221
+ posData[o + 3] = 1;
222
+ nrmData[o] = _n.x;
223
+ nrmData[o + 1] = _n.y;
224
+ nrmData[o + 2] = _n.z;
225
+ nrmData[o + 3] = 1;
55
226
  }
56
- _skin.multiplyMatrices(_acc, skinnedMesh.bindMatrix).premultiply(skinnedMesh.bindMatrixInverse);
57
- _bp.fromBufferAttribute(basePos, v);
58
- _p.copy(_bp).applyMatrix4(_skin);
59
- bounds.expandByPoint(_p);
60
- _n.fromBufferAttribute(baseNrm, v).transformDirection(_skin);
61
- const o = (row * vertexCount + v) * 4;
62
- const dx = _p.x - _bp.x;
63
- const dy = _p.y - _bp.y;
64
- const dz = _p.z - _bp.z;
65
- maxDeltaSq = Math.max(maxDeltaSq, dx * dx + dy * dy + dz * dz);
66
- posData[o] = dx;
67
- posData[o + 1] = dy;
68
- posData[o + 2] = dz;
69
- posData[o + 3] = 1;
70
- nrmData[o] = _n.x;
71
- nrmData[o + 1] = _n.y;
72
- nrmData[o + 2] = _n.z;
73
- nrmData[o + 3] = 1;
74
227
  }
75
228
  }
76
229
  action.stop();
@@ -87,6 +240,10 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
87
240
  mixer.stopAllAction();
88
241
  mixer.setTime(0);
89
242
  root.updateMatrixWorld(true);
243
+ geometry.boundingBox = bounds.clone();
244
+ geometry.boundingSphere = bounds.getBoundingSphere(new Sphere());
245
+ const materials = [];
246
+ for (const part of parts) materials[part.materialIndex] = part.material;
90
247
  return {
91
248
  positionTexture: makeVATTexture(posData, vertexCount, totalFrames),
92
249
  normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames),
@@ -94,7 +251,9 @@ function bakeVAT(root, skinnedMesh, clips, { fps = 30 } = {}) {
94
251
  bounds,
95
252
  vertexCount,
96
253
  totalFrames,
97
- encoding: "delta"
254
+ encoding: "delta",
255
+ geometry,
256
+ materials
98
257
  };
99
258
  }
100
259
  function makeVATTexture(data, width, height, type = FloatType) {
package/dist/tsl.d.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import { Node } from 'three/webgpu';
2
- import { V as VAT } from './types-DVtQvVjk.js';
2
+ import { a as VAT } from './types-wVmIj2tC.js';
3
3
  import 'three';
4
4
 
5
+ /**
6
+ * The real maximum texture dimension this renderer accepts, for
7
+ * `bakeVAT(..., { maxTextureSize })`. The baker is renderer-agnostic (it runs
8
+ * in Node, and in a Web Worker) so it cannot query this itself.
9
+ *
10
+ * Call this *after* `await renderer.init()` — the backend has no device before
11
+ * that. Handles both a WebGPU backend and the WebGL fallback backend a
12
+ * `WebGPURenderer` may silently switch to when WebGPU is unavailable.
13
+ */
14
+ declare function getMaxTextureSize(renderer: object): number;
5
15
  /** A fluent TSL float node (has `.add`, `.mul`, … via NodeExtensions). */
6
16
  type FloatNode = Node<'float'>;
7
17
  /** A fluent TSL vec3 node. */
@@ -41,4 +51,4 @@ interface VATNodes {
41
51
  */
42
52
  declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
43
53
 
44
- export { type VATNodeOptions, type VATNodes, vatNodes };
54
+ export { type VATNodeOptions, type VATNodes, getMaxTextureSize, vatNodes };
package/dist/tsl.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { uniform, float, int, hash, instanceIndex, vertexIndex, positionLocal, textureLoad, ivec2, mix } from 'three/tsl';
2
2
 
3
3
  // src/tsl.ts
4
+ function getMaxTextureSize(renderer) {
5
+ const backend = renderer.backend;
6
+ const device = backend?.["device"];
7
+ if (device?.limits?.maxTextureDimension2D) return device.limits.maxTextureDimension2D;
8
+ const gl = backend?.["gl"];
9
+ if (gl) return gl.getParameter(gl.MAX_TEXTURE_SIZE);
10
+ throw new Error("three-vat: renderer has no initialized backend \u2014 call `await renderer.init()` first");
11
+ }
4
12
  function vatNodes(vat, options = {}) {
5
13
  const { time = uniform(0), clipIndex = 0, desync = 0 } = options;
6
14
  const clip = vat.clips[clipIndex];
@@ -27,4 +35,4 @@ function vatNodes(vat, options = {}) {
27
35
  };
28
36
  }
29
37
 
30
- export { vatNodes };
38
+ export { getMaxTextureSize, vatNodes };
@@ -1,4 +1,4 @@
1
- import { DataTexture, Box3 } from 'three';
1
+ import { DataTexture, Box3, BufferGeometry, Material } from 'three';
2
2
 
3
3
  /** One baked animation range within a VAT's stacked frame rows. */
4
4
  interface VATClip {
@@ -41,5 +41,23 @@ interface VAT {
41
41
  /** Position encoding. Only `'delta'` in v1. */
42
42
  encoding: 'delta';
43
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
+ }
44
62
 
45
- export type { VAT as V, VATClip as a };
63
+ export type { BakedVAT as B, VATClip as V, VAT as a };
package/dist/webgl.d.ts CHANGED
@@ -1,6 +1,15 @@
1
- import { IUniform, BufferGeometry, MeshDepthMaterial, Material } from 'three';
2
- import { V as VAT } from './types-DVtQvVjk.js';
1
+ import { IUniform, BufferGeometry, MeshDepthMaterial, WebGLRenderer, Material } from 'three';
2
+ import { a as VAT } from './types-wVmIj2tC.js';
3
3
 
4
+ /**
5
+ * The real maximum texture dimension this GPU accepts, for
6
+ * `bakeVAT(..., { maxTextureSize })`. The baker cannot query this itself — it
7
+ * is renderer-agnostic so it can run in Node or a Web Worker — so read it
8
+ * here and hand it over. Desktop typically reports 16384, but mobile GPUs
9
+ * commonly report 4096 or 8192, which is exactly the case a hardcoded default
10
+ * bakes straight past.
11
+ */
12
+ declare function getMaxTextureSize(renderer: WebGLRenderer): number;
4
13
  /** The shared uniform driving every VAT-patched material's playback clock. */
5
14
  interface VATUniforms {
6
15
  uVatTime: IUniform<number>;
@@ -35,4 +44,4 @@ declare function patchVATMaterial<T extends Material>(material: T, vat: VAT, uni
35
44
  */
36
45
  declare function createVATDepthMaterial(vat: VAT, uniforms: VATUniforms): MeshDepthMaterial;
37
46
 
38
- export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
47
+ export { type VATInstance, type VATUniforms, addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
package/dist/webgl.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { InstancedBufferAttribute, MeshDepthMaterial, RGBADepthPacking } from 'three';
2
2
 
3
3
  // src/webgl.ts
4
+ function getMaxTextureSize(renderer) {
5
+ return renderer.capabilities.maxTextureSize;
6
+ }
4
7
  function createVATUniforms(time = 0) {
5
8
  return { uVatTime: { value: time } };
6
9
  }
7
10
  function addInstancedVATAttributes(geometry, instances) {
11
+ geometry.morphAttributes = {};
12
+ geometry.morphTargetsRelative = false;
8
13
  const n = instances.length;
9
14
  const clipStart = new Float32Array(n);
10
15
  const clipFrames = new Float32Array(n);
@@ -78,4 +83,4 @@ function createVATDepthMaterial(vat, uniforms) {
78
83
  return depth;
79
84
  }
80
85
 
81
- export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, patchVATMaterial };
86
+ export { addInstancedVATAttributes, createVATDepthMaterial, createVATUniforms, getMaxTextureSize, patchVATMaterial };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-vat",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -34,15 +34,6 @@
34
34
  "import": "./dist/tsl.js"
35
35
  }
36
36
  },
37
- "scripts": {
38
- "build": "tsup",
39
- "dev": "tsup --watch",
40
- "test": "vitest run",
41
- "test:watch": "vitest",
42
- "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build",
44
- "example": "pnpm --dir examples dev"
45
- },
46
37
  "keywords": [
47
38
  "three",
48
39
  "threejs",
@@ -68,5 +59,15 @@
68
59
  },
69
60
  "engines": {
70
61
  "node": ">=18"
62
+ },
63
+ "scripts": {
64
+ "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
72
  }
72
- }
73
+ }