@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.
- package/dist/GLTFAnimationRuntime.d.ts +175 -2
- package/dist/GLTFAnimationRuntime.d.ts.map +1 -1
- package/dist/GLTFAnimationRuntime.js +615 -5
- package/dist/GLTFAnimationRuntime.js.map +1 -1
- package/dist/GLTFCompressionDecoders.d.ts +1 -0
- package/dist/GLTFCompressionDecoders.d.ts.map +1 -1
- package/dist/GLTFCompressionDecoders.js +20 -5
- package/dist/GLTFCompressionDecoders.js.map +1 -1
- package/dist/GLTFExtensionSupport.js +2 -2
- package/dist/GLTFExtensionSupport.js.map +1 -1
- package/dist/GLTFLoader.d.ts +360 -1
- package/dist/GLTFLoader.d.ts.map +1 -1
- package/dist/GLTFLoader.js +218 -8
- package/dist/GLTFLoader.js.map +1 -1
- package/dist/KTX2BasisTextureTranscoder.d.ts +39 -0
- package/dist/KTX2BasisTextureTranscoder.d.ts.map +1 -1
- package/dist/KTX2BasisTextureTranscoder.js +78 -0
- package/dist/KTX2BasisTextureTranscoder.js.map +1 -1
- package/dist/TexturePipeline.d.ts +20 -0
- package/dist/TexturePipeline.d.ts.map +1 -1
- package/dist/TexturePipeline.js +20 -0
- package/dist/TexturePipeline.js.map +1 -1
- package/dist/TextureStreaming.d.ts +30 -0
- package/dist/TextureStreaming.d.ts.map +1 -1
- package/dist/TextureStreaming.js +53 -0
- package/dist/TextureStreaming.js.map +1 -1
- package/dist/browser-index.d.ts +7 -3
- package/dist/browser-index.d.ts.map +1 -1
- package/dist/browser-index.js +3 -1
- package/dist/browser-index.js.map +1 -1
- package/dist/gltf-runtime.d.ts +1 -1
- package/dist/gltf-runtime.d.ts.map +1 -1
- package/dist/gltf-runtime.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/loaders/HDRLoader.d.ts +40 -0
- package/dist/loaders/HDRLoader.d.ts.map +1 -1
- package/dist/loaders/HDRLoader.js +158 -0
- package/dist/loaders/HDRLoader.js.map +1 -1
- 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
|
-
|
|
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 = [];
|