@aura3d/assets 3.0.1 → 4.0.2

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.
Files changed (158) hide show
  1. package/dist/AdvancedAssetCorpus.d.ts.map +1 -1
  2. package/dist/AdvancedAssetCorpus.js +7 -12
  3. package/dist/AdvancedAssetCorpus.js.map +1 -1
  4. package/dist/AssetDecoderRegistry.d.ts +56 -0
  5. package/dist/AssetDecoderRegistry.d.ts.map +1 -0
  6. package/dist/AssetDecoderRegistry.js +225 -0
  7. package/dist/AssetDecoderRegistry.js.map +1 -0
  8. package/dist/GLTFAnimationRuntime.d.ts +216 -2
  9. package/dist/GLTFAnimationRuntime.d.ts.map +1 -1
  10. package/dist/GLTFAnimationRuntime.js +674 -38
  11. package/dist/GLTFAnimationRuntime.js.map +1 -1
  12. package/dist/GLTFAssetParseCache.d.ts +24 -0
  13. package/dist/GLTFAssetParseCache.d.ts.map +1 -0
  14. package/dist/GLTFAssetParseCache.js +100 -0
  15. package/dist/GLTFAssetParseCache.js.map +1 -0
  16. package/dist/GLTFExtensionSupport.js +3 -3
  17. package/dist/GLTFExtensionSupport.js.map +1 -1
  18. package/dist/GLTFLoader.d.ts +28 -1
  19. package/dist/GLTFLoader.d.ts.map +1 -1
  20. package/dist/GLTFLoader.js +176 -23
  21. package/dist/GLTFLoader.js.map +1 -1
  22. package/dist/GLTFRenderResources.d.ts +77 -2
  23. package/dist/GLTFRenderResources.d.ts.map +1 -1
  24. package/dist/GLTFRenderResources.js +310 -153
  25. package/dist/GLTFRenderResources.js.map +1 -1
  26. package/dist/KTX2BasisTextureTranscoder.d.ts +70 -12
  27. package/dist/KTX2BasisTextureTranscoder.d.ts.map +1 -1
  28. package/dist/KTX2BasisTextureTranscoder.js +225 -192
  29. package/dist/KTX2BasisTextureTranscoder.js.map +1 -1
  30. package/dist/KTX2TargetSelection.d.ts +25 -0
  31. package/dist/KTX2TargetSelection.d.ts.map +1 -0
  32. package/dist/KTX2TargetSelection.js +36 -0
  33. package/dist/KTX2TargetSelection.js.map +1 -0
  34. package/dist/KTX2TranscodeDriver.d.ts +70 -0
  35. package/dist/KTX2TranscodeDriver.d.ts.map +1 -0
  36. package/dist/KTX2TranscodeDriver.js +104 -0
  37. package/dist/KTX2TranscodeDriver.js.map +1 -0
  38. package/dist/KTX2TranscodeWorker.d.ts +30 -0
  39. package/dist/KTX2TranscodeWorker.d.ts.map +1 -0
  40. package/dist/KTX2TranscodeWorker.js +230 -0
  41. package/dist/KTX2TranscodeWorker.js.map +1 -0
  42. package/dist/MikkTSpaceTangents.d.ts +43 -0
  43. package/dist/MikkTSpaceTangents.d.ts.map +1 -0
  44. package/dist/MikkTSpaceTangents.js +149 -0
  45. package/dist/MikkTSpaceTangents.js.map +1 -0
  46. package/dist/asset-corpus/ProductionAssetCorpus.d.ts.map +1 -1
  47. package/dist/asset-corpus/ProductionAssetCorpus.js +7 -13
  48. package/dist/asset-corpus/ProductionAssetCorpus.js.map +1 -1
  49. package/dist/asset-corpus/ProductionGLTFRenderPipeline.d.ts +43 -0
  50. package/dist/asset-corpus/ProductionGLTFRenderPipeline.d.ts.map +1 -1
  51. package/dist/asset-corpus/ProductionGLTFRenderPipeline.js +146 -22
  52. package/dist/asset-corpus/ProductionGLTFRenderPipeline.js.map +1 -1
  53. package/dist/assetPhaseMarks.d.ts +14 -0
  54. package/dist/assetPhaseMarks.d.ts.map +1 -0
  55. package/dist/assetPhaseMarks.js +23 -0
  56. package/dist/assetPhaseMarks.js.map +1 -0
  57. package/dist/browser-index.d.ts +5 -1
  58. package/dist/browser-index.d.ts.map +1 -1
  59. package/dist/browser-index.js +3 -1
  60. package/dist/browser-index.js.map +1 -1
  61. package/dist/contracts/decoders.d.ts +15 -0
  62. package/dist/contracts/decoders.d.ts.map +1 -0
  63. package/dist/contracts/decoders.js +8 -0
  64. package/dist/contracts/decoders.js.map +1 -0
  65. package/dist/decoderLoad.d.ts +28 -0
  66. package/dist/decoderLoad.d.ts.map +1 -0
  67. package/dist/decoderLoad.js +49 -0
  68. package/dist/decoderLoad.js.map +1 -0
  69. package/dist/gltf/ImageDecode.d.ts +18 -0
  70. package/dist/gltf/ImageDecode.d.ts.map +1 -0
  71. package/dist/gltf/ImageDecode.js +140 -0
  72. package/dist/gltf/ImageDecode.js.map +1 -0
  73. package/dist/gltf-runtime.d.ts +1 -1
  74. package/dist/gltf-runtime.d.ts.map +1 -1
  75. package/dist/gltf-runtime.js.map +1 -1
  76. package/dist/index.d.ts +5 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +3 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/lanes/index.d.ts +17 -0
  81. package/dist/lanes/index.d.ts.map +1 -0
  82. package/dist/lanes/index.js +17 -0
  83. package/dist/lanes/index.js.map +1 -0
  84. package/dist/lanes/prd01.d.ts +6 -0
  85. package/dist/lanes/prd01.d.ts.map +1 -0
  86. package/dist/lanes/prd01.js +6 -0
  87. package/dist/lanes/prd01.js.map +1 -0
  88. package/dist/lanes/prd02.d.ts +6 -0
  89. package/dist/lanes/prd02.d.ts.map +1 -0
  90. package/dist/lanes/prd02.js +6 -0
  91. package/dist/lanes/prd02.js.map +1 -0
  92. package/dist/lanes/prd03.d.ts +6 -0
  93. package/dist/lanes/prd03.d.ts.map +1 -0
  94. package/dist/lanes/prd03.js +6 -0
  95. package/dist/lanes/prd03.js.map +1 -0
  96. package/dist/lanes/prd04.d.ts +6 -0
  97. package/dist/lanes/prd04.d.ts.map +1 -0
  98. package/dist/lanes/prd04.js +6 -0
  99. package/dist/lanes/prd04.js.map +1 -0
  100. package/dist/lanes/prd05.d.ts +14 -0
  101. package/dist/lanes/prd05.d.ts.map +1 -0
  102. package/dist/lanes/prd05.js +14 -0
  103. package/dist/lanes/prd05.js.map +1 -0
  104. package/dist/lanes/prd06.d.ts +6 -0
  105. package/dist/lanes/prd06.d.ts.map +1 -0
  106. package/dist/lanes/prd06.js +6 -0
  107. package/dist/lanes/prd06.js.map +1 -0
  108. package/dist/lanes/prd07.d.ts +6 -0
  109. package/dist/lanes/prd07.d.ts.map +1 -0
  110. package/dist/lanes/prd07.js +6 -0
  111. package/dist/lanes/prd07.js.map +1 -0
  112. package/dist/lanes/prd08.d.ts +6 -0
  113. package/dist/lanes/prd08.d.ts.map +1 -0
  114. package/dist/lanes/prd08.js +6 -0
  115. package/dist/lanes/prd08.js.map +1 -0
  116. package/dist/lanes/prd09.d.ts +6 -0
  117. package/dist/lanes/prd09.d.ts.map +1 -0
  118. package/dist/lanes/prd09.js +6 -0
  119. package/dist/lanes/prd09.js.map +1 -0
  120. package/dist/lanes/prd10.d.ts +6 -0
  121. package/dist/lanes/prd10.d.ts.map +1 -0
  122. package/dist/lanes/prd10.js +6 -0
  123. package/dist/lanes/prd10.js.map +1 -0
  124. package/dist/lanes/prd11.d.ts +6 -0
  125. package/dist/lanes/prd11.d.ts.map +1 -0
  126. package/dist/lanes/prd11.js +6 -0
  127. package/dist/lanes/prd11.js.map +1 -0
  128. package/dist/lanes/prd12.d.ts +6 -0
  129. package/dist/lanes/prd12.d.ts.map +1 -0
  130. package/dist/lanes/prd12.js +6 -0
  131. package/dist/lanes/prd12.js.map +1 -0
  132. package/dist/lanes/prd13.d.ts +6 -0
  133. package/dist/lanes/prd13.d.ts.map +1 -0
  134. package/dist/lanes/prd13.js +6 -0
  135. package/dist/lanes/prd13.js.map +1 -0
  136. package/dist/lanes/prd14.d.ts +6 -0
  137. package/dist/lanes/prd14.d.ts.map +1 -0
  138. package/dist/lanes/prd14.js +6 -0
  139. package/dist/lanes/prd14.js.map +1 -0
  140. package/dist/lanes/prd15.d.ts +6 -0
  141. package/dist/lanes/prd15.d.ts.map +1 -0
  142. package/dist/lanes/prd15.js +6 -0
  143. package/dist/lanes/prd15.js.map +1 -0
  144. package/dist/vite/auraDecoders.d.ts +31 -0
  145. package/dist/vite/auraDecoders.d.ts.map +1 -0
  146. package/dist/vite/auraDecoders.js +89 -0
  147. package/dist/vite/auraDecoders.js.map +1 -0
  148. package/package.json +13 -5
  149. package/vendor/basis/README.md +25 -0
  150. package/vendor/basis/basis_transcoder.js +19 -0
  151. package/vendor/basis/basis_transcoder.wasm +0 -0
  152. package/vendor/draco/README.md +23 -0
  153. package/vendor/draco/draco_decoder.js +33 -0
  154. package/vendor/draco/draco_decoder.wasm +0 -0
  155. package/vendor/meshopt/README.md +21 -0
  156. package/vendor/meshopt/meshopt_decoder.cjs +199 -0
  157. package/vendor/meshopt/meshopt_decoder.mjs +196 -0
  158. package/vendor/meshopt_decoder.mjs +196 -0
@@ -1,5 +1,8 @@
1
1
  import { AnimationAction, AnimationClip, AnimationMixer, consumeRootMotion, extractRootMotion, createFootIkRig, normalizeQuat, slerpQuat, solveTwoBoneIk } from "@aura3d/animation";
2
+ import { bindSkeleton, compileClip, createPoseBuffer, makeClipAdditive, PoseMixer } from "@aura3d/animation/lanes";
3
+ import { AURA3D_RETARGET_ENGINE_VERSION, bakeClipsInWorker, bakeRetargetedClipMap, createRetargetWorker, decompileCompiledClip, readRetargetCache, retargetCacheKey, retargetClipsHash, retargetSkeletonHash, writeRetargetCache } from "@aura3d/animation/lanes";
2
4
  import { composeMat4, decomposeMat4, invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
5
+ import { skinningPaletteCache } from "@aura3d/rendering";
3
6
  /**
4
7
  * Synonym groups for fuzzy clip-name matching. Real catalog assets label the same
5
8
  * motion wildly differently ("Loops" vs "Idle", "Take 001" vs "Run", "sprint" vs
@@ -17,19 +20,31 @@ const GLTF_CLIP_SYNONYM_GROUPS = [
17
20
  function normalizeClipToken(name) {
18
21
  return name.toLowerCase().replace(/[\s_.\-]+/g, "");
19
22
  }
20
- /**
21
- * Resolve a requested clip name to the best available clip name.
22
- *
23
- * Matching order:
24
- * 1. Exact case-insensitive (ignoring whitespace/underscores/dots/dashes).
25
- * 2. Synonym-group match: the requested name and an available name belong to
26
- * the same synonym group (e.g. "run" resolves to "Sprint").
27
- * 3. Substring match in either direction (e.g. "walk" -> "WalkCycle").
28
- * 4. Fallback to the first available clip.
29
- *
30
- * Returns `undefined` only when there are no available clips.
31
- */
32
- export function resolveGLTFClipName(requested, available) {
23
+ const animationClipDefaultsAre31 = () => {
24
+ const parseList = (value) => value === undefined ? [] : value.split(/[\s,]+/).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
25
+ const env = typeof process !== "undefined" ? process.env : undefined;
26
+ if (env !== undefined) {
27
+ const scoped = env["A3D_QR_ANIMATION"];
28
+ if (scoped !== undefined && scoped !== "0" && scoped.toLowerCase() !== "false" && scoped.toLowerCase() !== "off")
29
+ return true;
30
+ const root = parseList(env["A3D_QR"] ?? env["A3D_QR_FLAGS"]);
31
+ if (root.includes("animation") || root.includes("all") || root.includes("true"))
32
+ return true;
33
+ }
34
+ if (typeof location !== "undefined" && typeof location.search === "string") {
35
+ try {
36
+ const params = new URLSearchParams(location.search);
37
+ const urlFlags = parseList(params.get("a3d-qr") ?? undefined);
38
+ if (urlFlags.includes("animation") || urlFlags.includes("all") || urlFlags.includes("true"))
39
+ return true;
40
+ }
41
+ catch {
42
+ // malformed URLSearchParams input — ignore URL source
43
+ }
44
+ }
45
+ return false;
46
+ };
47
+ export function resolveGLTFClipName(requested, available, options) {
33
48
  if (available.length === 0)
34
49
  return undefined;
35
50
  const requestedToken = normalizeClipToken(requested);
@@ -50,7 +65,8 @@ export function resolveGLTFClipName(requested, available) {
50
65
  return name;
51
66
  }
52
67
  }
53
- return available[0];
68
+ const fallback = options?.fallback ?? (animationClipDefaultsAre31() ? "error" : "first");
69
+ return fallback === "first" ? available[0] : undefined;
54
70
  }
55
71
  function clipMaskAllowsNode(mask, nodeName) {
56
72
  if (!mask)
@@ -253,12 +269,73 @@ export class GLTFImportedSkeletonIKController {
253
269
  };
254
270
  }
255
271
  }
272
+ /** PRD-06 §9.8 — wall clock, ms (`performance.now` where it exists). */
273
+ function nowMs() {
274
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
275
+ }
276
+ /** Column-major mat4 multiply written into a Float32Array slot — the T0.11 no-alloc palette path. Exported so the §13 palette-build micro-budget test measures the exact per-joint write the runtime performs. `a` is hoisted into scalars once per call so the inner writes are pure FMA-grade flops — the indexed-read loop measured ~1.3× over the §13 palette budget. */
277
+ export function multiplyMat4Into(out, outOffset, a, b) {
278
+ const a00 = a[0], a10 = a[1], a20 = a[2], a30 = a[3];
279
+ const a01 = a[4], a11 = a[5], a21 = a[6], a31 = a[7];
280
+ const a02 = a[8], a12 = a[9], a22 = a[10], a32 = a[11];
281
+ const a03 = a[12], a13 = a[13], a23 = a[14], a33 = a[15];
282
+ for (let col = 0; col < 4; col += 1) {
283
+ const b0 = b[col * 4 + 0], b1 = b[col * 4 + 1], b2 = b[col * 4 + 2], b3 = b[col * 4 + 3];
284
+ out[outOffset + col * 4 + 0] = a00 * b0 + a01 * b1 + a02 * b2 + a03 * b3;
285
+ out[outOffset + col * 4 + 1] = a10 * b0 + a11 * b1 + a12 * b2 + a13 * b3;
286
+ out[outOffset + col * 4 + 2] = a20 * b0 + a21 * b1 + a22 * b2 + a23 * b3;
287
+ out[outOffset + col * 4 + 3] = a30 * b0 + a31 * b1 + a32 * b2 + a33 * b3;
288
+ }
289
+ }
290
+ /** Column-major general 4×4 inverse into `out` (adjugate / determinant, gl-matrix `mat4.invert`; 06-T0: out[7], out[10], out[11], out[14], out[15] previously used the wrong cofactor terms, so even inverse(identity) had out[11] = -1 and every skinning palette carried a non-affine row 3 that projected skinned vertices off-screen on the GPU). §13: `invertMat4` allocates two objects per call — over 600 frames that alone blows the steady-state budget. */
291
+ export function invertMat4Into(out, a) {
292
+ const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
293
+ const a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
294
+ const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
295
+ const a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
296
+ const b00 = a00 * a11 - a01 * a10;
297
+ const b01 = a00 * a12 - a02 * a10;
298
+ const b02 = a00 * a13 - a03 * a10;
299
+ const b03 = a01 * a12 - a02 * a11;
300
+ const b04 = a01 * a13 - a03 * a11;
301
+ const b05 = a02 * a13 - a03 * a12;
302
+ const b06 = a20 * a31 - a21 * a30;
303
+ const b07 = a20 * a32 - a22 * a30;
304
+ const b08 = a20 * a33 - a23 * a30;
305
+ const b09 = a21 * a32 - a22 * a31;
306
+ const b10 = a21 * a33 - a23 * a31;
307
+ const b11 = a22 * a33 - a23 * a32;
308
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
309
+ if (!det) {
310
+ out.fill(0);
311
+ return out;
312
+ }
313
+ det = 1.0 / det;
314
+ out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
315
+ out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
316
+ out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
317
+ out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
318
+ out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
319
+ out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
320
+ out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
321
+ out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
322
+ out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
323
+ out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
324
+ out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
325
+ out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
326
+ out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
327
+ out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
328
+ out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
329
+ out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
330
+ return out;
331
+ }
256
332
  export class GLTFSceneAnimationRuntime {
257
333
  options;
258
334
  clipsByName = new Map();
259
335
  nodesByName = new Map();
260
336
  morphRenderablesByNodeName = new Map();
261
337
  skinningBindings = [];
338
+ disposed = false;
262
339
  footBindMatrices = new Map();
263
340
  footOrientationLocks = new Map();
264
341
  footDescendantLocks = new Map();
@@ -266,6 +343,30 @@ export class GLTFSceneAnimationRuntime {
266
343
  footPlanting;
267
344
  footRig;
268
345
  lastApply;
346
+ /** T0.11 scratch for the per-joint palette multiply (inverseMeshWorld * jointWorld). */
347
+ paletteScratch = new Float32Array(16);
348
+ /** §13: reusable scratch for the per-binding inverse world matrix (no alloc/frame). */
349
+ inverseWorldScratch = new Float32Array(16);
350
+ /** §13: persistent result object for `refreshSkinningPalettes` (0 bytes/frame). */
351
+ paletteResult = { updated: 0, missingTargets: [] };
352
+ /**
353
+ * T1.7 (PRD-06) — the per-runtime pose pipeline backing `applyClips`: a
354
+ * scene-wide SkeletonBinding (every traversed node in order, rest pose = the
355
+ * node's local TRS at bind time), one PoseMixer, and the compiled clips.
356
+ * Rebuilt whenever `reindexScene` re-reads the node set.
357
+ */
358
+ poseState;
359
+ /**
360
+ * T3.5 — world→model matrix provider for pose constraints. Defaults to the
361
+ * scene root's world matrix; the `prd06.animation` actor extension installs
362
+ * the app-node transform when one is known.
363
+ */
364
+ poseConstraintModelMatrix;
365
+ /**
366
+ * Wall-clock (ms) of the last constraint evaluation under `applyClips` —
367
+ * its caller passes no dt, so constraint dynamics measure the real gap.
368
+ */
369
+ lastConstraintEvalAtMs;
269
370
  constructor(options) {
270
371
  this.options = options;
271
372
  for (const clip of options.clips) {
@@ -347,6 +448,91 @@ export class GLTFSceneAnimationRuntime {
347
448
  this.lastApply = this.applySampledTargets(`root-motion:${names.join(",")}`, maxTime, { sampledTargets: values, unsupportedTracks }, samples.length);
348
449
  return { motion, applyResult: this.lastApply };
349
450
  }
451
+ /**
452
+ * T3.7 (PRD-06 §6.8) — resolve the root-motion translation track target for
453
+ * spec-level `play(clip, {rootMotion})`: with `bone` set, the bone's own
454
+ * `translation`/`position` track; without it, the clip's root-motion
455
+ * candidate (largest planar displacement on a hips/root/pelvis node, then
456
+ * any translation track by displacement). Throws when nothing qualifies.
457
+ */
458
+ rootMotionTargetFor(clipName, bone) {
459
+ const clip = this.clipsByName.get(clipName);
460
+ if (!clip)
461
+ throw new Error(`glTF animation clip "${clipName}" was not found.`);
462
+ const isTranslation = (target) => target.endsWith(".translation") || target.endsWith(".position");
463
+ if (bone !== undefined) {
464
+ for (const leaf of ["translation", "position"]) {
465
+ const candidate = `${bone}.${leaf}`;
466
+ if (clip.tracks.some(track => track.target === candidate && track.valueType === "vector3"))
467
+ return candidate;
468
+ }
469
+ throw new Error(`Root motion bone "${bone}" has no translation track in glTF animation clip "${clipName}".`);
470
+ }
471
+ let best;
472
+ let bestDistance = -1;
473
+ let fallback;
474
+ for (const track of clip.tracks) {
475
+ if (track.valueType !== "vector3" || !isTranslation(track.target))
476
+ continue;
477
+ fallback ??= track.target;
478
+ const keys = track.keyframes ?? [];
479
+ if (keys.length < 2)
480
+ continue;
481
+ const first = vec3OfKeyframeValue(keys[0].value);
482
+ const last = vec3OfKeyframeValue(keys[keys.length - 1].value);
483
+ if (!first || !last)
484
+ continue;
485
+ const distance = Math.hypot(last[0] - first[0], last[2] - first[2]);
486
+ const rooted = /hips|root|pelvis/i.test(track.target);
487
+ if (distance > 0.05 && (rooted || best === undefined || distance > bestDistance)) {
488
+ if (rooted || best === undefined || !/hips|root|pelvis/i.test(best)) {
489
+ best = track.target;
490
+ bestDistance = distance;
491
+ }
492
+ }
493
+ }
494
+ const resolved = best ?? fallback;
495
+ if (resolved === undefined) {
496
+ throw new Error(`glTF animation clip "${clipName}" has no translation track for root motion.`);
497
+ }
498
+ return resolved;
499
+ }
500
+ /**
501
+ * T3.7 — yaw delta (radians, about local Y) authored on `bone`'s rotation
502
+ * track between `fromTime`/`toTime` (loop-aware). Returns 0 when the clip
503
+ * has no matching rotation track.
504
+ */
505
+ rootMotionYawDelta(clipName, bone, fromTime, toTime, loop) {
506
+ const clip = this.clipsByName.get(clipName);
507
+ if (!clip)
508
+ throw new Error(`glTF animation clip "${clipName}" was not found.`);
509
+ const node = bone ?? this.rootMotionTargetFor(clipName).replace(/\.(translation|position)$/, "");
510
+ const track = clip.tracks.find(candidate => candidate.valueType === "quaternion" &&
511
+ (candidate.target === `${node}.rotation` || candidate.target === `${node}.quaternion`));
512
+ if (!track)
513
+ return 0;
514
+ const yawAt = (time) => {
515
+ const q = track.sample(time);
516
+ const arr = (Array.isArray(q) || ArrayBuffer.isView(q)) ? q : undefined;
517
+ const [x, y, z, w] = arr !== undefined && arr.length >= 4
518
+ ? [arr[0], arr[1], arr[2], arr[3]]
519
+ : [0, 0, 0, 1];
520
+ // forward = q * (0,0,1); yaw = atan2(forward.x, forward.z)
521
+ const fx = 2 * (x * z + w * y);
522
+ const fz = 1 - 2 * (x * x + y * y);
523
+ return Math.atan2(fx, fz);
524
+ };
525
+ const wrap01 = (t) => ((t % clip.duration) + clip.duration) % clip.duration;
526
+ const unwrap = (delta) => delta - Math.round(delta / (Math.PI * 2)) * Math.PI * 2;
527
+ if (!loop || clip.duration <= 0) {
528
+ const clamp = (t) => Math.max(0, Math.min(clip.duration, t));
529
+ return unwrap(yawAt(clamp(toTime)) - yawAt(clamp(fromTime)));
530
+ }
531
+ // Continuous-loop yaw: same-cycle delta plus per-cycle contribution per wrap.
532
+ const perCycle = unwrap(yawAt(clip.duration) - yawAt(0));
533
+ const crossings = Math.floor(toTime / clip.duration) - Math.floor(fromTime / clip.duration);
534
+ return unwrap(yawAt(wrap01(toTime)) - yawAt(wrap01(fromTime))) + crossings * perCycle;
535
+ }
350
536
  applyClipByName(name, time) {
351
537
  const clip = this.clipsByName.get(name);
352
538
  if (!clip) {
@@ -358,13 +544,27 @@ export class GLTFSceneAnimationRuntime {
358
544
  clipNames() {
359
545
  return [...this.clipsByName.keys()];
360
546
  }
547
+ /**
548
+ * T0.6 (PRD-06) — the C-19 `AuraResolvedClipInfo` surface that backs the
549
+ * `prd06.animation` handle extension's `resolveAnimationClips()`: real GLB
550
+ * clip durations (plus channel counts and the 5 cm XZ-displacement
551
+ * root-motion candidate heuristic of T0.7) after the asset has loaded.
552
+ */
553
+ resolvedClipInfos() {
554
+ return [...this.clipsByName.values()].map((clip) => ({
555
+ name: clip.name,
556
+ duration: clip.duration,
557
+ channelCount: clip.tracks.length,
558
+ hasRootMotionCandidate: clipHasRootMotionCandidate(clip)
559
+ }));
560
+ }
361
561
  /**
362
562
  * Resolve a requested clip name to the best available registered clip name
363
563
  * using fuzzy matching (exact -> synonym group -> substring -> first clip).
364
564
  * Returns `undefined` when the runtime has no clips.
365
565
  */
366
- resolveClipName(name) {
367
- return resolveGLTFClipName(name, this.clipNames());
566
+ resolveClipName(name, options) {
567
+ return resolveGLTFClipName(name, this.clipNames(), options);
368
568
  }
369
569
  /**
370
570
  * Apply a clip selected by fuzzy name resolution. Unlike {@link applyClipByName}
@@ -386,14 +586,35 @@ export class GLTFSceneAnimationRuntime {
386
586
  this.lastApply = this.applySampledTargets(clip.name, wrappedTime, sampleClipTracks(clip, wrappedTime, 1, false));
387
587
  return this.lastApply;
388
588
  }
589
+ /**
590
+ * T1.7 (PRD-06 §10) — re-implemented on the per-runtime `PoseMixer` in
591
+ * stateless mode (`evaluateSamples` with explicit per-sample times). Node
592
+ * translation/rotation/scale tracks bound to a scene node go through the
593
+ * pose pipeline (incremental-weight accumulate + rest fill + additive
594
+ * accumulators, three r185 `AnimationMixer` order); morph-weight, material
595
+ * and light pointer tracks keep the existing accumulator path, and so do
596
+ * node tracks aimed at nodes the binding does not cover (they keep their
597
+ * `missingTargets` reporting). Two documented semantic changes vs the old
598
+ * accumulator blend: partial-weight mixes fill the remainder from the rest
599
+ * pose instead of renormalising (rest blend), and a bone covered by the
600
+ * blend but not by a given channel resets that channel to rest (rest reset).
601
+ */
389
602
  applyClips(samples) {
390
603
  if (samples.length === 0) {
391
604
  throw new Error("glTF animation runtime blend requires at least one clip sample.");
392
605
  }
606
+ const phaseStart = nowMs();
607
+ let mixerMs = 0;
608
+ let constraintsMs = 0;
609
+ let springsMs = 0;
393
610
  const accumulators = new Map();
394
611
  const unsupportedTracks = [];
395
612
  let maxTime = 0;
396
613
  const names = [];
614
+ const pose = this.poseRuntime();
615
+ const poseSpecs = [];
616
+ const touchedBones = new Set();
617
+ const maskWeights = new Map();
397
618
  for (const sample of samples) {
398
619
  const clip = this.clipsByName.get(sample.clipName);
399
620
  if (!clip) {
@@ -411,25 +632,353 @@ export class GLTFSceneAnimationRuntime {
411
632
  const wrappedTime = clip.duration > 0 && sample.time > clip.duration ? sample.time % clip.duration : Math.min(sample.time, clip.duration);
412
633
  maxTime = Math.max(maxTime, wrappedTime);
413
634
  names.push(`${clip.name}@${Number(wrappedTime.toFixed(4))}x${Number(weight.toFixed(4))}${sample.additive ? "+add" : ""}`);
635
+ let bindsNodes = false;
414
636
  for (const track of clip.tracks) {
415
637
  const target = parseAnimationTarget(track.target);
416
638
  if (!target) {
417
639
  unsupportedTracks.push(track.target);
418
640
  continue;
419
641
  }
420
- // Bone masks filter node tracks only; material/light property tracks always apply.
421
- if (target.kind === "node" && !clipMaskAllowsNode(sample.mask, target.nodeName))
642
+ if (target.kind === "node" && target.path !== "weights") {
643
+ // Node TRS: pose path when the name binds and the mask allows it.
644
+ const boneIndices = pose.binding.jointIndicesByName.get(target.nodeName);
645
+ if (boneIndices !== undefined && boneIndices.length > 0 && clipMaskAllowsNode(sample.mask, target.nodeName)) {
646
+ bindsNodes = true;
647
+ touchedBones.add(boneIndices[0]);
648
+ }
649
+ else if (boneIndices === undefined) {
650
+ // Track points at a node absent from the scene — legacy path so
651
+ // `missingTargets` reporting is unchanged.
652
+ blendInto(accumulators, track.target, track.valueType, track.sample(wrappedTime), weight, sample.additive === true);
653
+ }
422
654
  continue;
655
+ }
423
656
  blendInto(accumulators, track.target, track.valueType, track.sample(wrappedTime), weight, sample.additive === true);
424
657
  }
658
+ if (bindsNodes) {
659
+ let mask = null;
660
+ if (sample.mask !== undefined) {
661
+ let cached = maskWeights.get(sample.mask);
662
+ if (cached === undefined) {
663
+ cached = new Float32Array(pose.binding.boneCount);
664
+ for (let i = 0; i < pose.binding.boneCount; i += 1) {
665
+ cached[i] = clipMaskAllowsNode(sample.mask, pose.binding.jointNames[i]) ? 1 : 0;
666
+ }
667
+ maskWeights.set(sample.mask, cached);
668
+ }
669
+ mask = cached;
670
+ }
671
+ poseSpecs.push({ clipName: clip.name, time: wrappedTime, weight, additive: sample.additive === true, mask });
672
+ }
425
673
  }
426
674
  const sampledTargets = new Map();
675
+ if (poseSpecs.length > 0) {
676
+ const mixerStart = nowMs();
677
+ pose.mixer.evaluateSamples(poseSpecs, pose.pose);
678
+ mixerMs = nowMs() - mixerStart;
679
+ // T3.5 — constraints apply to the freshly-mixed pose before palette
680
+ // build; touched joints union into the emitted sampled targets. Their
681
+ // dynamics (look-at half-life, spring substeps) integrate in wall-clock
682
+ // seconds, so measure the real gap between applies — a hardcoded 1/60
683
+ // makes smoothing converge ~6× too slowly in sub-60fps sessions.
684
+ const now = nowMs();
685
+ const constraintDt = this.lastConstraintEvalAtMs === undefined
686
+ ? 1 / 60
687
+ : Math.min(0.25, Math.max(1e-4, (now - this.lastConstraintEvalAtMs) / 1000));
688
+ this.lastConstraintEvalAtMs = now;
689
+ const constrained = this.runPoseConstraints(pose.pose, pose.binding, constraintDt);
690
+ constraintsMs = constrained.constraintsMs;
691
+ springsMs = constrained.springsMs;
692
+ for (const boneIndex of constrained.touched)
693
+ touchedBones.add(boneIndex);
694
+ for (const boneIndex of touchedBones) {
695
+ const name = pose.binding.jointNames[boneIndex];
696
+ const p = boneIndex * 3;
697
+ const q = boneIndex * 4;
698
+ sampledTargets.set(`${name}.translation`, [pose.pose.positions[p], pose.pose.positions[p + 1], pose.pose.positions[p + 2]]);
699
+ sampledTargets.set(`${name}.rotation`, [pose.pose.rotations[q], pose.pose.rotations[q + 1], pose.pose.rotations[q + 2], pose.pose.rotations[q + 3]]);
700
+ sampledTargets.set(`${name}.scale`, [pose.pose.scales[p], pose.pose.scales[p + 1], pose.pose.scales[p + 2]]);
701
+ }
702
+ }
427
703
  for (const [target, accumulator] of accumulators) {
428
704
  sampledTargets.set(target, finalizeTargetBlend(accumulator));
429
705
  }
430
- this.lastApply = this.applySampledTargets(`blend:${names.join(",")}`, maxTime, { sampledTargets, unsupportedTracks }, samples.length);
706
+ this.lastApply = this.applySampledTargets(`blend:${names.join(",")}`, maxTime, { sampledTargets, unsupportedTracks }, samples.length, { mixerMs, constraintsMs, springsMs, paletteBytes: this.paletteBufferBytes(), cpuMs: nowMs() - phaseStart });
431
707
  return this.lastApply;
432
708
  }
709
+ /**
710
+ * §10 (PRD-06) — the scene-wide pose binding the pose path evaluates on.
711
+ * Joints are every scene node in `traverse` order, resolved by node index
712
+ * (duplicate names bind all slots); the rest pose is each node's local TRS
713
+ * captured at bind time.
714
+ */
715
+ skeletons() {
716
+ return [this.poseRuntime().binding];
717
+ }
718
+ /** §10 (PRD-06) — the per-clip compiled tracks used by the pose path. */
719
+ compiledClips() {
720
+ return this.poseRuntime().compiled;
721
+ }
722
+ /** §10 (PRD-06) — the per-runtime PoseMixer `applyClips` evaluates on. */
723
+ mixer() {
724
+ return this.poseRuntime().mixer;
725
+ }
726
+ /**
727
+ * T3.8 (PRD-06 §7.2, C-19) — `actor.animation.addClipsFrom(source)` retarget-
728
+ * bakes another skeleton's compiled clips onto this runtime's skeleton and
729
+ * registers them by name (raw + compiled + mixer) so `play`/`playLayer`
730
+ * resolve them like loaded clips. The bake runs in `pose/retarget.worker.ts`
731
+ * when the platform has workers, otherwise in-process, and its output is
732
+ * cached in IndexedDB under `(engineVersion, sourceHash, targetHash)` so a
733
+ * rollback ignores newer caches. Returns the registered clip names.
734
+ */
735
+ async addClipsFrom(source, options = {}) {
736
+ const pose = this.poseRuntime();
737
+ const sourceSkeleton = "skeletons" in source ? source.skeletons()[0] : source.skeleton;
738
+ const sourceClips = "compiledClips" in source ? source.compiledClips() : source.clips;
739
+ if (sourceSkeleton === undefined) {
740
+ throw new Error("addClipsFrom: source has no skeleton to retarget from.");
741
+ }
742
+ const engineVersion = options.engineVersion ?? AURA3D_RETARGET_ENGINE_VERSION;
743
+ const sourceHash = `${retargetSkeletonHash(sourceSkeleton)}:${retargetClipsHash(sourceClips)}`;
744
+ const targetHash = retargetSkeletonHash(pose.binding);
745
+ const key = retargetCacheKey(engineVersion, sourceHash, targetHash);
746
+ let baked = options.cache === false ? undefined : await readRetargetCache(key);
747
+ if (baked === undefined) {
748
+ const bakeOptions = {
749
+ ...(options.map !== undefined ? { map: options.map } : {}),
750
+ hipsScale: options.hipsScale ?? "leg-length",
751
+ ...(options.fingers !== undefined ? { fingers: options.fingers } : {}),
752
+ ...(options.retarget !== undefined ? { retarget: options.retarget } : {})
753
+ };
754
+ const worker = options.worker === false ? undefined : createRetargetWorker();
755
+ baked = worker !== undefined
756
+ ? await bakeClipsInWorker(worker, {
757
+ sourceSkeleton,
758
+ targetSkeleton: pose.binding,
759
+ clips: sourceClips,
760
+ options: bakeOptions
761
+ })
762
+ : bakeRetargetedClipMap({ skeleton: sourceSkeleton, clips: sourceClips }, pose.binding, bakeOptions);
763
+ if (options.cache !== false)
764
+ void writeRetargetCache(key, baked);
765
+ }
766
+ const names = [];
767
+ for (const [name, compiled] of baked) {
768
+ const raw = decompileCompiledClip(name, compiled);
769
+ this.clipsByName.set(name, raw);
770
+ pose.compiled.set(name, compiled);
771
+ pose.mixer.addCompiledClip(name, compiled, raw);
772
+ names.push(name);
773
+ }
774
+ return names;
775
+ }
776
+ /**
777
+ * T1.9 (PRD-06 §10) — advance the per-runtime `PoseMixer` by `dt` seconds
778
+ * and write the evaluated pose to scene nodes in `applySampledTargets`
779
+ * order. `restPoseReset !== false` writes every bound bone (rest-filled
780
+ * channels reset); `false` writes only the bones covered by active actions'
781
+ * bound tracks, so uncovered bones keep their current scene values.
782
+ * Non-pose tracks (morph weights, material/light pointer, node tracks
783
+ * aimed at nodes outside the binding) keep the legacy accumulator path,
784
+ * sampled at each active action's own clock and effective weight.
785
+ */
786
+ applyPoseMixer(dt, options) {
787
+ const phaseStart = nowMs();
788
+ const pose = this.poseRuntime();
789
+ const mixerStart = nowMs();
790
+ pose.mixer.update(dt);
791
+ pose.mixer.evaluate(pose.pose);
792
+ const mixerMs = nowMs() - mixerStart;
793
+ // T3.5 — constraints evaluate post-mixer / pre-palette; their touched
794
+ // bones union into `covered` so the write-back emits them.
795
+ this.lastConstraintEvalAtMs = nowMs();
796
+ const constrained = this.runPoseConstraints(pose.pose, pose.binding, dt);
797
+ const constraintsMs = constrained.constraintsMs;
798
+ const springsMs = constrained.springsMs;
799
+ const actions = pose.mixer.activeActions();
800
+ let covered;
801
+ if (options?.restPoseReset === false) {
802
+ const set = new Set();
803
+ for (const action of actions) {
804
+ for (const binding of action.bindings)
805
+ set.add(binding.boneIndex);
806
+ }
807
+ for (const boneIndex of constrained.touched)
808
+ set.add(boneIndex);
809
+ covered = set;
810
+ }
811
+ // `covered === undefined` already emits every bound bone below — the
812
+ // constraint union matters only on the covered path.
813
+ const sampledTargets = new Map();
814
+ const writeBone = (boneIndex) => {
815
+ const name = pose.binding.jointNames[boneIndex];
816
+ const p = boneIndex * 3;
817
+ const q = boneIndex * 4;
818
+ sampledTargets.set(`${name}.translation`, [pose.pose.positions[p], pose.pose.positions[p + 1], pose.pose.positions[p + 2]]);
819
+ sampledTargets.set(`${name}.rotation`, [pose.pose.rotations[q], pose.pose.rotations[q + 1], pose.pose.rotations[q + 2], pose.pose.rotations[q + 3]]);
820
+ sampledTargets.set(`${name}.scale`, [pose.pose.scales[p], pose.pose.scales[p + 1], pose.pose.scales[p + 2]]);
821
+ };
822
+ if (covered === undefined) {
823
+ for (let i = 0; i < pose.binding.boneCount; i += 1)
824
+ writeBone(i);
825
+ }
826
+ else {
827
+ for (const boneIndex of covered)
828
+ writeBone(boneIndex);
829
+ }
830
+ // Legacy accumulator path for every track the pose binding does not
831
+ // cover — sampled at each action's clock and effective weight, in active
832
+ // action order (same semantics as `applyClips`' per-sample partition).
833
+ const accumulators = new Map();
834
+ const unsupportedTracks = [];
835
+ for (const action of actions) {
836
+ const source = this.clipsByName.get(action.clipName);
837
+ if (source === undefined)
838
+ continue;
839
+ for (const track of source.tracks) {
840
+ const target = parseAnimationTarget(track.target);
841
+ if (target === undefined) {
842
+ unsupportedTracks.push(track.target);
843
+ continue;
844
+ }
845
+ if (target.kind === "node" && target.path !== "weights") {
846
+ const boneIndices = pose.binding.jointIndicesByName.get(target.nodeName);
847
+ if (boneIndices !== undefined && boneIndices.length > 0)
848
+ continue; // bound → mixer path
849
+ }
850
+ blendInto(accumulators, track.target, track.valueType, track.sample(action.time), action.effectiveWeight, action.additive);
851
+ }
852
+ }
853
+ for (const [target, accumulator] of accumulators) {
854
+ sampledTargets.set(target, finalizeTargetBlend(accumulator));
855
+ }
856
+ const active = actions.filter((action) => action.effectiveWeight > 0);
857
+ this.lastApply = this.applySampledTargets(`mixer:${options?.label ?? (actions.map((action) => action.clipName).join("+") || "idle")}`, active[0]?.time ?? 0, { sampledTargets, unsupportedTracks }, actions.length > 0 ? actions.length : undefined, { mixerMs, constraintsMs, springsMs, paletteBytes: this.paletteBufferBytes(), cpuMs: nowMs() - phaseStart });
858
+ return this.lastApply;
859
+ }
860
+ /**
861
+ * T1.10 (PRD-06 §10, C-19 `blendMode: "additive"`/`additiveReference`) — lazily
862
+ * compile an additive variant of `clipName` via `makeClipAdditive` (reference
863
+ * defaults to the clip's own first frame) and register it on the mixer under
864
+ * a synthesized name. Returns the registered clip key for `clipAction`/`playLayer`.
865
+ */
866
+ ensureAdditiveClip(clipName, reference) {
867
+ const pose = this.poseRuntime();
868
+ const source = this.clipsByName.get(clipName);
869
+ if (source === undefined) {
870
+ return clipName;
871
+ }
872
+ const referenceClip = reference?.clip === undefined ? undefined : this.clipsByName.get(reference.clip);
873
+ const referenceTime = reference?.time ?? 0;
874
+ const name = `${clipName}#additive:${reference?.clip ?? "self"}:${referenceTime}`;
875
+ if (!pose.compiled.has(name)) {
876
+ const additive = makeClipAdditive(source, referenceClip === undefined ? referenceTime : { clip: referenceClip, time: referenceTime });
877
+ const compiledClip = compileClip(additive);
878
+ pose.compiled.set(name, compiledClip);
879
+ pose.mixer.addCompiledClip(name, compiledClip, additive);
880
+ }
881
+ return name;
882
+ }
883
+ /**
884
+ * T1.10 (C-19 `animation.mask`) — bind-order joint names for `createBoneMask`.
885
+ */
886
+ skeletonJointNames() {
887
+ return this.poseRuntime().binding.jointNames;
888
+ }
889
+ /**
890
+ * T3.5 — register a pose constraint. Entries evaluate in insertion order
891
+ * after each mixer write; returns a disposer that removes exactly this
892
+ * constraint (by identity — a duplicate spec stays).
893
+ */
894
+ addPoseConstraint(constraint) {
895
+ const state = this.poseRuntime();
896
+ state.constraints.push(constraint);
897
+ let live = true;
898
+ return () => {
899
+ if (!live)
900
+ return;
901
+ live = false;
902
+ const index = state.constraints.indexOf(constraint);
903
+ if (index >= 0)
904
+ state.constraints.splice(index, 1);
905
+ };
906
+ }
907
+ /** T3.5 — drop every pose constraint (constant-time identity sweep). */
908
+ clearPoseConstraints() {
909
+ if (this.poseState === undefined)
910
+ return;
911
+ this.poseState.constraints.length = 0;
912
+ }
913
+ /** T3.5 — the count of live pose constraints (diagnostics/tests). */
914
+ poseConstraintCount() {
915
+ return this.poseState?.constraints.length ?? 0;
916
+ }
917
+ /** T3.5 — install the world→model matrix provider (actor extension). */
918
+ setPoseConstraintModelMatrix(provider) {
919
+ this.poseConstraintModelMatrix = provider;
920
+ }
921
+ /**
922
+ * T3.5 — evaluate the constraint list in order onto the just-mixed pose and
923
+ * return the union of touched joint indices (empty when no constraints),
924
+ * plus the §9.8 wall-clock split between `"springs"`-tagged constraints and
925
+ * the rest.
926
+ */
927
+ runPoseConstraints(pose, binding, dt) {
928
+ const touched = new Set();
929
+ const constraints = this.poseState?.constraints;
930
+ let constraintsMs = 0;
931
+ let springsMs = 0;
932
+ if (constraints === undefined || constraints.length === 0)
933
+ return { touched, constraintsMs, springsMs };
934
+ const modelMatrix = this.poseConstraintModelMatrix?.() ?? this.options.scene.root.transform.worldMatrix;
935
+ for (const constraint of constraints) {
936
+ const start = nowMs();
937
+ constraint.evaluate(pose, binding, modelMatrix, { dt });
938
+ const elapsed = nowMs() - start;
939
+ if (constraint.kind === "springs")
940
+ springsMs += elapsed;
941
+ else
942
+ constraintsMs += elapsed;
943
+ for (const bone of constraint.bones)
944
+ touched.add(bone);
945
+ }
946
+ return { touched, constraintsMs, springsMs };
947
+ }
948
+ /** §9.8/§13 — CPU-side palette payload this runtime owns, bytes (16 f32/joint). */
949
+ paletteBufferBytes() {
950
+ let bytes = 0;
951
+ for (const binding of this.skinningBindings)
952
+ bytes += binding.skin.joints.length * 16 * 4;
953
+ return bytes;
954
+ }
955
+ poseRuntime() {
956
+ if (this.poseState === undefined) {
957
+ const nodes = [];
958
+ this.options.scene.traverse((node) => nodes.push(node));
959
+ const indexByNode = new Map(nodes.map((node, index) => [node, index]));
960
+ const binding = bindSkeleton({
961
+ joints: nodes.map((_, index) => index),
962
+ resolveNode: (index) => {
963
+ const node = nodes[index];
964
+ if (node === undefined)
965
+ return undefined;
966
+ return { name: node.name, position: node.transform.position, rotation: node.transform.rotation, scale: node.transform.scale };
967
+ },
968
+ jointNames: nodes.map((node) => node.name),
969
+ parentIndices: nodes.map((node) => (node.parent === null ? -1 : indexByNode.get(node.parent) ?? -1))
970
+ });
971
+ const mixer = new PoseMixer({ skeleton: binding });
972
+ const compiled = new Map();
973
+ for (const [name, clip] of this.clipsByName) {
974
+ const compiledClip = compileClip(clip);
975
+ compiled.set(name, compiledClip);
976
+ mixer.addCompiledClip(name, compiledClip, clip);
977
+ }
978
+ this.poseState = { binding, mixer, compiled, pose: createPoseBuffer(binding.boneCount), constraints: [] };
979
+ }
980
+ return this.poseState;
981
+ }
433
982
  applyAnimationValues(label, time, values, unsupportedTracks = []) {
434
983
  if (label.trim().length === 0) {
435
984
  throw new Error("glTF animation runtime value application label cannot be empty.");
@@ -443,6 +992,22 @@ export class GLTFSceneAnimationRuntime {
443
992
  });
444
993
  return this.lastApply;
445
994
  }
995
+ /**
996
+ * §16 S3 (PRD-06) — teardown frees the C-18 palette textures this runtime
997
+ * stamped, keyed on each skin binding (`skinningPaletteCache.release`).
998
+ * Morph array textures and CPU-morph scratch are released by the actor's
999
+ * own teardown (`prd06.animation` extension dispose / pipeline lifecycle),
1000
+ * which is the layer that can see resolved `Geometry` objects — the scene
1001
+ * layer here only knows geometry handles.
1002
+ */
1003
+ dispose() {
1004
+ if (this.disposed)
1005
+ return;
1006
+ for (const binding of this.skinningBindings) {
1007
+ skinningPaletteCache.release(binding.paletteView?.paletteKey ?? binding);
1008
+ }
1009
+ this.disposed = true;
1010
+ }
446
1011
  /**
447
1012
  * Render-time bridge: drive the loaded GLB from an externally-computed pose whose keys are GLB
448
1013
  * **node names** (e.g. the output of `@aura3d/animation`'s `retargetHumanoidPose`, whose
@@ -585,6 +1150,8 @@ export class GLTFSceneAnimationRuntime {
585
1150
  return [...this.clipsByName.values()].map((clip) => this.inspectClipBinding(clip));
586
1151
  }
587
1152
  reindexScene() {
1153
+ this.poseState = undefined;
1154
+ this.lastConstraintEvalAtMs = undefined;
588
1155
  this.options.scene.updateWorldTransforms();
589
1156
  this.footBindMatrices.clear();
590
1157
  this.footOrientationLocks.clear();
@@ -617,7 +1184,7 @@ export class GLTFSceneAnimationRuntime {
617
1184
  const skin = mesh?.skinIndex === undefined ? undefined : this.options.asset.skins[mesh.skinIndex];
618
1185
  if (!mesh || !skin || skin.joints.length > MAX_RENDERABLE_SKINNING_JOINTS)
619
1186
  continue;
620
- this.skinningBindings.push({ node, renderable, mesh, skin, bindWorldMatrix: [...node.transform.worldMatrix] });
1187
+ this.skinningBindings.push({ node, renderable, mesh, skin, bindWorldMatrix: [...node.transform.worldMatrix], paletteMatrices: new Float32Array(skin.joints.length * 16) });
621
1188
  }
622
1189
  }
623
1190
  }
@@ -939,36 +1506,64 @@ export class GLTFSceneAnimationRuntime {
939
1506
  return samples;
940
1507
  }
941
1508
  refreshSkinningPalettes() {
1509
+ const result = this.paletteResult;
1510
+ result.updated = 0;
1511
+ result.missingTargets.length = 0;
942
1512
  if (this.skinningBindings.length === 0) {
943
- return { updated: 0, missingTargets: [] };
1513
+ return result;
944
1514
  }
945
- let updated = 0;
946
- const missingTargets = [];
1515
+ const missingTargets = result.missingTargets;
947
1516
  for (const binding of this.skinningBindings) {
948
- const matrices = new Float32Array(binding.skin.joints.length * 16);
949
- const inverseMeshWorld = invertMat4(binding.node.transform.worldMatrix);
1517
+ // T0.11: write into the binding's persistent palette buffer — no per-frame
1518
+ // Float32Array allocation, and the renderer's C-18 cache can key on it.
1519
+ const matrices = binding.paletteMatrices;
1520
+ const inverseMeshWorld = invertMat4Into(this.inverseWorldScratch, binding.node.transform.worldMatrix);
1521
+ // §13: joint nodes resolved once per binding instead of a string Map
1522
+ // lookup per joint per frame; the cache rebuilds while any joint is
1523
+ // missing so late-registered nodes still resolve.
1524
+ let jointNodes = binding.jointNodes;
1525
+ if (!jointNodes) {
1526
+ jointNodes = new Array(binding.skin.jointNames.length);
1527
+ let allResolved = true;
1528
+ for (let index = 0; index < binding.skin.jointNames.length; index += 1) {
1529
+ const node = this.nodesByName.get(binding.skin.jointNames[index])?.[0];
1530
+ jointNodes[index] = node;
1531
+ if (!node)
1532
+ allResolved = false;
1533
+ }
1534
+ if (allResolved)
1535
+ binding.jointNodes = jointNodes;
1536
+ }
950
1537
  let complete = true;
951
1538
  for (let index = 0; index < binding.skin.jointNames.length; index += 1) {
952
- const jointName = binding.skin.jointNames[index];
953
- const jointNode = this.nodesByName.get(jointName)?.[0];
1539
+ const jointNode = jointNodes[index];
954
1540
  const inverseBind = binding.skin.inverseBindMatrices[index];
955
1541
  if (!jointNode || !inverseBind) {
956
- missingTargets.push(`${binding.skin.name}.${jointName}`);
1542
+ missingTargets.push(`${binding.skin.name}.${binding.skin.jointNames[index]}`);
957
1543
  complete = false;
958
1544
  break;
959
1545
  }
960
- const jointMatrix = multiplyMat4(multiplyMat4(inverseMeshWorld, jointNode.transform.worldMatrix), inverseBind);
961
- matrices.set(jointMatrix, index * 16);
1546
+ // (inverseMeshWorld * jointWorld) * inverseBind, straight into the palette.
1547
+ multiplyMat4Into(this.paletteScratch, 0, inverseMeshWorld, jointNode.transform.worldMatrix);
1548
+ multiplyMat4Into(matrices, index * 16, this.paletteScratch, inverseBind);
962
1549
  }
963
1550
  if (!complete)
964
1551
  continue;
965
- binding.renderable.skinning = {
966
- jointCount: binding.skin.joints.length,
967
- matrices
968
- };
969
- updated += 1;
1552
+ let skinningPalette = binding.paletteView;
1553
+ if (!skinningPalette) {
1554
+ skinningPalette = {
1555
+ jointCount: binding.skin.joints.length,
1556
+ matrices
1557
+ };
1558
+ binding.paletteView = skinningPalette;
1559
+ }
1560
+ // C-18/§9.2: the stable per-skin key the palette cache binds on is this
1561
+ // binding object, not the per-frame `renderable.skinning` wrapper (E40).
1562
+ skinningPalette.paletteKey = binding;
1563
+ binding.renderable.skinning = skinningPalette;
1564
+ result.updated += 1;
970
1565
  }
971
- return { updated, missingTargets };
1566
+ return result;
972
1567
  }
973
1568
  inspectClipBinding(clip) {
974
1569
  let supportedTrackCount = 0;
@@ -1049,7 +1644,7 @@ export class GLTFSceneAnimationRuntime {
1049
1644
  animatesSkeleton: this.skinningBindings.length > 0 && boundTrackCount > 0 && transformTrackCount > 0
1050
1645
  };
1051
1646
  }
1052
- applySampledTargets(clipName, time, sampled, blendedClipCount) {
1647
+ applySampledTargets(clipName, time, sampled, blendedClipCount, phaseTimings) {
1053
1648
  let transformTracksApplied = 0;
1054
1649
  let morphWeightTracksApplied = 0;
1055
1650
  let materialTracksApplied = 0;
@@ -1117,7 +1712,8 @@ export class GLTFSceneAnimationRuntime {
1117
1712
  ...(measuredFootPlanting === undefined ? {} : { footPlanting: measuredFootPlanting }),
1118
1713
  skinningPalettesUpdated: skinning.updated,
1119
1714
  missingTargets: [...missingTargets, ...skinning.missingTargets],
1120
- unsupportedTracks: sampled.unsupportedTracks
1715
+ unsupportedTracks: sampled.unsupportedTracks,
1716
+ ...(phaseTimings === undefined ? {} : { phaseTimings })
1121
1717
  };
1122
1718
  }
1123
1719
  }
@@ -1727,4 +2323,44 @@ function multiplyQuat(a, b) {
1727
2323
  a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
1728
2324
  ];
1729
2325
  }
2326
+ /**
2327
+ * T0.6/T0.7 (PRD-06) — a clip "has a root-motion candidate" when a translation
2328
+ * track on a hips/root-named target nets more than 5 cm of XZ displacement
2329
+ * between its first and last keyframes.
2330
+ */
2331
+ function clipHasRootMotionCandidate(clip) {
2332
+ for (const track of clip.tracks) {
2333
+ const target = parseAnimationTarget(track.target);
2334
+ if (target?.kind !== "node" || target.path !== "translation")
2335
+ continue;
2336
+ if (!/hips|root|pelvis/i.test(target.nodeName))
2337
+ continue;
2338
+ const keys = track.keyframes ?? [];
2339
+ if (keys.length < 2)
2340
+ continue;
2341
+ const first = vec3OfKeyframeValue(keys[0].value);
2342
+ const last = vec3OfKeyframeValue(keys[keys.length - 1].value);
2343
+ if (!first || !last)
2344
+ continue;
2345
+ const dx = last[0] - first[0];
2346
+ const dz = last[2] - first[2];
2347
+ if (Math.hypot(dx, dz) > 0.05)
2348
+ return true;
2349
+ }
2350
+ return false;
2351
+ }
2352
+ function vec3OfKeyframeValue(value) {
2353
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
2354
+ const arr = value;
2355
+ if (arr.length >= 3)
2356
+ return [arr[0], arr[1], arr[2]];
2357
+ return undefined;
2358
+ }
2359
+ if (value && typeof value === "object") {
2360
+ const v = value;
2361
+ if (typeof v.x === "number" && typeof v.z === "number")
2362
+ return [v.x, v.y ?? 0, v.z];
2363
+ }
2364
+ return undefined;
2365
+ }
1730
2366
  //# sourceMappingURL=GLTFAnimationRuntime.js.map