three-vat 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,54 @@
1
- export { addVATInstanceAttributes } from './chunk-SXTVASKG.js';
2
- import { FloatType, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, DataTexture, RGBAFormat, NearestFilter, BufferGeometry, BufferAttribute } from 'three';
1
+ import { LoopMode, MAX_TEXTURE_SIZE, makeVATTexture, LIBRARY_PLAYBACK_DEFAULTS, FORWARD_ONLY_REASON, INFINITE_REPETITIONS } from './chunk-2PGZ44TP.js';
2
+ export { EndMode, INFINITE_REPETITIONS, LoopMode, MAX_FADE_DURATION, MAX_TEXTURE_SIZE, createVATPlaybackTexture, endsAt, makeVATTexture, resolveVATFrame, setVATInstance } from './chunk-2PGZ44TP.js';
3
+ import { LoopRepeat, LoopOnce, LoopPingPong, Matrix4, AnimationMixer, Box3, Vector4, Vector3, Sphere, AdditiveAnimationBlendMode, BufferAttribute, BufferGeometry } from 'three';
3
4
 
4
- var MAX_TEXTURE_SIZE = 16384;
5
+ var LOOP_MODES = /* @__PURE__ */ new Map([
6
+ [LoopRepeat, LoopMode.Repeat],
7
+ [LoopOnce, LoopMode.Once],
8
+ [LoopPingPong, LoopMode.PingPong]
9
+ ]);
10
+ function isAction(input) {
11
+ return typeof input.getClip === "function";
12
+ }
13
+ function resolveAnimation(input) {
14
+ if (!isAction(input)) return { clip: input, defaults: { ...LIBRARY_PLAYBACK_DEFAULTS } };
15
+ const clip = input.getClip();
16
+ const name = clip.name || "(unnamed)";
17
+ const cannotBlend = (field, value) => new Error(
18
+ `three-vat: action for clip "${name}" has ${field} ${value}; that blends several actions at once, which a single baked band cannot represent \u2014 bake the clips separately and crossfade between them (three-vat#30), or reset the action before baking`
19
+ );
20
+ if (input.weight !== 1) throw cannotBlend("weight", String(input.weight));
21
+ if (input.blendMode === AdditiveAnimationBlendMode) throw cannotBlend("blendMode", "additive");
22
+ if (input.timeScale < 0) {
23
+ throw new Error(
24
+ `three-vat: action for clip "${name}" has timeScale ${input.timeScale}; ${FORWARD_ONLY_REASON}.`
25
+ );
26
+ }
27
+ const loopMode = LOOP_MODES.get(input.loop);
28
+ if (loopMode === void 0) {
29
+ throw new Error(
30
+ `three-vat: action for clip "${name}" has an unrecognised loop mode ${input.loop}; expected THREE.LoopRepeat, LoopOnce or LoopPingPong`
31
+ );
32
+ }
33
+ return {
34
+ clip,
35
+ defaults: {
36
+ loopMode,
37
+ // three's `repetitions` defaults to Infinity and `LoopOnce` ignores it
38
+ // outright — so a one-shot is one play whatever the field says, and an
39
+ // endless count becomes the sentinel a Float32Array can carry.
40
+ repetitions: loopMode === LoopMode.Once ? 1 : Number.isFinite(input.repetitions) ? input.repetitions : INFINITE_REPETITIONS,
41
+ // Not read off the action (see the rule above), so a bake clamps whichever
42
+ // input it was handed. Uniform across loop modes on purpose: an instance
43
+ // may override a repeating clip's `loopMode` to `Once` and inherit this
44
+ // field, so there is no mode whose end mode is safely unreachable and
45
+ // could hold a different answer. Rewind is per instance:
46
+ // `{ ..., endMode: EndMode.Rewind }` (#43, ADR-0017).
47
+ endMode: LIBRARY_PLAYBACK_DEFAULTS.endMode,
48
+ speed: input.timeScale
49
+ }
50
+ };
51
+ }
5
52
  function asAttribute(value, mesh, name) {
6
53
  if (!(value instanceof BufferAttribute)) {
7
54
  throw new Error(
@@ -44,6 +91,8 @@ function collectParts(root) {
44
91
  baseNrm: asAttribute(geometry.attributes.normal, mesh, "normal"),
45
92
  isSkinned: !!geometry.attributes.skinWeight && !!skinned.skeleton,
46
93
  skeleton: skinned.skeleton,
94
+ pose: void 0,
95
+ // assigned below, once the distinct rigs are known
47
96
  skinIndex: geometry.attributes.skinIndex,
48
97
  skinWeight: geometry.attributes.skinWeight,
49
98
  morphPos: geometry.morphAttributes.position,
@@ -64,18 +113,25 @@ function mergeGeometry(parts, restMatrices, total) {
64
113
  const normal = new Float32Array(total * 3);
65
114
  const wantUV = parts.every((p) => !!p.mesh.geometry.attributes.uv);
66
115
  const wantColor = parts.every((p) => !!p.mesh.geometry.attributes.color);
116
+ const wantTangent = parts.every((p) => {
117
+ const t = p.mesh.geometry.attributes.tangent;
118
+ return t instanceof BufferAttribute && t.itemSize === 4;
119
+ });
67
120
  const uv = wantUV ? new Float32Array(total * 2) : null;
68
121
  const color = wantColor ? new Float32Array(total * 3) : null;
122
+ const tangent = wantTangent ? new Float32Array(total * 4) : null;
69
123
  const indices = [];
70
124
  const groups = [];
71
125
  const _v = new Vector3();
72
126
  const _n = new Vector3();
127
+ const _t = new Vector3();
73
128
  parts.forEach((part, pi) => {
74
129
  const m = restMatrices[pi];
75
130
  const geometry = part.mesh.geometry;
76
131
  const start = part.vertexStart;
77
132
  const srcUV = uv ? asAttribute(geometry.attributes.uv, part.mesh, "uv") : null;
78
133
  const srcColor = color ? asAttribute(geometry.attributes.color, part.mesh, "color") : null;
134
+ const srcTangent = tangent ? geometry.attributes.tangent : null;
79
135
  for (let v = 0; v < part.vertexCount; v++) {
80
136
  _v.fromBufferAttribute(part.basePos, v).applyMatrix4(m);
81
137
  _n.fromBufferAttribute(part.baseNrm, v).transformDirection(m);
@@ -95,6 +151,14 @@ function mergeGeometry(parts, restMatrices, total) {
95
151
  color[o3 + 1] = srcColor.getY(v);
96
152
  color[o3 + 2] = srcColor.getZ(v);
97
153
  }
154
+ if (tangent && srcTangent) {
155
+ _t.fromBufferAttribute(srcTangent, v).transformDirection(m);
156
+ const o4 = (start + v) * 4;
157
+ tangent[o4] = _t.x;
158
+ tangent[o4 + 1] = _t.y;
159
+ tangent[o4 + 2] = _t.z;
160
+ tangent[o4 + 3] = srcTangent.getW(v);
161
+ }
98
162
  }
99
163
  const groupStart = indices.length;
100
164
  const index = geometry.index;
@@ -119,11 +183,14 @@ function mergeGeometry(parts, restMatrices, total) {
119
183
  merged.setAttribute("normal", new BufferAttribute(normal, 3));
120
184
  if (uv) merged.setAttribute("uv", new BufferAttribute(uv, 2));
121
185
  if (color) merged.setAttribute("color", new BufferAttribute(color, 3));
186
+ if (tangent) merged.setAttribute("tangent", new BufferAttribute(tangent, 4));
122
187
  merged.setIndex(indices);
123
188
  for (const g of groups) merged.addGroup(g.start, g.count, g.materialIndex);
124
189
  return merged;
125
190
  }
126
- function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } = {}) {
191
+ function bakeVAT(root, animations, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE, bakeNormals = true } = {}) {
192
+ const resolved = animations.map(resolveAnimation);
193
+ const clips = resolved.map((a) => a.clip);
127
194
  root.updateMatrixWorld(true);
128
195
  const parts = collectParts(root);
129
196
  const vertexCount = parts.reduce((n, p) => n + p.vertexCount, 0);
@@ -146,13 +213,13 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
146
213
  const geometry = mergeGeometry(parts, restMatrices, vertexCount);
147
214
  const mergedBase = geometry.attributes.position;
148
215
  const posData = new Float32Array(vertexCount * totalFrames * 4);
149
- const nrmData = new Float32Array(vertexCount * totalFrames * 4);
216
+ const nrmData = bakeNormals ? new Float32Array(vertexCount * totalFrames * 4) : null;
217
+ const bakeNormal = nrmData !== null;
150
218
  const mixer = new AnimationMixer(root);
151
219
  const bounds = new Box3();
152
220
  const clipTable = [];
153
221
  const _si = new Vector4();
154
222
  const _sw = new Vector4();
155
- const _bone = new Matrix4();
156
223
  const _acc = new Matrix4();
157
224
  const _skin = new Matrix4();
158
225
  const _partMatrix = new Matrix4();
@@ -162,7 +229,8 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
162
229
  const _mt = new Vector3();
163
230
  const _mb = new Vector3();
164
231
  const _mbn = new Vector3();
165
- const influencers = parts.filter((p) => p.isSkinned && p.skeleton).map(influencedBones);
232
+ const poses = attachPoseBuffers(parts);
233
+ const influencers = bakeNormals ? parts.filter((p) => p.pose).map(influencedBones) : [];
166
234
  let warnedNonUniformScale = false;
167
235
  let rowOffset = 0;
168
236
  clips.forEach((clip, ci) => {
@@ -174,22 +242,24 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
174
242
  mixer.setTime(f / frames * clip.duration);
175
243
  root.updateMatrixWorld(true);
176
244
  const row = rowOffset + f;
245
+ poseSkeletons(poses);
177
246
  if (!warnedNonUniformScale) {
178
- warnedNonUniformScale = warnOnNonUniformBoneScale(influencers, _bone);
247
+ warnedNonUniformScale = warnOnNonUniformBoneScale(influencers);
179
248
  }
180
249
  for (const part of parts) {
181
250
  _partMatrix.multiplyMatrices(rootInverse, part.mesh.matrixWorld);
182
251
  const influences = part.mesh.morphTargetInfluences;
183
- const { morphPos, morphNrm, morphRelative, isSkinned, skeleton, skinIndex, skinWeight } = part;
252
+ const { morphPos, morphNrm, morphRelative, isSkinned, skinIndex, skinWeight } = part;
253
+ const boneMatrices = part.pose?.matrices;
184
254
  const morphCount = influences ? Math.min(influences.length, Math.max(morphPos?.length ?? 0, morphNrm?.length ?? 0)) : 0;
185
255
  for (let v = 0; v < part.vertexCount; v++) {
186
256
  const vi = part.vertexStart + v;
187
257
  _p.fromBufferAttribute(part.basePos, v);
188
- _n.fromBufferAttribute(part.baseNrm, v);
258
+ if (bakeNormal) _n.fromBufferAttribute(part.baseNrm, v);
189
259
  if (influences && morphCount > 0) {
190
260
  if (!morphRelative) {
191
261
  _mb.fromBufferAttribute(part.basePos, v);
192
- _mbn.fromBufferAttribute(part.baseNrm, v);
262
+ if (bakeNormal) _mbn.fromBufferAttribute(part.baseNrm, v);
193
263
  }
194
264
  for (let t = 0; t < morphCount; t++) {
195
265
  const w = influences[t];
@@ -200,7 +270,7 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
200
270
  if (!morphRelative) _mt.sub(_mb);
201
271
  _p.addScaledVector(_mt, w);
202
272
  }
203
- const targetNrm = morphNrm?.[t];
273
+ const targetNrm = bakeNormal ? morphNrm?.[t] : void 0;
204
274
  if (targetNrm) {
205
275
  _mt.fromBufferAttribute(targetNrm, v);
206
276
  if (!morphRelative) _mt.sub(_mbn);
@@ -208,26 +278,24 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
208
278
  }
209
279
  }
210
280
  }
211
- if (isSkinned && skeleton) {
281
+ if (isSkinned && boneMatrices) {
212
282
  const skinned = part.mesh;
213
283
  _si.fromBufferAttribute(skinIndex, v);
214
284
  _sw.fromBufferAttribute(skinWeight, v);
215
- _acc.elements.fill(0);
285
+ const ae = _acc.elements;
286
+ ae.fill(0);
216
287
  for (let i = 0; i < 4; i++) {
217
288
  const w = _sw.getComponent(i);
218
289
  if (w === 0) continue;
219
- const bi = _si.getComponent(i);
220
- _bone.multiplyMatrices(skeleton.bones[bi].matrixWorld, skeleton.boneInverses[bi]);
221
- const ae = _acc.elements;
222
- const be = _bone.elements;
223
- for (let e = 0; e < 16; e++) ae[e] += be[e] * w;
290
+ const b = _si.getComponent(i) * BONE_STRIDE;
291
+ for (let e = 0; e < 16; e++) ae[e] += boneMatrices[b + e] * w;
224
292
  }
225
293
  _skin.multiplyMatrices(_acc, skinned.bindMatrix).premultiply(skinned.bindMatrixInverse);
226
294
  _p.applyMatrix4(_skin);
227
- _n.transformDirection(_skin);
295
+ if (bakeNormal) _n.transformDirection(_skin);
228
296
  }
229
297
  _p.applyMatrix4(_partMatrix);
230
- _n.transformDirection(_partMatrix);
298
+ if (bakeNormal) _n.transformDirection(_partMatrix);
231
299
  bounds.expandByPoint(_p);
232
300
  _bp.fromBufferAttribute(mergedBase, vi);
233
301
  const o = (row * vertexCount + vi) * 4;
@@ -239,10 +307,12 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
239
307
  posData[o + 1] = dy;
240
308
  posData[o + 2] = dz;
241
309
  posData[o + 3] = 1;
242
- nrmData[o] = _n.x;
243
- nrmData[o + 1] = _n.y;
244
- nrmData[o + 2] = _n.z;
245
- nrmData[o + 3] = 1;
310
+ if (bakeNormal) {
311
+ nrmData[o] = _n.x;
312
+ nrmData[o + 1] = _n.y;
313
+ nrmData[o + 2] = _n.z;
314
+ nrmData[o + 3] = 1;
315
+ }
246
316
  }
247
317
  }
248
318
  }
@@ -253,7 +323,10 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
253
323
  frames,
254
324
  fps: frames / clip.duration,
255
325
  duration: clip.duration,
256
- maxDelta: Math.sqrt(maxDeltaSq)
326
+ maxDelta: Math.sqrt(maxDeltaSq),
327
+ // Declared once, here, rather than repeated at every instance that plays
328
+ // this band. An instance overrides any of them, field by field.
329
+ ...resolved[ci].defaults
257
330
  });
258
331
  rowOffset += frames;
259
332
  });
@@ -266,7 +339,7 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
266
339
  for (const part of parts) materials[part.materialIndex] = part.material;
267
340
  return {
268
341
  positionTexture: makeVATTexture(posData, vertexCount, totalFrames),
269
- normalTexture: makeVATTexture(nrmData, vertexCount, totalFrames),
342
+ normalTexture: nrmData ? makeVATTexture(nrmData, vertexCount, totalFrames) : null,
270
343
  clips: clipTable,
271
344
  bounds,
272
345
  vertexCount,
@@ -276,6 +349,32 @@ function bakeVAT(root, clips, { fps = 30, maxTextureSize = MAX_TEXTURE_SIZE } =
276
349
  materials
277
350
  };
278
351
  }
352
+ var BONE_STRIDE = 16;
353
+ var IDENTITY = /* @__PURE__ */ new Matrix4();
354
+ function attachPoseBuffers(parts) {
355
+ const byRig = /* @__PURE__ */ new Map();
356
+ for (const part of parts) {
357
+ if (!part.isSkinned || !part.skeleton) continue;
358
+ let pose = byRig.get(part.skeleton);
359
+ if (!pose) {
360
+ const matrices = new Float64Array(part.skeleton.bones.length * BONE_STRIDE);
361
+ pose = { skeleton: part.skeleton, matrices };
362
+ byRig.set(part.skeleton, pose);
363
+ }
364
+ part.pose = pose;
365
+ }
366
+ return [...byRig.values()];
367
+ }
368
+ function poseSkeletons(poses) {
369
+ const scratch = new Matrix4();
370
+ for (const { skeleton, matrices } of poses) {
371
+ const { bones, boneInverses } = skeleton;
372
+ for (let b = 0; b < bones.length; b++) {
373
+ scratch.multiplyMatrices(bones[b]?.matrixWorld ?? IDENTITY, boneInverses[b]);
374
+ matrices.set(scratch.elements, b * BONE_STRIDE);
375
+ }
376
+ }
377
+ }
279
378
  function influencedBones(part) {
280
379
  const used = /* @__PURE__ */ new Set();
281
380
  const index = part.skinIndex;
@@ -285,37 +384,28 @@ function influencedBones(part) {
285
384
  if (weight.getComponent(v, i) !== 0) used.add(index.getComponent(v, i));
286
385
  }
287
386
  }
288
- return { skeleton: part.skeleton, bones: [...used] };
387
+ return { pose: part.pose, bones: [...used] };
289
388
  }
290
389
  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];
390
+ function hasNonUniformScale(matrices, b) {
391
+ const o = b * BONE_STRIDE;
392
+ const x = matrices[o] ** 2 + matrices[o + 1] ** 2 + matrices[o + 2] ** 2;
393
+ const y = matrices[o + 4] ** 2 + matrices[o + 5] ** 2 + matrices[o + 6] ** 2;
394
+ const z = matrices[o + 8] ** 2 + matrices[o + 9] ** 2 + matrices[o + 10] ** 2;
296
395
  const max = Math.max(x, y, z);
297
396
  return max - Math.min(x, y, z) > SCALE_UNIFORMITY_EPSILON * max;
298
397
  }
299
- function warnOnNonUniformBoneScale(influencers, scratch) {
300
- for (const { skeleton, bones } of influencers) {
398
+ function warnOnNonUniformBoneScale(influencers) {
399
+ for (const { pose, bones } of influencers) {
301
400
  for (const b of bones) {
302
- scratch.multiplyMatrices(skeleton.bones[b].matrixWorld, skeleton.boneInverses[b]);
303
- if (!hasNonUniformScale(scratch)) continue;
401
+ if (!hasNonUniformScale(pose.matrices, b)) continue;
304
402
  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.`
403
+ `three-vat: bone "${pose.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
404
  );
307
405
  return true;
308
406
  }
309
407
  }
310
408
  return false;
311
409
  }
312
- function makeVATTexture(data, width, height, type = FloatType) {
313
- const tex = new DataTexture(data, width, height, RGBAFormat, type);
314
- tex.minFilter = NearestFilter;
315
- tex.magFilter = NearestFilter;
316
- tex.generateMipmaps = false;
317
- tex.needsUpdate = true;
318
- return tex;
319
- }
320
410
 
321
- export { MAX_TEXTURE_SIZE, bakeVAT, makeVATTexture };
411
+ export { bakeVAT };
package/dist/tsl.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { BufferGeometry, InstancedMesh } from 'three';
2
1
  import { Node } from 'three/webgpu';
3
- import { b as VATClock, V as VAT, d as VATInstance, c as VATCrowd } from './instance-playback-BrGBIKLe.js';
2
+ import { d as VATClock, i as VATPlaybackTexture, a as VATCarrier, V as VAT, h as VATInstance, e as VATCrowd } from './carrier-BFCPmcQK.js';
3
+ import 'three';
4
4
 
5
5
  /**
6
6
  * The real maximum texture dimension this renderer accepts, for
@@ -31,40 +31,50 @@ interface VATNodeOptions {
31
31
  */
32
32
  time?: VATTimeUniform;
33
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`.
34
+ * The crowd's playback texture — build it with `createVATPlaybackTexture`
35
+ * from `three-vat` *before* calling this, or let `createVATMesh` do it — and
36
+ * each instance plays its own clip, at its own phase and rate, read from its
37
+ * own row — which row that is, is the carrier's answer (see `carrier`). Without
38
+ * it, every instance plays `clipIndex`, phase-desynced by `desync`.
39
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.
40
+ * Which decode the graph compiles is decided here, at build time: the
41
+ * fallback is a different graph, not a shader-side branch.
43
42
  */
44
- geometry?: BufferGeometry;
43
+ playback?: VATPlaybackTexture;
45
44
  /**
46
- * The `InstancedMesh` these nodes will render, when there is one.
45
+ * The crowd's **carrier** — the mesh these nodes will render on, an
46
+ * `InstancedMesh` or a `BatchedMesh`. Named as the WebGL path's
47
+ * `patchVATMaterial` names it, because it is the one concept (CONTEXT.md).
47
48
  *
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.
49
+ * Required for a crowd, and for two reasons. First: three applies the
50
+ * carrier's transform to `positionLocal` *before* it reads `positionNode`, so
51
+ * the decode has to displace in the geometry's own space and then re-apply
52
+ * that transform itself. Without this the delta is added in instance space —
53
+ * unrotated and unscaled — and every instance deforms according to its own
54
+ * matrix. Second: the carrier decides how this instance's *logical* index is
55
+ * spelled, which is the row of the playback texture the pack is read from —
56
+ * `instanceIndex` on an `InstancedMesh`, `batchIndirectIndex` on a
57
+ * `BatchedMesh`, whose drawn slot is a permutation that changes every frame
58
+ * (ADR-0016).
59
+ *
60
+ * One option rather than one per carrier, because those two answers have to
61
+ * come from the same object: a decode that re-applied one mesh's transform
62
+ * while reading another's index would render a crowd nothing could explain.
53
63
  *
54
64
  * Omit it for a single, non-instanced mesh, where `positionLocal` is the
55
65
  * geometry position and there is nothing to re-apply.
56
66
  */
57
- instancedMesh?: InstancedMesh;
67
+ carrier?: VATCarrier;
58
68
  /**
59
69
  * 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`.
70
+ * `desync` — when a `playback` texture is given, which says all of this per
71
+ * instance. Default `0`.
62
72
  */
63
73
  clipIndex?: number;
64
74
  /**
65
- * Max random per-instance time offset in seconds, hashed from `instanceIndex`.
66
- * `0` (default) plays every instance in lockstep. Ignored when `geometry`
67
- * carries instance playback.
75
+ * Max random per-instance time offset in seconds, hashed from the instance's
76
+ * logical index. `0` (default) plays every instance in lockstep. Ignored when
77
+ * a `playback` texture is given.
68
78
  */
69
79
  desync?: number;
70
80
  }
@@ -97,11 +107,11 @@ interface VATNodes {
97
107
  * Build TSL decode nodes for a baked VAT, for the WebGPU/TSL renderer path.
98
108
  * Shadows work automatically because `positionNode` also feeds the depth pass.
99
109
  *
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
110
+ * Pass the crowd's `playback` texture and each instance plays the clip, phase
111
+ * and rate written into its row by `createVATPlaybackTexture` — the same
102
112
  * 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`.
113
+ * crowd renders identically on either renderer. Without it every instance plays
114
+ * `clipIndex`, desynced by a phase hashed from the instance index.
105
115
  *
106
116
  * Coverage note: the node graph is tested structurally in CI (no GPU); that the
107
117
  * two paths decode *identically* is a pixel-diff release gate.
@@ -110,6 +120,7 @@ declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
110
120
  /**
111
121
  * The decode's arithmetic: the position delta and the normal this instance reads
112
122
  * at this moment, as nodes — before the vertex-stage writes that place them.
123
+ * `normal` is `null` when the VAT was baked without a normal texture.
113
124
  *
114
125
  * @internal Split out and exported for the structural tests. A `Fn` body is
115
126
  * opaque to graph traversal (its statements are not built until the shader is),
@@ -119,7 +130,7 @@ declare function vatNodes(vat: VAT, options?: VATNodeOptions): VATNodes;
119
130
  */
120
131
  declare function vatDecode(vat: VAT, options?: VATNodeOptions): {
121
132
  position: Vec3Node;
122
- normal: Vec3Node;
133
+ normal: Vec3Node | null;
123
134
  };
124
135
  /** Options for {@link createVATMesh}. */
125
136
  interface CreateVATMeshOptions {
@@ -137,11 +148,12 @@ interface CreateVATMeshOptions {
137
148
  }
138
149
  /**
139
150
  * 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.
151
+ * `InstancedMesh` rendering the bake's geometry, a playback texture carrying
152
+ * the instance-playback contract, and materials that decode the VAT on the
153
+ * vertex stage.
142
154
  *
143
155
  * ```ts
144
- * const { mesh, time } = createVATMesh(vat, instances)
156
+ * const { mesh, time, playback } = createVATMesh(vat, instances)
145
157
  * mesh.castShadow = mesh.receiveShadow = true
146
158
  * scene.add(mesh)
147
159
  * // per frame:
package/dist/tsl.js CHANGED
@@ -1,6 +1,7 @@
1
- import { createCrowdGeometry, PLAYBACK_ATTRIBUTES } from './chunk-SXTVASKG.js';
1
+ import { isBatchedCarrier, assertVATCarrier, assertBakedNormal } from './chunk-W2ZAFMPB.js';
2
+ import { INFINITE_REPETITIONS, LoopMode, EndMode, createVATPlaybackTexture, PACK_TEXELS } from './chunk-2PGZ44TP.js';
2
3
  import { InstancedMesh } from 'three';
3
- import { uniform, Fn, positionLocal, positionGeometry, normalLocal, instancedMesh, int, vertexIndex, textureLoad, ivec2, mix, float, hash, instanceIndex, attribute } from 'three/tsl';
4
+ import { uniform, Fn, positionLocal, positionGeometry, normalLocal, batch, instancedMesh, int, vertexIndex, float, bool, textureLoad, ivec2, mix, batchIndirectIndex, instanceIndex, hash } from 'three/tsl';
4
5
 
5
6
  function getMaxTextureSize(renderer) {
6
7
  const backend = renderer.backend;
@@ -10,17 +11,30 @@ function getMaxTextureSize(renderer) {
10
11
  if (gl) return gl.getParameter(gl.MAX_TEXTURE_SIZE);
11
12
  throw new Error("three-vat: renderer has no initialized backend \u2014 call `await renderer.init()` first");
12
13
  }
13
- var floatAttribute = (name) => attribute(name, "float");
14
- function attributePlayback() {
15
- const frames = floatAttribute(PLAYBACK_ATTRIBUTES.clipFrames);
14
+ var packTexel = (texture, field, instance) => textureLoad(texture, ivec2(int(field), instance));
15
+ function texturePlayback(texture, instance) {
16
+ const clip = packTexel(texture, PACK_TEXELS.clip, instance);
17
+ const playback = packTexel(texture, PACK_TEXELS.playback, instance);
18
+ const fade = packTexel(texture, PACK_TEXELS.fade, instance);
19
+ const frames = clip.y;
16
20
  return {
17
- startFrame: int(floatAttribute(PLAYBACK_ATTRIBUTES.clipStart)),
21
+ startFrame: int(clip.x),
18
22
  frames,
19
- duration: frames.div(floatAttribute(PLAYBACK_ATTRIBUTES.clipFps)),
20
- timeOffset: floatAttribute(PLAYBACK_ATTRIBUTES.timeOffset),
21
- speed: floatAttribute(PLAYBACK_ATTRIBUTES.speed)
23
+ duration: frames.div(clip.z),
24
+ startTime: playback.x,
25
+ speed: clip.w,
26
+ loopMode: playback.y,
27
+ repetitions: playback.z,
28
+ endMode: playback.w,
29
+ fade: {
30
+ startFrame: fade.x,
31
+ frames: fade.y,
32
+ phase: fade.z,
33
+ duration: fade.w
34
+ }
22
35
  };
23
36
  }
37
+ var instanceIdOf = (carrier) => isBatchedCarrier(carrier) ? int(batchIndirectIndex) : int(instanceIndex);
24
38
  function clipAt(vat, clipIndex) {
25
39
  const clip = vat.clips[clipIndex];
26
40
  if (!clip) {
@@ -28,60 +42,102 @@ function clipAt(vat, clipIndex) {
28
42
  }
29
43
  return clip;
30
44
  }
31
- function hashedPlayback(clip, desync) {
45
+ function hashedPlayback(clip, desync, instance) {
32
46
  return {
33
47
  startFrame: int(clip.startFrame),
34
48
  frames: float(clip.frames),
35
49
  duration: float(clip.frames / clip.fps),
36
- timeOffset: hash(instanceIndex).mul(desync),
37
- speed: float(1)
50
+ // Negated, because desync is now a start time in the *past*: an instance
51
+ // that began `desync` seconds ago is that far into its clip already.
52
+ startTime: hash(instance).mul(-desync),
53
+ // Everything but the phase comes from the clip's own baked defaults, so a
54
+ // clip baked "once, clamped, at 2x" plays that way here too. A second set
55
+ // of defaults living in this path would be a crowd that animates
56
+ // differently depending on whether anyone wrote the attributes.
57
+ speed: float(clip.speed),
58
+ loopMode: float(clip.loopMode),
59
+ repetitions: float(clip.repetitions),
60
+ endMode: float(clip.endMode),
61
+ // Nothing to fade out of: this path is the zero-config default, where an
62
+ // instance has never been written and so has no animation it left behind.
63
+ fade: { startFrame: float(0), frames: float(0), phase: float(0), duration: float(0) }
38
64
  };
39
65
  }
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
66
  function vatNodes(vat, options = {}) {
50
- const { time = uniform(0), instancedMesh: instanced } = options;
67
+ const { time = uniform(0), carrier } = options;
51
68
  const { position, normal } = vatDecode(vat, options);
52
69
  const decode = Fn(() => {
53
- positionLocal.assign((instanced ? positionGeometry : positionLocal).add(position));
54
- normalLocal.assign(normal);
55
- if (instanced) instancedMesh(instanced);
70
+ positionLocal.assign((carrier ? positionGeometry : positionLocal).add(position));
71
+ if (normal) normalLocal.assign(normal);
72
+ if (carrier) {
73
+ if (isBatchedCarrier(carrier)) batch(carrier);
74
+ else instancedMesh(carrier);
75
+ }
56
76
  return positionLocal;
57
77
  }, "vec3");
58
78
  return { positionNode: decode(), time };
59
79
  }
60
80
  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);
81
+ const { time = uniform(0), playback: playbackTexture, carrier, clipIndex = 0, desync = 0 } = options;
82
+ if (carrier) assertVATCarrier(carrier, vat);
83
+ const instance = instanceIdOf(carrier);
84
+ const playback = playbackTexture ? texturePlayback(playbackTexture.texture, instance) : hashedPlayback(clipAt(vat, clipIndex), desync, instance);
63
85
  const vertexRow = int(vertexIndex);
86
+ const frames = playback.frames;
87
+ const last = frames.sub(1);
88
+ const elapsed = time.sub(playback.startTime);
89
+ const local = elapsed.mul(playback.speed);
90
+ const loops = local.div(playback.duration);
91
+ const started = local.greaterThanEqual(0);
92
+ const finished = started.and(playback.repetitions.notEqual(INFINITE_REPETITIONS)).and(loops.greaterThanEqual(playback.repetitions));
93
+ const isPingPong = playback.loopMode.equal(LoopMode.PingPong);
94
+ const bounce = loops.mod(2);
95
+ const pingPongPhase = bounce.lessThan(1).select(bounce, float(2).sub(bounce));
96
+ const endPhase = playback.endMode.equal(EndMode.Clamp).select(float(1), float(0));
97
+ const phase = started.select(
98
+ finished.select(endPhase, isPingPong.select(pingPongPhase, loops.fract())),
99
+ float(0)
100
+ );
101
+ const wraps = started.select(
102
+ finished.select(bool(false), isPingPong.select(bool(false), bool(true))),
103
+ bool(false)
104
+ );
105
+ const f = phase.mul(wraps.select(frames, last));
106
+ const f0 = f.floor().min(last);
107
+ const f1 = wraps.select(f0.add(1).mod(frames), f0.add(1).min(last));
108
+ const frameMix = f.sub(f0);
109
+ const bandRow = (offset) => int(offset).add(playback.startFrame);
110
+ const row0 = bandRow(f0);
111
+ const row1 = bandRow(f1);
112
+ const fade = playback.fade;
113
+ const fadeWeight = fade.duration.greaterThan(0).select(
114
+ float(1).sub(elapsed.div(fade.duration).clamp(0, 1)),
115
+ float(0)
116
+ );
117
+ const frozenRow = int(fade.phase.mul(fade.frames).floor().min(fade.frames.sub(1)).max(0)).add(
118
+ int(fade.startFrame)
119
+ );
64
120
  const sample = (tex) => {
65
- const t = time.mul(playback.speed).add(playback.timeOffset).div(playback.duration).fract().mul(playback.frames);
66
- const f0 = int(t);
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;
70
- return mix(s0, s1, t.fract());
121
+ const s0 = textureLoad(tex, ivec2(vertexRow, row0)).xyz;
122
+ const s1 = textureLoad(tex, ivec2(vertexRow, row1)).xyz;
123
+ const frozen = textureLoad(tex, ivec2(vertexRow, frozenRow)).xyz;
124
+ return mix(mix(s0, s1, frameMix), frozen, fadeWeight);
71
125
  };
126
+ const normalTexture = vat.normalTexture;
72
127
  return {
73
128
  position: sample(vat.positionTexture),
74
- normal: sample(vat.normalTexture).normalize()
129
+ normal: normalTexture ? sample(normalTexture).normalize() : null
75
130
  };
76
131
  }
77
132
  function createVATMesh(vat, instances, options = {}) {
78
133
  const time = options.time ?? uniform(0);
79
- const geometry = createCrowdGeometry(vat, instances);
134
+ const playback = createVATPlaybackTexture(instances);
135
+ for (const source of vat.materials) assertBakedNormal(vat, source);
80
136
  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 });
137
+ const mesh = new InstancedMesh(vat.geometry, materials, instances.length);
138
+ const { positionNode } = vatNodes(vat, { time, playback, carrier: mesh });
83
139
  for (const material of materials) material.positionNode = positionNode;
84
- return { mesh, time };
140
+ return { mesh, time, playback };
85
141
  }
86
142
 
87
143
  export { createVATMesh, getMaxTextureSize, vatDecode, vatNodes };