@aura3d/assets 2.0.4 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/GLTFAnimationRuntime.d.ts +175 -2
  2. package/dist/GLTFAnimationRuntime.d.ts.map +1 -1
  3. package/dist/GLTFAnimationRuntime.js +615 -5
  4. package/dist/GLTFAnimationRuntime.js.map +1 -1
  5. package/dist/GLTFCompressionDecoders.d.ts +1 -0
  6. package/dist/GLTFCompressionDecoders.d.ts.map +1 -1
  7. package/dist/GLTFCompressionDecoders.js +20 -5
  8. package/dist/GLTFCompressionDecoders.js.map +1 -1
  9. package/dist/GLTFExtensionSupport.js +2 -2
  10. package/dist/GLTFExtensionSupport.js.map +1 -1
  11. package/dist/GLTFLoader.d.ts +360 -1
  12. package/dist/GLTFLoader.d.ts.map +1 -1
  13. package/dist/GLTFLoader.js +218 -8
  14. package/dist/GLTFLoader.js.map +1 -1
  15. package/dist/KTX2BasisTextureTranscoder.d.ts +39 -0
  16. package/dist/KTX2BasisTextureTranscoder.d.ts.map +1 -1
  17. package/dist/KTX2BasisTextureTranscoder.js +78 -0
  18. package/dist/KTX2BasisTextureTranscoder.js.map +1 -1
  19. package/dist/TexturePipeline.d.ts +20 -0
  20. package/dist/TexturePipeline.d.ts.map +1 -1
  21. package/dist/TexturePipeline.js +20 -0
  22. package/dist/TexturePipeline.js.map +1 -1
  23. package/dist/TextureStreaming.d.ts +30 -0
  24. package/dist/TextureStreaming.d.ts.map +1 -1
  25. package/dist/TextureStreaming.js +53 -0
  26. package/dist/TextureStreaming.js.map +1 -1
  27. package/dist/browser-index.d.ts +7 -3
  28. package/dist/browser-index.d.ts.map +1 -1
  29. package/dist/browser-index.js +3 -1
  30. package/dist/browser-index.js.map +1 -1
  31. package/dist/gltf-runtime.d.ts +1 -1
  32. package/dist/gltf-runtime.d.ts.map +1 -1
  33. package/dist/gltf-runtime.js.map +1 -1
  34. package/dist/index.d.ts +7 -7
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/loaders/HDRLoader.d.ts +40 -0
  39. package/dist/loaders/HDRLoader.d.ts.map +1 -1
  40. package/dist/loaders/HDRLoader.js +158 -0
  41. package/dist/loaders/HDRLoader.js.map +1 -1
  42. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
- import { AnimationAction, AnimationClip, AnimationMixer, normalizeQuat, slerpQuat, solveTwoBoneIk } from "@aura3d/animation";
2
- import { invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
1
+ import { AnimationAction, AnimationClip, AnimationMixer, consumeRootMotion, extractRootMotion, createFootIkRig, normalizeQuat, slerpQuat, solveTwoBoneIk } from "@aura3d/animation";
2
+ import { composeMat4, decomposeMat4, invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
3
3
  /**
4
4
  * Synonym groups for fuzzy clip-name matching. Real catalog assets label the same
5
5
  * motion wildly differently ("Loops" vs "Idle", "Take 001" vs "Run", "sprint" vs
@@ -259,6 +259,12 @@ export class GLTFSceneAnimationRuntime {
259
259
  nodesByName = new Map();
260
260
  morphRenderablesByNodeName = new Map();
261
261
  skinningBindings = [];
262
+ footBindMatrices = new Map();
263
+ footOrientationLocks = new Map();
264
+ footDescendantLocks = new Map();
265
+ footSurfaceVertices = new Map();
266
+ footPlanting;
267
+ footRig;
262
268
  lastApply;
263
269
  constructor(options) {
264
270
  this.options = options;
@@ -266,6 +272,80 @@ export class GLTFSceneAnimationRuntime {
266
272
  this.clipsByName.set(clip.name, clip);
267
273
  }
268
274
  this.reindexScene();
275
+ if (options.footPlanting)
276
+ this.setFootPlanting(options.footPlanting);
277
+ }
278
+ /**
279
+ * Enable, replace, or clear (undefined) the foot-planting post-pass. Replacing the leg
280
+ * set, ground, or solve parameters resets foot-lock state so a stale lock can never drag
281
+ * a foot after retargeting. A matrix-only refresh (same shape, new `worldFromLocal`)
282
+ * keeps locks: the engine re-sends the model matrix every frame because time-animated
283
+ * model nodes move, and world-space locks stay valid across that refresh.
284
+ */
285
+ setFootPlanting(config) {
286
+ if (config !== undefined && config.legs.length === 0) {
287
+ throw new Error("glTF foot planting requires at least one leg.");
288
+ }
289
+ if (!sameFootPlantingShape(this.footPlanting, config)) {
290
+ this.footRig = undefined;
291
+ this.footOrientationLocks.clear();
292
+ this.footDescendantLocks.clear();
293
+ }
294
+ this.footPlanting = config;
295
+ }
296
+ /**
297
+ * Extract real authored displacement over unwrapped clip time, hand it to the physical
298
+ * authority once, then apply an in-place root pose and solve feet in the updated actor
299
+ * transform. Rejected movement is returned and discarded rather than accumulating drift.
300
+ * The caller refreshes footPlanting.worldFromLocal inside move after committing movement.
301
+ */
302
+ applyRootMotionClip(name, options) {
303
+ return this.applyRootMotionClips([{ clipName: name, ...options }], options);
304
+ }
305
+ /** Blend displacement and consumed poses with the same base/additive weights. */
306
+ applyRootMotionClips(samples, options) {
307
+ if (samples.length === 0)
308
+ throw new Error("glTF root motion blend requires at least one clip sample.");
309
+ const accumulators = new Map();
310
+ const motionAccumulators = new Map();
311
+ const unsupportedTracks = [];
312
+ const names = [];
313
+ let maxTime = 0;
314
+ // Finish validation and sampling before the physical authority can mutate the actor.
315
+ for (const sample of samples) {
316
+ const clip = this.clipsByName.get(sample.clipName);
317
+ if (!clip)
318
+ throw new Error(`glTF animation clip "${sample.clipName}" was not found.`);
319
+ const weight = sample.weight ?? 1;
320
+ if (!Number.isFinite(weight) || weight < 0)
321
+ throw new Error("glTF root motion blend weight must be finite and non-negative.");
322
+ const extracted = extractRootMotion(clip, sample);
323
+ if (weight === 0)
324
+ continue;
325
+ const time = sample.loop && clip.duration > 0
326
+ ? ((sample.toTime % clip.duration) + clip.duration) % clip.duration
327
+ : Math.max(0, Math.min(clip.duration, sample.toTime));
328
+ maxTime = Math.max(maxTime, time);
329
+ names.push(`${clip.name}@${time}x${weight}${sample.additive ? "+add" : ""}`);
330
+ blendInto(motionAccumulators, "motion", "vector3", extracted.delta, weight, sample.additive === true);
331
+ for (const track of clip.tracks) {
332
+ if (!parseAnimationTarget(track.target)) {
333
+ unsupportedTracks.push(track.target);
334
+ continue;
335
+ }
336
+ // Consume each clip's own root before blending, including additive root tracks.
337
+ const value = track.sample(track.target === sample.target ? 0 : time);
338
+ blendInto(accumulators, track.target, track.valueType, value, weight, sample.additive === true);
339
+ }
340
+ }
341
+ const values = new Map();
342
+ for (const [target, accumulator] of accumulators)
343
+ values.set(target, finalizeTargetBlend(accumulator));
344
+ const motionAccumulator = motionAccumulators.get("motion");
345
+ const delta = motionAccumulator ? finalizeTargetBlend(motionAccumulator) : [0, 0, 0];
346
+ const motion = consumeRootMotion({ target: "blend", fromTime: 0, toTime: maxTime, looped: samples.some(sample => sample.loop), delta }, options.worldFromLocal, options.move);
347
+ this.lastApply = this.applySampledTargets(`root-motion:${names.join(",")}`, maxTime, { sampledTargets: values, unsupportedTracks }, samples.length);
348
+ return { motion, applyResult: this.lastApply };
269
349
  }
270
350
  applyClipByName(name, time) {
271
351
  const clip = this.clipsByName.get(name);
@@ -337,7 +417,8 @@ export class GLTFSceneAnimationRuntime {
337
417
  unsupportedTracks.push(track.target);
338
418
  continue;
339
419
  }
340
- if (!clipMaskAllowsNode(sample.mask, target.nodeName))
420
+ // Bone masks filter node tracks only; material/light property tracks always apply.
421
+ if (target.kind === "node" && !clipMaskAllowsNode(sample.mask, target.nodeName))
341
422
  continue;
342
423
  blendInto(accumulators, track.target, track.valueType, track.sample(wrappedTime), weight, sample.additive === true);
343
424
  }
@@ -504,10 +585,16 @@ export class GLTFSceneAnimationRuntime {
504
585
  return [...this.clipsByName.values()].map((clip) => this.inspectClipBinding(clip));
505
586
  }
506
587
  reindexScene() {
588
+ this.options.scene.updateWorldTransforms();
589
+ this.footBindMatrices.clear();
590
+ this.footOrientationLocks.clear();
591
+ this.footDescendantLocks.clear();
507
592
  this.nodesByName.clear();
508
593
  this.morphRenderablesByNodeName.clear();
509
594
  this.skinningBindings.length = 0;
595
+ this.footSurfaceVertices.clear();
510
596
  this.options.scene.traverse((node) => {
597
+ this.footBindMatrices.set(node.id, { world: [...node.transform.worldMatrix], local: [...node.transform.localMatrix] });
511
598
  const nodes = this.nodesByName.get(node.name) ?? [];
512
599
  nodes.push(node);
513
600
  this.nodesByName.set(node.name, nodes);
@@ -530,9 +617,326 @@ export class GLTFSceneAnimationRuntime {
530
617
  const skin = mesh?.skinIndex === undefined ? undefined : this.options.asset.skins[mesh.skinIndex];
531
618
  if (!mesh || !skin || skin.joints.length > MAX_RENDERABLE_SKINNING_JOINTS)
532
619
  continue;
533
- this.skinningBindings.push({ node, renderable, mesh, skin });
620
+ this.skinningBindings.push({ node, renderable, mesh, skin, bindWorldMatrix: [...node.transform.worldMatrix] });
621
+ }
622
+ }
623
+ }
624
+ findSceneLight(name) {
625
+ return this.options.scene.collectLights().find((light) => light.name === name);
626
+ }
627
+ /**
628
+ * Foot-planting post-pass (E2): solves the configured leg chains against the ground,
629
+ * applies a shared pelvis reach correction, and rotates rigid hip/knee chains toward the
630
+ * solved targets. Missing leg nodes are reported in
631
+ * `missingLegNodes` and skipped, never faked. Returns undefined when unconfigured.
632
+ */
633
+ applyFootPlanting() {
634
+ const config = this.footPlanting;
635
+ if (!config)
636
+ return undefined;
637
+ this.options.scene.updateWorldTransforms();
638
+ // Actor-local → world for the solve, world → actor-local for write-back. The rig and
639
+ // the ground both speak world space (meters); the skeleton may live in import units
640
+ // (centimeters). Points convert through the matrix pair; hipOffset rides along inside
641
+ // the converted hip target point so any affine (scale, rotation, centering) is exact.
642
+ const toWorld = config.worldFromLocal;
643
+ const toLocal = toWorld ? invertMat4(toWorld) : undefined;
644
+ const solvePoint = (point) => (toWorld ? transformPoint(toWorld, point) : point);
645
+ // Solved legs arrive as readonly tuples; copy into mutable points for the transform.
646
+ const actorPoint = (point) => {
647
+ const mutable = [point[0], point[1], point[2]];
648
+ return toLocal ? transformPoint(toLocal, mutable) : mutable;
649
+ };
650
+ const missingLegNodes = [];
651
+ const resolved = [];
652
+ for (const leg of config.legs) {
653
+ const hip = this.nodesByName.get(leg.hip)?.[0];
654
+ const knee = this.nodesByName.get(leg.knee)?.[0];
655
+ const ankle = this.nodesByName.get(leg.ankle)?.[0];
656
+ if (!hip)
657
+ missingLegNodes.push(`${leg.side}:hip:${leg.hip}`);
658
+ if (!knee)
659
+ missingLegNodes.push(`${leg.side}:knee:${leg.knee}`);
660
+ if (!ankle)
661
+ missingLegNodes.push(`${leg.side}:ankle:${leg.ankle}`);
662
+ if (!hip || !knee || !ankle)
663
+ continue;
664
+ resolved.push({ side: leg.side, hip, knee, ankle, pole: leg.pole, ...(leg.contact === undefined ? {} : { contact: leg.contact }), ...(leg.support ? { support: leg.support } : {}) });
665
+ }
666
+ if (resolved.length === 0) {
667
+ return { groundedFeet: 0, averageTargetError: 0, lockedSides: [], missingLegNodes, hipOffset: 0 };
668
+ }
669
+ // Measure each animated ankle-to-sole offset from the exact pre-solve skinning
670
+ // palette. One global value cannot plant asymmetric shoes or a rotated touchdown.
671
+ this.refreshSkinningPalettes();
672
+ const rawSoles = this.sampleFootSurfaces();
673
+ const measuredAnkleHeights = new Map(resolved.map(entry => {
674
+ const ankle = solvePoint(worldPosition(entry.ankle));
675
+ const minimum = Math.min(Infinity, ...rawSoles.filter(point => point.side === entry.side).map(point => point.worldPosition[1]));
676
+ return [entry.side, Number.isFinite(minimum) ? Math.max(0, ankle[1] - minimum) : undefined];
677
+ }));
678
+ if (!this.footRig) {
679
+ this.footRig = createFootIkRig({
680
+ legs: resolved.map((entry) => ({
681
+ side: entry.side,
682
+ hip: solvePoint(worldPosition(entry.hip)),
683
+ knee: solvePoint(worldPosition(entry.knee)),
684
+ ankle: solvePoint(worldPosition(entry.ankle)),
685
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
686
+ ...(entry.pole ? { pole: entry.pole } : {})
687
+ })),
688
+ raycaster: config.ground,
689
+ ...(config.ankleHeight !== undefined ? { ankleHeight: config.ankleHeight } : {}),
690
+ ...(config.rayStartHeight !== undefined ? { rayStartHeight: config.rayStartHeight } : {}),
691
+ ...(config.maxRayDistance !== undefined ? { maxRayDistance: config.maxRayDistance } : {}),
692
+ ...(config.plantThreshold !== undefined ? { plantThreshold: config.plantThreshold } : {}),
693
+ ...(config.hipDropFactor !== undefined ? { hipDropFactor: config.hipDropFactor } : {})
694
+ });
695
+ }
696
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
697
+ const originalLengths = new Map(resolved.map(entry => [entry.side, {
698
+ upper: distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee))),
699
+ lower: distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)))
700
+ }]));
701
+ const solved = this.footRig.solveFootPlacement({
702
+ legs: resolved.map((entry) => ({
703
+ side: entry.side,
704
+ hip: solvePoint(worldPosition(entry.hip)),
705
+ knee: solvePoint(worldPosition(entry.knee)),
706
+ ankle: solvePoint(worldPosition(entry.ankle)),
707
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
708
+ ...(entry.pole ? { pole: entry.pole } : {}),
709
+ ...(entry.contact === undefined ? {} : { contact: entry.contact }),
710
+ ...(entry.support ? { support: entry.support } : {})
711
+ }))
712
+ });
713
+ // A reach correction belongs to the shared pelvis owner. Move it once before
714
+ // rotating either leg; translating each upper-leg root dislocates the chain,
715
+ // while omitting the drop leaves a fully extended stance foot above ground.
716
+ if (solved.hipOffset < 0) {
717
+ const common = commonAncestor(resolved.map(entry => entry.hip));
718
+ const anchors = common && common.parent ? [common] : [...new Set(resolved.map(entry => entry.hip))];
719
+ for (const anchor of anchors) {
720
+ const currentWorld = solvePoint(worldPosition(anchor));
721
+ const dropped = actorPoint([currentWorld[0], currentWorld[1] + solved.hipOffset, currentWorld[2]]);
722
+ setWorldPosition(anchor, dropped);
723
+ }
724
+ this.options.scene.updateWorldTransforms();
725
+ }
726
+ for (const foot of solved.feet) {
727
+ const entry = resolved.find((candidate) => candidate.side === foot.side);
728
+ if (!entry)
729
+ continue;
730
+ // Apply the two-bone solution with joint rotations. Moving hip/knee/ankle
731
+ // translations directly changes authored bone lengths and deforms every weighted
732
+ // mesh. The solver targets are world-space; orient each parent toward its solved
733
+ // child while preserving the animation's local translations and scales.
734
+ const kneeTarget = actorPoint(foot.knee);
735
+ orientJointToward(entry.hip, entry.knee, kneeTarget);
736
+ entry.hip.updateWorldTransform();
737
+ const ankleTarget = actorPoint(foot.ankle);
738
+ orientJointToward(entry.knee, entry.ankle, ankleTarget);
739
+ entry.knee.updateWorldTransform();
740
+ }
741
+ this.options.scene.updateWorldTransforms();
742
+ for (const foot of solved.feet) {
743
+ const entry = resolved.find(candidate => candidate.side === foot.side);
744
+ if (!foot.locked || config.lockFootRotation === false) {
745
+ this.footOrientationLocks.delete(foot.side);
746
+ this.footDescendantLocks.delete(foot.side);
747
+ continue;
748
+ }
749
+ let locked = this.footOrientationLocks.get(foot.side);
750
+ if (!locked) {
751
+ // Capture the actual animated sole orientation and toe pose at touchdown.
752
+ // Reusing the asset bind pose snaps arbitrary rigs and moves their sole vertices.
753
+ locked = toWorld ? multiplyMat4(toWorld, entry.ankle.transform.worldMatrix) : [...entry.ankle.transform.worldMatrix];
754
+ const descendants = new Map();
755
+ entry.ankle.children.forEach(child => child.traverse(node => descendants.set(node.id, [...node.transform.localMatrix])));
756
+ this.footDescendantLocks.set(foot.side, descendants);
757
+ const normal = foot.sample.groundNormal;
758
+ const norm = Math.hypot(...normal);
759
+ if (!(norm > 0))
760
+ throw new Error("Foot contact requires a nonzero ground normal.");
761
+ const nx = normal[0] / norm, ny = normal[1] / norm, nz = normal[2] / norm;
762
+ if (ny <= -0.999)
763
+ throw new Error("Foot contact cannot plant on a downward facing surface.");
764
+ const vx = nz, vz = -nx, t = 1 / (1 + ny);
765
+ const tilt = [1 - vz * vz * t, vz, vx * vz * t, 0, -vz, 1 - (vx * vx + vz * vz) * t, vx, 0, vx * vz * t, -vx, 1 - vx * vx * t, 0, 0, 0, 0, 1];
766
+ locked = multiplyMat4(tilt, locked);
767
+ this.footOrientationLocks.set(foot.side, locked);
768
+ }
769
+ // Keep the solved ankle position while holding the sole orientation in world space.
770
+ const ankleWorld = solvePoint(worldPosition(entry.ankle));
771
+ const desired = [...locked];
772
+ desired[12] = ankleWorld[0];
773
+ desired[13] = ankleWorld[1];
774
+ desired[14] = ankleWorld[2];
775
+ const actorMatrix = toLocal ? multiplyMat4(toLocal, desired) : desired;
776
+ const parent = entry.ankle.parent;
777
+ const local = parent ? multiplyMat4(invertMat4(parent.transform.worldMatrix), actorMatrix) : actorMatrix;
778
+ entry.ankle.transform.setFromLocalMatrix(local);
779
+ // Toe joints remain in their touchdown relationship during stance; animated
780
+ // toe curl otherwise moves the sole while the ankle still reports zero slip.
781
+ const touchdown = this.footDescendantLocks.get(foot.side);
782
+ const restore = (node) => {
783
+ const rest = touchdown?.get(node.id);
784
+ if (rest)
785
+ node.transform.setFromLocalMatrix(rest);
786
+ node.children.forEach(restore);
787
+ };
788
+ entry.ankle.children.forEach(restore);
789
+ entry.ankle.updateWorldTransform();
790
+ }
791
+ this.options.scene.updateWorldTransforms();
792
+ return {
793
+ legDeformation: resolved.map(entry => {
794
+ const before = originalLengths.get(entry.side);
795
+ const upperAfter = distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee)));
796
+ const lowerAfter = distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)));
797
+ return { side: entry.side, upperBefore: before.upper, lowerBefore: before.lower, upperAfter, lowerAfter,
798
+ maxRelativeLengthChange: Math.max(Math.abs(upperAfter - before.upper) / Math.max(1e-9, before.upper), Math.abs(lowerAfter - before.lower) / Math.max(1e-9, before.lower)) };
799
+ }),
800
+ feet: solved.feet.map(foot => {
801
+ const entry = resolved.find(candidate => candidate.side === foot.side);
802
+ const position = solvePoint(worldPosition(entry.ankle));
803
+ return { side: foot.side, worldPosition: position, locked: foot.locked,
804
+ contactError: Math.hypot(...position.map((value, axis) => value - foot.sample.plantedFoot[axis])) };
805
+ }),
806
+ groundedFeet: solved.groundedFeet,
807
+ averageTargetError: solved.averageTargetError,
808
+ lockedSides: solved.feet.filter((foot) => foot.locked).map((foot) => foot.side),
809
+ missingLegNodes,
810
+ hipOffset: solved.hipOffset
811
+ };
812
+ }
813
+ /**
814
+ * Applies one sampled `material:<name>.<leaf-path>` value to the live material sink.
815
+ * Returns false (→ missing target, diagnosed) when no sink resolves, the leaf maps to no
816
+ * renderer uniform, or the value shape is wrong. Throws on a sampled value that is not
817
+ * finite, matching the node-path converters below.
818
+ */
819
+ applyMaterialTrackValue(materialName, leafPath, value, trackTarget) {
820
+ const parameters = materialParametersForLeaf(leafPath);
821
+ const sink = this.options.resolveAnimationMaterial?.(materialName);
822
+ if (parameters === undefined || sink === undefined)
823
+ return false;
824
+ const numeric = asMaterialLeafValue(value, trackTarget);
825
+ for (const parameter of parameters) {
826
+ sink.setAnimationParameter(parameter, numeric);
827
+ }
828
+ return true;
829
+ }
830
+ /**
831
+ * Applies one sampled `light:<name>.<leaf>` value to the live scene light of that name.
832
+ * The loader instantiates each node-referenced `KHR_lights_punctual` light as a scene light
833
+ * carrying its node's name; extension-defined lights no node references have no scene object,
834
+ * so a light track matching no scene light is reported missing (diagnosed, never silent).
835
+ */
836
+ applyLightTrackValue(lightName, leaf, value, trackTarget) {
837
+ if (lightLeafApplies(leaf) === false)
838
+ return false;
839
+ const light = this.findSceneLight(lightName);
840
+ if (!light)
841
+ return false;
842
+ if (leaf === "intensity") {
843
+ light.intensity = asScalar(value, trackTarget);
844
+ return true;
845
+ }
846
+ if (leaf === "color") {
847
+ light.color = asVec3(value, trackTarget);
848
+ return true;
849
+ }
850
+ const rangeHolder = light;
851
+ if (typeof rangeHolder.range !== "number")
852
+ return false;
853
+ light.range = asPositiveScalar(value, trackTarget);
854
+ return true;
855
+ }
856
+ /** CPU skinning oracle using the exact palette submitted to rendering, not IK targets. */
857
+ sampleFootSurfaces() {
858
+ const config = this.footPlanting;
859
+ if (!config)
860
+ return [];
861
+ const samples = [];
862
+ for (const leg of config.legs) {
863
+ const ankle = this.nodesByName.get(leg.ankle)?.[0];
864
+ if (!ankle)
865
+ continue;
866
+ const footNames = new Set();
867
+ const visit = (node) => { footNames.add(node.name); node.children.forEach(visit); };
868
+ visit(ankle);
869
+ for (const binding of this.skinningBindings) {
870
+ const palette = binding.renderable.skinning;
871
+ if (!palette)
872
+ continue;
873
+ const mesh = binding.mesh;
874
+ const cacheKey = `${leg.ankle}:${binding.node.id}:${mesh.name}`;
875
+ let selected = this.footSurfaceVertices.get(cacheKey);
876
+ if (!selected) {
877
+ const candidates = [];
878
+ for (let vertex = 0; vertex < mesh.positions.length; vertex++) {
879
+ let footWeight = 0;
880
+ for (let lane = 0; lane < 4; lane++) {
881
+ if (footNames.has(binding.skin.jointNames[mesh.joints[vertex]?.[lane] ?? -1] ?? ""))
882
+ footWeight += mesh.weights[vertex]?.[lane] ?? 0;
883
+ if (footNames.has(binding.skin.jointNames[mesh.joints1?.[vertex]?.[lane] ?? -1] ?? ""))
884
+ footWeight += mesh.weights1?.[vertex]?.[lane] ?? 0;
885
+ }
886
+ if (footWeight >= 0.5)
887
+ candidates.push(vertex);
888
+ }
889
+ if (!candidates.length) {
890
+ this.footSurfaceVertices.set(cacheKey, []);
891
+ continue;
892
+ }
893
+ // Fixed bind-space sole strip: never reselect vertices from animated positions.
894
+ const bindHeights = new Map(candidates.map(vertex => [vertex, transformPoint(binding.bindWorldMatrix, [...mesh.positions[vertex]])[1]]));
895
+ const ys = [...bindHeights.values()];
896
+ const minimum = Math.min(...ys), maximum = Math.max(...ys);
897
+ const strip = Math.max(1e-6, (maximum - minimum) * 0.001);
898
+ selected = candidates.filter(index => bindHeights.get(index) <= minimum + strip);
899
+ this.footSurfaceVertices.set(cacheKey, selected);
900
+ }
901
+ const toWorld = config.worldFromLocal ? multiplyMat4(config.worldFromLocal, binding.node.transform.worldMatrix) : binding.node.transform.worldMatrix;
902
+ for (const vertex of selected) {
903
+ const authored = [...mesh.positions[vertex]];
904
+ for (let morph = 0; morph < mesh.morphTargets.length; morph++) {
905
+ const weight = binding.renderable.morphWeights?.[morph] ?? 0;
906
+ const delta = mesh.morphTargets[morph]?.positions[vertex];
907
+ if (delta && weight)
908
+ for (let axis = 0; axis < 3; axis++)
909
+ authored[axis] += delta[axis] * weight;
910
+ }
911
+ const skinned = [0, 0, 0];
912
+ let total = 0;
913
+ for (let set = 0; set < 2; set++)
914
+ for (let lane = 0; lane < 4; lane++) {
915
+ const weight = (set ? mesh.weights1?.[vertex]?.[lane] : mesh.weights[vertex]?.[lane]) ?? 0;
916
+ const joint = (set ? mesh.joints1?.[vertex]?.[lane] : mesh.joints[vertex]?.[lane]) ?? -1;
917
+ if (weight <= 0)
918
+ continue;
919
+ if (joint < 0 || joint >= palette.jointCount)
920
+ throw new Error("Foot surface vertex has an invalid palette influence.");
921
+ const point = transformPoint(Array.from(palette.matrices.subarray(joint * 16, joint * 16 + 16)), authored);
922
+ for (let axis = 0; axis < 3; axis++)
923
+ skinned[axis] += point[axis] * weight;
924
+ total += weight;
925
+ }
926
+ if (Math.abs(total - 1) > 0.001)
927
+ throw new Error("Foot surface vertex weights must sum to one.");
928
+ const worldPosition = transformPoint(toWorld, skinned);
929
+ const hit = config.ground.raycastDown([worldPosition[0], worldPosition[1] + 2, worldPosition[2]], 4);
930
+ samples.push({ side: leg.side, mesh: `${binding.node.userData.gltfNodeIndex ?? binding.node.name}:${mesh.name}`, vertex, worldPosition,
931
+ groundError: hit ? worldPosition[1] - hit.point[1] : null,
932
+ ...(vertex === selected[0] ? { skinningProbe: { position: authored,
933
+ joints: [...(mesh.joints[vertex] ?? [0, 0, 0, 0]), ...(mesh.joints1?.[vertex] ?? [0, 0, 0, 0])],
934
+ weights: [...(mesh.weights[vertex] ?? [0, 0, 0, 0]), ...(mesh.weights1?.[vertex] ?? [0, 0, 0, 0])],
935
+ matrices: Array.from(palette.matrices), modelMatrix: [...toWorld], expectedWorldPosition: worldPosition } } : {}) });
936
+ }
534
937
  }
535
938
  }
939
+ return samples;
536
940
  }
537
941
  refreshSkinningPalettes() {
538
942
  if (this.skinningBindings.length === 0) {
@@ -571,6 +975,10 @@ export class GLTFSceneAnimationRuntime {
571
975
  let boundTrackCount = 0;
572
976
  let transformTrackCount = 0;
573
977
  let morphWeightTrackCount = 0;
978
+ let materialTrackCount = 0;
979
+ let lightTrackCount = 0;
980
+ const boundMaterialNames = new Set();
981
+ const boundLightNames = new Set();
574
982
  const boundNodeNames = new Set();
575
983
  const missingTargets = new Set();
576
984
  const unsupportedTracks = new Set();
@@ -581,6 +989,26 @@ export class GLTFSceneAnimationRuntime {
581
989
  continue;
582
990
  }
583
991
  supportedTrackCount += 1;
992
+ if (target.kind === "material") {
993
+ materialTrackCount += 1;
994
+ if (materialParametersForLeaf(target.leaf) === undefined || this.options.resolveAnimationMaterial?.(target.materialName) === undefined) {
995
+ missingTargets.add(track.target);
996
+ continue;
997
+ }
998
+ boundTrackCount += 1;
999
+ boundMaterialNames.add(target.materialName);
1000
+ continue;
1001
+ }
1002
+ if (target.kind === "light") {
1003
+ lightTrackCount += 1;
1004
+ if (lightLeafApplies(target.leaf) === false || this.findSceneLight(target.lightName) === undefined) {
1005
+ missingTargets.add(track.target);
1006
+ continue;
1007
+ }
1008
+ boundTrackCount += 1;
1009
+ boundLightNames.add(target.lightName);
1010
+ continue;
1011
+ }
584
1012
  if (target.path === "weights") {
585
1013
  morphWeightTrackCount += 1;
586
1014
  const renderables = this.morphRenderablesByNodeName.get(target.nodeName) ?? [];
@@ -608,6 +1036,10 @@ export class GLTFSceneAnimationRuntime {
608
1036
  boundTrackCount,
609
1037
  transformTrackCount,
610
1038
  morphWeightTrackCount,
1039
+ materialTrackCount,
1040
+ lightTrackCount,
1041
+ boundMaterialNames: [...boundMaterialNames].sort(),
1042
+ boundLightNames: [...boundLightNames].sort(),
611
1043
  missingTargetCount: missingTargets.size,
612
1044
  unsupportedTrackCount: unsupportedTracks.size,
613
1045
  skinningBindingCount: this.skinningBindings.length,
@@ -620,12 +1052,32 @@ export class GLTFSceneAnimationRuntime {
620
1052
  applySampledTargets(clipName, time, sampled, blendedClipCount) {
621
1053
  let transformTracksApplied = 0;
622
1054
  let morphWeightTracksApplied = 0;
1055
+ let materialTracksApplied = 0;
1056
+ let lightTracksApplied = 0;
623
1057
  const missingTargets = [];
624
1058
  for (const [trackTarget, value] of sampled.sampledTargets) {
625
1059
  const target = parseAnimationTarget(trackTarget);
626
1060
  if (!target) {
627
1061
  continue;
628
1062
  }
1063
+ if (target.kind === "material") {
1064
+ if (this.applyMaterialTrackValue(target.materialName, target.leaf, value, trackTarget)) {
1065
+ materialTracksApplied += 1;
1066
+ }
1067
+ else {
1068
+ missingTargets.push(trackTarget);
1069
+ }
1070
+ continue;
1071
+ }
1072
+ if (target.kind === "light") {
1073
+ if (this.applyLightTrackValue(target.lightName, target.leaf, value, trackTarget)) {
1074
+ lightTracksApplied += 1;
1075
+ }
1076
+ else {
1077
+ missingTargets.push(trackTarget);
1078
+ }
1079
+ continue;
1080
+ }
629
1081
  if (target.path === "weights") {
630
1082
  const renderables = this.morphRenderablesByNodeName.get(target.nodeName) ?? [];
631
1083
  if (renderables.length === 0) {
@@ -650,14 +1102,19 @@ export class GLTFSceneAnimationRuntime {
650
1102
  transformTracksApplied += 1;
651
1103
  }
652
1104
  this.options.scene.updateWorldTransforms();
1105
+ const footPlanting = this.applyFootPlanting();
653
1106
  const skinning = this.refreshSkinningPalettes();
1107
+ const measuredFootPlanting = footPlanting ? { ...footPlanting, surfaces: this.sampleFootSurfaces() } : undefined;
654
1108
  return {
655
1109
  clipName,
656
1110
  time,
657
1111
  ...(blendedClipCount === undefined ? {} : { blendedClipCount }),
658
- tracksApplied: transformTracksApplied + morphWeightTracksApplied,
1112
+ tracksApplied: transformTracksApplied + morphWeightTracksApplied + materialTracksApplied + lightTracksApplied,
659
1113
  transformTracksApplied,
660
1114
  morphWeightTracksApplied,
1115
+ materialTracksApplied,
1116
+ lightTracksApplied,
1117
+ ...(measuredFootPlanting === undefined ? {} : { footPlanting: measuredFootPlanting }),
661
1118
  skinningPalettesUpdated: skinning.updated,
662
1119
  missingTargets: [...missingTargets, ...skinning.missingTargets],
663
1120
  unsupportedTracks: sampled.unsupportedTracks
@@ -679,6 +1136,7 @@ export class GLTFSceneAnimationMixerBinding {
679
1136
  constructor(options) {
680
1137
  this.runtime = new GLTFSceneAnimationRuntime(options);
681
1138
  this.mixer = new AnimationMixer({
1139
+ ...(options.consumeRootMotion ? { consumeRootMotion: options.consumeRootMotion } : {}),
682
1140
  setAnimationValue: (target, value) => {
683
1141
  this.pendingValues.set(target, cloneAnimationValue(value));
684
1142
  }
@@ -867,7 +1325,27 @@ export class GLTFSceneAnimationMixerBinding {
867
1325
  export function createGLTFSceneAnimationMixer(options) {
868
1326
  return new GLTFSceneAnimationMixerBinding(options);
869
1327
  }
1328
+ /**
1329
+ * Pointer-bound leaf paths (`material:`/`light:`) use the FIRST dot as the name/leaf split:
1330
+ * the binding emits `material:<name>.<property-path…>` where the property path itself may
1331
+ * contain dots (`pbrMetallicRoughness.baseColorFactor`), while glTF material/light names with
1332
+ * dots are rare. Names containing "." therefore resolve by first segment and are documented
1333
+ * as unsupported in the animation-pointer limits.
1334
+ */
870
1335
  function parseAnimationTarget(target) {
1336
+ for (const prefix of ["material:", "light:"]) {
1337
+ if (target.startsWith(prefix)) {
1338
+ const rest = target.slice(prefix.length);
1339
+ const separator = rest.indexOf(".");
1340
+ if (separator <= 0 || separator === rest.length - 1)
1341
+ return undefined;
1342
+ const name = rest.slice(0, separator);
1343
+ const leaf = rest.slice(separator + 1);
1344
+ return prefix === "material:"
1345
+ ? { kind: "material", materialName: name, leaf }
1346
+ : { kind: "light", lightName: name, leaf };
1347
+ }
1348
+ }
871
1349
  const separator = target.lastIndexOf(".");
872
1350
  if (separator <= 0 || separator === target.length - 1) {
873
1351
  return undefined;
@@ -877,10 +1355,44 @@ function parseAnimationTarget(target) {
877
1355
  return undefined;
878
1356
  }
879
1357
  return {
1358
+ kind: "node",
880
1359
  nodeName: target.slice(0, separator),
881
1360
  path
882
1361
  };
883
1362
  }
1363
+ /**
1364
+ * Maps an animation-pointer material leaf path to the renderer uniform parameters it drives.
1365
+ * Only leaves with uniforms the production bridge provably writes (see TypedGLBActor tint
1366
+ * path: `u_baseColor`, `u_baseColorFactor`, `u_emissiveColor`, `u_emissiveFactor`,
1367
+ * `u_emissiveStrength`, `u_metallic`, `u_roughness`) are supported; every other leaf stays
1368
+ * diagnostic-only, preserving diagnose-and-drop for the unmapped remainder. Matches on the
1369
+ * last path segment so `pbrMetallicRoughness.baseColorFactor` and `baseColorFactor` agree.
1370
+ */
1371
+ function materialParametersForLeaf(leafPath) {
1372
+ const leaf = leafPath.slice(leafPath.lastIndexOf(".") + 1);
1373
+ switch (leaf) {
1374
+ case "baseColorFactor":
1375
+ return ["u_baseColorFactor", "u_baseColor"];
1376
+ case "emissiveFactor":
1377
+ return ["u_emissiveFactor", "u_emissiveColor"];
1378
+ case "metallicFactor":
1379
+ return ["u_metallic"];
1380
+ case "roughnessFactor":
1381
+ return ["u_roughness"];
1382
+ case "emissiveStrength":
1383
+ return ["u_emissiveStrength"];
1384
+ case "clearcoatFactor":
1385
+ return ["u_clearcoatFactor"];
1386
+ case "clearcoatRoughnessFactor":
1387
+ return ["u_clearcoatRoughnessFactor"];
1388
+ default:
1389
+ return undefined;
1390
+ }
1391
+ }
1392
+ /** Light leaves the runtime can drive on a live scene light. Anything else stays diagnostic-only. */
1393
+ function lightLeafApplies(leaf) {
1394
+ return leaf === "intensity" || leaf === "color" || leaf === "range";
1395
+ }
884
1396
  function resolveIKSkin(skins, skinName) {
885
1397
  if (skinName === undefined) {
886
1398
  const skin = skins.find((candidate) => candidate.jointNames.length >= 3);
@@ -907,10 +1419,88 @@ function firstTwoBoneJointChain(skin) {
907
1419
  }
908
1420
  return [root, mid, end];
909
1421
  }
1422
+ /**
1423
+ * True when two foot-planting configs solve the same rig the same way. The world matrix is
1424
+ * deliberately excluded: the engine refreshes `worldFromLocal` every frame (time-animated
1425
+ * model nodes move) and that refresh must not reset world-space foot locks.
1426
+ */
1427
+ function sameFootPlantingShape(previous, next) {
1428
+ if (previous === next)
1429
+ return true;
1430
+ if (!previous || !next)
1431
+ return false;
1432
+ if (previous.ground !== next.ground)
1433
+ return false;
1434
+ if (previous.ankleHeight !== next.ankleHeight)
1435
+ return false;
1436
+ if (previous.rayStartHeight !== next.rayStartHeight)
1437
+ return false;
1438
+ if (previous.maxRayDistance !== next.maxRayDistance)
1439
+ return false;
1440
+ if (previous.plantThreshold !== next.plantThreshold)
1441
+ return false;
1442
+ if (previous.hipDropFactor !== next.hipDropFactor)
1443
+ return false;
1444
+ if (previous.lockFootRotation !== next.lockFootRotation)
1445
+ return false;
1446
+ if (previous.legs.length !== next.legs.length)
1447
+ return false;
1448
+ return previous.legs.every((leg, index) => {
1449
+ const other = next.legs[index];
1450
+ return other !== undefined
1451
+ && leg.side === other.side
1452
+ && leg.hip === other.hip
1453
+ && leg.knee === other.knee
1454
+ && leg.ankle === other.ankle
1455
+ && leg.ankleHeight === other.ankleHeight
1456
+ && (leg.pole ?? []).join(",") === (other.pole ?? []).join(",");
1457
+ });
1458
+ }
910
1459
  function worldPosition(node) {
911
1460
  node.updateWorldTransform();
912
1461
  return [node.transform.worldMatrix[12], node.transform.worldMatrix[13], node.transform.worldMatrix[14]];
913
1462
  }
1463
+ function commonAncestor(nodes) {
1464
+ if (nodes.length === 0)
1465
+ return undefined;
1466
+ const first = [];
1467
+ for (let node = nodes[0]; node; node = node.parent)
1468
+ first.push(node);
1469
+ return first.find(candidate => nodes.every(node => node === candidate || candidate.isAncestorOf(node)));
1470
+ }
1471
+ function orientJointToward(node, child, target) {
1472
+ node.updateWorldTransform();
1473
+ child.updateWorldTransform();
1474
+ const origin = worldPosition(node);
1475
+ const current = worldPosition(child);
1476
+ const from = [current[0] - origin[0], current[1] - origin[1], current[2] - origin[2]];
1477
+ const to = [target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]];
1478
+ const delta = quatFromUnitVectors(from, to);
1479
+ const worldRotation = multiplyRigidQuat(delta, decomposeMat4(node.transform.worldMatrix).rotation);
1480
+ const parentRotation = node.parent ? decomposeMat4(node.parent.transform.worldMatrix).rotation : [0, 0, 0, 1];
1481
+ const localRotation = multiplyRigidQuat(invertRigidQuat(parentRotation), worldRotation);
1482
+ node.transform.setRotation(localRotation[0], localRotation[1], localRotation[2], localRotation[3]);
1483
+ }
1484
+ function quatFromUnitVectors(from, to) {
1485
+ const fl = Math.hypot(...from), tl = Math.hypot(...to);
1486
+ if (!(fl > 1e-12) || !(tl > 1e-12))
1487
+ return [0, 0, 0, 1];
1488
+ const fx = from[0] / fl, fy = from[1] / fl, fz = from[2] / fl, tx = to[0] / tl, ty = to[1] / tl, tz = to[2] / tl;
1489
+ const dot = fx * tx + fy * ty + fz * tz;
1490
+ if (dot < -0.999999) {
1491
+ const axis = Math.abs(fx) > 0.1 ? [-fz, 0, fx] : [0, fz, -fy];
1492
+ const length = Math.hypot(...axis);
1493
+ return [axis[0] / length, axis[1] / length, axis[2] / length, 0];
1494
+ }
1495
+ if (dot > 0.999999)
1496
+ return [0, 0, 0, 1];
1497
+ return normalizeQuaternion([fy * tz - fz * ty, fz * tx - fx * tz, fx * ty - fy * tx, 1 + dot]);
1498
+ }
1499
+ function multiplyRigidQuat(a, b) {
1500
+ return normalizeQuaternion([a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]]);
1501
+ }
1502
+ function invertRigidQuat(q) { return [-q[0], -q[1], -q[2], q[3]]; }
1503
+ function normalizeQuaternion(q) { const n = Math.hypot(...q); return n > 0 ? [q[0] / n, q[1] / n, q[2] / n, q[3] / n] : [0, 0, 0, 1]; }
914
1504
  function setWorldPosition(node, position) {
915
1505
  const local = node.parent
916
1506
  ? transformPoint(invertMat4(node.parent.transform.worldMatrix), [position[0], position[1], position[2]])
@@ -967,6 +1557,26 @@ function asNumberArray(value) {
967
1557
  }
968
1558
  return [...value];
969
1559
  }
1560
+ function asScalar(value, target) {
1561
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1562
+ throw new Error(`glTF animation track "${target}" sampled an invalid scalar.`);
1563
+ }
1564
+ return value;
1565
+ }
1566
+ function asPositiveScalar(value, target) {
1567
+ const scalar = asScalar(value, target);
1568
+ if (!(scalar > 0)) {
1569
+ throw new Error(`glTF animation track "${target}" sampled a non-positive range.`);
1570
+ }
1571
+ return scalar;
1572
+ }
1573
+ function asMaterialLeafValue(value, target) {
1574
+ if (typeof value === "number")
1575
+ return asScalar(value, target);
1576
+ if (Array.isArray(value))
1577
+ return asNumberArray(value);
1578
+ throw new Error(`glTF animation track "${target}" sampled an invalid material value.`);
1579
+ }
970
1580
  function sampleClipTracks(clip, time, weight, additive) {
971
1581
  const accumulators = new Map();
972
1582
  const unsupportedTracks = [];