@ninkch/dsh-infinite-canvas 0.19.0 → 0.19.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.
- package/assets/director/engine.js +984 -39
- package/client.js +594 -89
- package/index.js +68 -4
- package/package.json +1 -1
|
@@ -659,6 +659,133 @@ export const BONE_POSES = [
|
|
|
659
659
|
{ bone: 'LeftUpLeg', to: [0.26, 0.10, 0.96] }, { bone: 'LeftLeg', to: [0.10, -0.86, 0.50] },
|
|
660
660
|
{ bone: 'LeftFoot', to: [0.06, -0.62, 0.78] },
|
|
661
661
|
] },
|
|
662
|
+
|
|
663
|
+
// ============================================================
|
|
664
|
+
// 打斗动作库(依据参考片段 6:20–6:30 的动作语义编写,方向式)
|
|
665
|
+
// 命名:spear_* / block_* / dodge_* / hit_* / ult_*
|
|
666
|
+
// 配合 poseAnim 串成打斗序列;每个动作都可单帧关键帧使用
|
|
667
|
+
// ============================================================
|
|
668
|
+
// ① 侧身持枪起势:侧身对敌、双手前后持杆、重心下沉
|
|
669
|
+
{ id: 'spear_ready', zh: '持枪起势', en: 'Spear ready',
|
|
670
|
+
base: { Spine: [0.10, 0.42, 0], Spine1: [0.06, 0.20, 0], Hips: [0.02, 0.16, 0], Neck: [0.06, -0.30, 0] },
|
|
671
|
+
dirs: [
|
|
672
|
+
{ bone: 'RightArm', to: [-0.10, -0.30, 0.95] }, { bone: 'RightForeArm', to: [-0.04, -0.10, 1.0] },
|
|
673
|
+
{ bone: 'LeftArm', to: [0.16, -0.42, 0.90] }, { bone: 'LeftForeArm', to: [0.06, -0.24, 0.97] },
|
|
674
|
+
{ bone: 'LeftUpLeg', to: [0.16, -0.92, 0.36] }, { bone: 'RightUpLeg', to: [-0.16, -0.94, -0.30] },
|
|
675
|
+
{ bone: 'LeftLeg', to: [0.06, -0.97, 0.22] }, { bone: 'RightLeg', to: [-0.06, -0.97, 0.20] },
|
|
676
|
+
] },
|
|
677
|
+
// ② 横枪突刺:腰部拧转、双手前推、前腿弓步
|
|
678
|
+
{ id: 'spear_thrust', zh: '横枪突刺', en: 'Spear thrust',
|
|
679
|
+
base: { Spine: [0.16, -0.46, 0], Spine1: [0.10, -0.24, 0], Hips: [0.04, -0.30, 0], Neck: [0.08, 0.34, 0] },
|
|
680
|
+
dirs: [
|
|
681
|
+
{ bone: 'RightArm', to: [-0.06, 0.02, 1.0] }, { bone: 'RightForeArm', to: [-0.04, 0.02, 1.0] },
|
|
682
|
+
{ bone: 'LeftArm', to: [0.12, -0.10, 0.98] }, { bone: 'LeftForeArm', to: [0.06, -0.04, 1.0] },
|
|
683
|
+
{ bone: 'LeftUpLeg', to: [0.10, -0.88, 0.46] }, { bone: 'LeftLeg', to: [0.04, -0.97, 0.22] },
|
|
684
|
+
{ bone: 'RightUpLeg', to: [-0.12, -0.98, -0.16] }, { bone: 'RightLeg', to: [-0.04, -1.0, 0.06] },
|
|
685
|
+
{ bone: 'RightFoot', to: [-0.04, -0.40, 0.70] },
|
|
686
|
+
] },
|
|
687
|
+
// ③ 单掌格挡:单臂上架、掌面外翻、躯干侧倾卸力
|
|
688
|
+
{ id: 'palm_block', zh: '单掌格挡', en: 'Palm block',
|
|
689
|
+
base: { Spine: [-0.14, 0.34, 0], Spine1: [-0.08, 0.18, 0], Hips: [-0.04, 0.12, 0], Neck: [0.10, -0.26, 0] },
|
|
690
|
+
dirs: [
|
|
691
|
+
{ bone: 'RightArm', to: [-0.52, 0.66, 0.54] }, { bone: 'RightForeArm', to: [-0.46, 0.86, 0.22] },
|
|
692
|
+
{ bone: 'LeftArm', to: [0.24, -0.62, 0.74] }, { bone: 'LeftForeArm', to: [0.12, 0.10, 0.98] },
|
|
693
|
+
{ bone: 'LeftUpLeg', to: [0.14, -0.94, 0.30] }, { bone: 'RightUpLeg', to: [-0.14, -0.92, -0.34] },
|
|
694
|
+
{ bone: 'LeftLeg', to: [0.04, -0.98, 0.18] }, { bone: 'RightLeg', to: [-0.04, -0.98, 0.16] },
|
|
695
|
+
] },
|
|
696
|
+
// ④ 后仰闪避:上身大幅后仰、髋部后坐、双手回收护身
|
|
697
|
+
{ id: 'dodge_lean', zh: '后仰闪避', en: 'Lean back dodge',
|
|
698
|
+
base: { Spine: [-0.62, 0.20, 0], Spine1: [-0.40, 0.12, 0], Hips: [0.18, 0.08, 0], Neck: [0.30, -0.16, 0] },
|
|
699
|
+
dirs: [
|
|
700
|
+
{ bone: 'RightArm', to: [-0.30, -0.30, 0.90] }, { bone: 'RightForeArm', to: [-0.18, 0.34, 0.92] },
|
|
701
|
+
{ bone: 'LeftArm', to: [0.30, -0.34, 0.88] }, { bone: 'LeftForeArm', to: [0.18, 0.30, 0.94] },
|
|
702
|
+
{ bone: 'LeftUpLeg', to: [0.18, -0.86, 0.48] }, { bone: 'LeftLeg', to: [0.06, -0.96, 0.26] },
|
|
703
|
+
{ bone: 'RightUpLeg', to: [-0.18, -0.90, -0.38] }, { bone: 'RightLeg', to: [-0.06, -0.98, 0.16] },
|
|
704
|
+
] },
|
|
705
|
+
// ⑤ 蹬地冲刺:前腿蹬直、后腿抬起、上身前压、双臂前后摆
|
|
706
|
+
{ id: 'dash_forward', zh: '蹬地冲刺', en: 'Dash',
|
|
707
|
+
base: { Spine: [0.46, 0.10, 0], Spine1: [0.28, 0.06, 0], Hips: [0.16, 0.04, 0], Neck: [-0.34, -0.08, 0] },
|
|
708
|
+
dirs: [
|
|
709
|
+
{ bone: 'RightArm', to: [-0.20, -0.72, 0.66] }, { bone: 'RightForeArm', to: [-0.10, 0.20, 0.97] },
|
|
710
|
+
{ bone: 'LeftArm', to: [0.24, 0.30, -0.92] }, { bone: 'LeftForeArm', to: [0.12, 0.10, -0.98] },
|
|
711
|
+
{ bone: 'LeftUpLeg', to: [0.10, -0.96, 0.26] }, { bone: 'LeftLeg', to: [0.04, -1.0, 0.02] },
|
|
712
|
+
{ bone: 'RightUpLeg', to: [-0.10, 0.52, -0.84] }, { bone: 'RightLeg', to: [-0.06, -0.30, -0.95] },
|
|
713
|
+
{ bone: 'RightFoot', to: [-0.04, -0.20, -0.98] },
|
|
714
|
+
] },
|
|
715
|
+
// ⑥ 压上发狠:上身前探、双臂收紧、下颌压低(近身压制的瞬间)
|
|
716
|
+
{ id: 'grit_advance', zh: '压上发狠', en: 'Grit advance',
|
|
717
|
+
base: { Spine: [0.34, -0.18, 0], Spine1: [0.22, -0.10, 0], Hips: [0.10, -0.12, 0], Neck: [0.16, 0.14, 0] },
|
|
718
|
+
dirs: [
|
|
719
|
+
{ bone: 'RightArm', to: [-0.14, -0.44, 0.88] }, { bone: 'RightForeArm', to: [-0.06, 0.26, 0.96] },
|
|
720
|
+
{ bone: 'LeftArm', to: [0.16, -0.50, 0.84] }, { bone: 'LeftForeArm', to: [0.06, 0.20, 0.97] },
|
|
721
|
+
{ bone: 'LeftUpLeg', to: [0.12, -0.92, 0.38] }, { bone: 'LeftLeg', to: [0.04, -0.98, 0.18] },
|
|
722
|
+
{ bone: 'RightUpLeg', to: [-0.12, -0.94, -0.32] }, { bone: 'RightLeg', to: [-0.04, -0.98, 0.20] },
|
|
723
|
+
] },
|
|
724
|
+
// ⑦ 低身横扫:深蹲转体、持杆手贴地扫出、另一手撑地
|
|
725
|
+
{ id: 'low_sweep', zh: '低身横扫', en: 'Low sweep',
|
|
726
|
+
base: { Spine: [0.72, -0.52, 0], Spine1: [0.42, -0.28, 0], Hips: [0.30, -0.24, 0.08], Neck: [-0.30, 0.20, 0] },
|
|
727
|
+
rootY: -0.29,
|
|
728
|
+
dirs: [
|
|
729
|
+
{ bone: 'RightArm', to: [-0.34, -0.86, 0.36] }, { bone: 'RightForeArm', to: [-0.16, -0.96, 0.22] },
|
|
730
|
+
{ bone: 'LeftArm', to: [0.40, -0.80, 0.44] }, { bone: 'LeftForeArm', to: [0.18, -0.94, 0.28] },
|
|
731
|
+
// 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
|
|
732
|
+
{ bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
|
|
733
|
+
{ bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
|
|
734
|
+
{ bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
|
|
735
|
+
] },
|
|
736
|
+
// ⑧ 枪尖戳地:单膝下沉、持杆手直下压、上身俯冲
|
|
737
|
+
{ id: 'spear_ground', zh: '枪尖戳地', en: 'Spear to ground',
|
|
738
|
+
base: { Spine: [0.86, -0.20, 0], Spine1: [0.48, -0.12, 0], Hips: [0.34, -0.10, 0.06], Neck: [-0.48, 0.10, 0] },
|
|
739
|
+
rootY: -0.29,
|
|
740
|
+
dirs: [
|
|
741
|
+
{ bone: 'RightArm', to: [-0.16, -0.96, 0.20] }, { bone: 'RightForeArm', to: [-0.08, -0.99, 0.10] },
|
|
742
|
+
{ bone: 'LeftArm', to: [0.30, -0.72, 0.62] }, { bone: 'LeftForeArm', to: [0.14, -0.30, 0.94] },
|
|
743
|
+
// 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
|
|
744
|
+
{ bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
|
|
745
|
+
{ bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
|
|
746
|
+
{ bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
|
|
747
|
+
] },
|
|
748
|
+
// ⑨ 俯身贴地收势:单膝跪地、上身压到近地、持杆手垂落
|
|
749
|
+
{ id: 'crouch_low', zh: '俯身贴地', en: 'Crouch low',
|
|
750
|
+
base: { Spine: [0.94, 0.18, 0], Spine1: [0.52, 0.10, 0], Hips: [0.38, 0.08, 0], Neck: [-0.54, -0.12, 0] },
|
|
751
|
+
rootY: -0.29,
|
|
752
|
+
dirs: [
|
|
753
|
+
{ bone: 'RightArm', to: [-0.24, -0.92, 0.30] }, { bone: 'RightForeArm', to: [-0.10, -0.98, 0.14] },
|
|
754
|
+
{ bone: 'LeftArm', to: [0.34, -0.86, 0.38] }, { bone: 'LeftForeArm', to: [0.16, -0.96, 0.22] },
|
|
755
|
+
// 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
|
|
756
|
+
{ bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
|
|
757
|
+
{ bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
|
|
758
|
+
{ bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
|
|
759
|
+
] },
|
|
760
|
+
// ⑩ 大招起势:抬头挺胸、双臂外展上举、重心拔起
|
|
761
|
+
{ id: 'ult_charge', zh: '大招起势', en: 'Ultimate charge',
|
|
762
|
+
base: { Spine: [-0.28, 0, 0], Spine1: [-0.18, 0, 0], Hips: [-0.10, 0, 0], Neck: [-0.26, 0, 0] },
|
|
763
|
+
rootY: 0.10,
|
|
764
|
+
dirs: [
|
|
765
|
+
{ bone: 'RightArm', to: [-0.62, 0.62, 0.48] }, { bone: 'RightForeArm', to: [-0.48, 0.84, 0.26] },
|
|
766
|
+
{ bone: 'LeftArm', to: [0.62, 0.62, 0.48] }, { bone: 'LeftForeArm', to: [0.48, 0.84, 0.26] },
|
|
767
|
+
{ bone: 'LeftUpLeg', to: [0.14, -0.94, 0.30] }, { bone: 'RightUpLeg', to: [-0.14, -0.94, -0.30] },
|
|
768
|
+
{ bone: 'LeftLeg', to: [0.04, -0.99, 0.14] }, { bone: 'RightLeg', to: [-0.04, -0.99, 0.14] },
|
|
769
|
+
] },
|
|
770
|
+
// ⑪ 受击后仰:躯干后折、双臂散开、后腿撤步卸力
|
|
771
|
+
{ id: 'hit_recoil', zh: '受击后仰', en: 'Hit recoil',
|
|
772
|
+
base: { Spine: [-0.78, 0.24, 0.10], Spine1: [-0.48, 0.14, 0.06], Hips: [0.24, 0.10, -0.06], Neck: [0.40, -0.20, 0] },
|
|
773
|
+
dirs: [
|
|
774
|
+
{ bone: 'RightArm', to: [-0.66, 0.30, 0.68] }, { bone: 'RightForeArm', to: [-0.52, -0.10, 0.84] },
|
|
775
|
+
{ bone: 'LeftArm', to: [0.70, 0.24, 0.66] }, { bone: 'LeftForeArm', to: [0.56, -0.16, 0.80] },
|
|
776
|
+
{ bone: 'LeftUpLeg', to: [0.20, -0.80, 0.56] }, { bone: 'LeftLeg', to: [0.08, -0.96, 0.26] },
|
|
777
|
+
{ bone: 'RightUpLeg', to: [-0.24, -0.86, -0.44] }, { bone: 'RightLeg', to: [-0.08, -0.98, 0.14] },
|
|
778
|
+
] },
|
|
779
|
+
// ⑫ 倒地:全身放倒、四肢摊开
|
|
780
|
+
{ id: 'knockdown', zh: '倒地', en: 'Knockdown',
|
|
781
|
+
base: { Hips: [-1.52, 0.20, 0.10], Spine: [0.40, 0.10, 0.04], Spine1: [0.26, 0.06, 0], Neck: [0.22, -0.14, 0] },
|
|
782
|
+
rootY: -0.70,
|
|
783
|
+
dirs: [
|
|
784
|
+
{ bone: 'LeftUpLeg', to: [0.34, -0.10, 0.94] }, { bone: 'LeftLeg', to: [0.16, -0.44, 0.88] },
|
|
785
|
+
{ bone: 'RightUpLeg', to: [-0.30, -0.06, 0.95] }, { bone: 'RightLeg', to: [-0.14, -0.38, 0.92] },
|
|
786
|
+
{ bone: 'LeftArm', to: [0.78, 0.22, 0.58] }, { bone: 'RightArm', to: [-0.80, 0.18, 0.56] },
|
|
787
|
+
{ bone: 'LeftForeArm', to: [0.62, 0.06, 0.78] }, { bone: 'RightForeArm', to: [-0.64, 0.02, 0.76] },
|
|
788
|
+
] },
|
|
662
789
|
];
|
|
663
790
|
|
|
664
791
|
|
|
@@ -1154,13 +1281,10 @@ function sampleTrack(track, time) {
|
|
|
1154
1281
|
const t0 = track.times[i], t1 = track.times[i + 1];
|
|
1155
1282
|
const k = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
|
|
1156
1283
|
if (track.path === 'rotation') {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const out = a.map((av, j) => av + (b[j] - av) * k);
|
|
1162
|
-
const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1;
|
|
1163
|
-
return out.map((x) => x / len);
|
|
1284
|
+
const a = [track.vals[i * 4], track.vals[i * 4 + 1], track.vals[i * 4 + 2], track.vals[i * 4 + 3]];
|
|
1285
|
+
const b = [track.vals[i * 4 + 4], track.vals[i * 4 + 5], track.vals[i * 4 + 6], track.vals[i * 4 + 7]];
|
|
1286
|
+
// 球面插值:角速度恒定(原实现为 nlerp,长弧段会变速)
|
|
1287
|
+
return quatSlerp(a, b, k);
|
|
1164
1288
|
}
|
|
1165
1289
|
return [0, 1, 2].map((j) => track.vals[i * 3 + j] + (track.vals[(i + 1) * 3 + j] - track.vals[i * 3 + j]) * k);
|
|
1166
1290
|
}
|
|
@@ -1253,7 +1377,7 @@ function b64ToU16(b64) { const u8 = b64ToU8(b64); return new Uint16Array(u8.buff
|
|
|
1253
1377
|
function b64ToU32(b64) { const u8 = b64ToU8(b64); return new Uint32Array(u8.buffer, u8.byteOffset, u8.byteLength / 4); }
|
|
1254
1378
|
|
|
1255
1379
|
/** 模型包 JSON → 与 parseGlb 同形的 asset(可直接喂 boneMatrices / 渲染)。 */
|
|
1256
|
-
export function decodeModelPack(pack) {
|
|
1380
|
+
export function decodeModelPack(pack, actionClips) {
|
|
1257
1381
|
const asset = {
|
|
1258
1382
|
id: pack.id,
|
|
1259
1383
|
label: pack.label,
|
|
@@ -1272,9 +1396,32 @@ export function decodeModelPack(pack) {
|
|
|
1272
1396
|
rawHeight: pack.rawHeight,
|
|
1273
1397
|
verts: pack.verts, tris: pack.tris,
|
|
1274
1398
|
};
|
|
1399
|
+
// 动作库(内置成熟动作:three.js AnimationClip 转 tracks,base64 Float32)
|
|
1400
|
+
if (Array.isArray(actionClips)) {
|
|
1401
|
+
for (const c of actionClips) {
|
|
1402
|
+
if (!c || !c.name || !Array.isArray(c.tracks)) continue;
|
|
1403
|
+
// 只追加同名不存在的动作:内置片段(待机/行走/奔跑/潜行)保持原样,绝不被覆盖
|
|
1404
|
+
if (asset.animations.some((a) => a.name === c.name)) continue;
|
|
1405
|
+
asset.animations.push({
|
|
1406
|
+
name: c.name, duration: Number(c.duration) || 0, label: c.label || c.name, loop: !!c.loop,
|
|
1407
|
+
tracks: c.tracks.map((t) => ({ node: t.node, path: t.path, times: b64ToF32(t.times), vals: b64ToF32(t.vals) })),
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1275
1411
|
asset.clipByName = (name) => asset.animations.find((a) => a.name === name) || null;
|
|
1276
1412
|
return asset;
|
|
1277
1413
|
}
|
|
1414
|
+
/** 解析动作库文件(clips/*.json,base64 tracks)→ decodeModelPack 第二参可直接用的数组。 */
|
|
1415
|
+
export function decodeActionClips(list) {
|
|
1416
|
+
if (!Array.isArray(list)) return [];
|
|
1417
|
+
return list.map((c) => ({
|
|
1418
|
+
name: String(c.name || ''),
|
|
1419
|
+
label: c.label || c.name || '',
|
|
1420
|
+
duration: Number(c.duration) || 0,
|
|
1421
|
+
loop: !!c.loop,
|
|
1422
|
+
tracks: (c.tracks || []).map((t) => ({ node: t.node, path: t.path, times: t.times, vals: t.vals })),
|
|
1423
|
+
}));
|
|
1424
|
+
}
|
|
1278
1425
|
|
|
1279
1426
|
/**
|
|
1280
1427
|
* CPU 蒙皮:算出某一时刻的顶点/法线(写进传入的复用数组,避免每帧分配)。
|
|
@@ -1445,7 +1592,212 @@ function solveDirsIntoLocal(asset, local, dirs) {
|
|
|
1445
1592
|
}
|
|
1446
1593
|
}
|
|
1447
1594
|
|
|
1448
|
-
|
|
1595
|
+
/* ============================================================
|
|
1596
|
+
* 约束与 IK(Phase C)
|
|
1597
|
+
* 约束:damped_track(骨骼 Y 轴指向目标)/ copy_rotation / limit_rotation
|
|
1598
|
+
* IK:两骨解析解(余弦定理 + 极向量),链 = [根骨, 中间骨, 末端骨]
|
|
1599
|
+
* 求值顺序对齐 Blender:FK 姿态 → 约束 → IK(constraint.cc / ikplugin 语义;算法自行实现)
|
|
1600
|
+
* ============================================================ */
|
|
1601
|
+
/** 四元数 → 欧拉(ZYX 顺序,与 eulerToQuat 的 qz*qy*qx 对应)。 */
|
|
1602
|
+
export function quatToEuler(q) {
|
|
1603
|
+
const x = Number(q && q[0]) || 0, y = Number(q && q[1]) || 0, z = Number(q && q[2]) || 0, w = Number(q && q[3]) || 1;
|
|
1604
|
+
const m00 = 1 - 2 * (y * y + z * z), m10 = 2 * (x * y + z * w), m20 = 2 * (x * z - y * w);
|
|
1605
|
+
const m21 = 2 * (y * z + x * w), m22 = 1 - 2 * (x * x + y * y);
|
|
1606
|
+
const beta = Math.asin(Math.max(-1, Math.min(1, -m20)));
|
|
1607
|
+
if (Math.abs(m20) < 0.9999999) {
|
|
1608
|
+
return [Math.atan2(m21, m22), beta, Math.atan2(m10, m00)];
|
|
1609
|
+
}
|
|
1610
|
+
// 万向锁兜底
|
|
1611
|
+
const m01 = 2 * (x * y - z * w), m11 = 1 - 2 * (x * x + z * z);
|
|
1612
|
+
return [Math.atan2(-m01, m11), beta, 0];
|
|
1613
|
+
}
|
|
1614
|
+
/** 罗德里格斯:绕单位轴 axis 旋转 v 角 ang。 */
|
|
1615
|
+
function rotateVecAxis(v, axis, ang) {
|
|
1616
|
+
const c = Math.cos(ang), s2 = Math.sin(ang);
|
|
1617
|
+
const d = v.x * axis.x + v.y * axis.y + v.z * axis.z;
|
|
1618
|
+
const cr = vCross(axis, v);
|
|
1619
|
+
return v3(
|
|
1620
|
+
v.x * c + cr.x * s2 + axis.x * d * (1 - c),
|
|
1621
|
+
v.y * c + cr.y * s2 + axis.y * d * (1 - c),
|
|
1622
|
+
v.z * c + cr.z * s2 + axis.z * d * (1 - c),
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
/** 由 local 变换算世界矩阵表(与 boneMatricesWithPose 的 ② 同构)。 */
|
|
1626
|
+
function localWorldMatrices(asset, local) {
|
|
1627
|
+
const n = local.length;
|
|
1628
|
+
const worldM = new Array(n);
|
|
1629
|
+
const parentOf = (i) => (asset.parentOfAll ? asset.parentOfAll[i] : -1);
|
|
1630
|
+
const resolve = (i, guard) => {
|
|
1631
|
+
if (worldM[i]) return worldM[i];
|
|
1632
|
+
if (guard > 128) return directorMat4Identity();
|
|
1633
|
+
const Lp = local[i];
|
|
1634
|
+
const m = trsToMat4(Lp.t, Lp.r, Lp.s);
|
|
1635
|
+
const p = parentOf(i);
|
|
1636
|
+
worldM[i] = (p >= 0 && p !== i) ? directorMat4Multiply(resolve(p, guard + 1), m) : m;
|
|
1637
|
+
return worldM[i];
|
|
1638
|
+
};
|
|
1639
|
+
for (let i = 0; i < n; i++) resolve(i, 0);
|
|
1640
|
+
return worldM;
|
|
1641
|
+
}
|
|
1642
|
+
const m4pos = (m) => v3(m[12], m[13], m[14]);
|
|
1643
|
+
/** IK 目标点:target 支持 [x,y,z] / {x,y,z} / {keys:[{t,pos:[x,y,z]}]}。 */
|
|
1644
|
+
export function ikTargetPoint(spec, time) {
|
|
1645
|
+
const t = spec && spec.target;
|
|
1646
|
+
if (!t) return null;
|
|
1647
|
+
if (Array.isArray(t)) return v3(Number(t[0]) || 0, Number(t[1]) || 0, Number(t[2]) || 0);
|
|
1648
|
+
if (typeof t === 'object') {
|
|
1649
|
+
if (Array.isArray(t.keys) && t.keys.length) {
|
|
1650
|
+
const gx = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[0] : undefined), time);
|
|
1651
|
+
const gy = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[1] : undefined), time);
|
|
1652
|
+
const gz = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[2] : undefined), time);
|
|
1653
|
+
return v3(gx === null ? 0 : gx, gy === null ? 0 : gy, gz === null ? 0 : gz);
|
|
1654
|
+
}
|
|
1655
|
+
return v3(Number(t.x) || 0, Number(t.y) || 0, Number(t.z) || 0);
|
|
1656
|
+
}
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* 两骨解析 IK 的方向解:给定三关节世界位置与目标,返回 [根骨方向, 中间骨方向](世界单位向量)。
|
|
1661
|
+
* 复用引擎的方向求解器落地到 local 旋转(见 resolveIkAndConstraints)。
|
|
1662
|
+
*/
|
|
1663
|
+
export function solveTwoBoneDirs(j0, j1, j2, target, pole) {
|
|
1664
|
+
const L1 = vLen(vSub(j1, j0)), L2 = vLen(vSub(j2, j1));
|
|
1665
|
+
const v = vSub(target, j0);
|
|
1666
|
+
const dRaw = vLen(v) || 1e-6;
|
|
1667
|
+
const d = Math.max(Math.abs(L1 - L2) + 1e-4, Math.min(L1 + L2 - 1e-4, dRaw));
|
|
1668
|
+
const dir = vMul(v, 1 / dRaw);
|
|
1669
|
+
let bend = pole ? vSub(pole, j0) : vSub(j1, j0);
|
|
1670
|
+
let axis = vCross(dir, bend);
|
|
1671
|
+
if (vLen(axis) < 1e-6) {
|
|
1672
|
+
axis = vCross(dir, v3(0, 1, 0));
|
|
1673
|
+
if (vLen(axis) < 1e-6) axis = vCross(dir, v3(1, 0, 0));
|
|
1674
|
+
}
|
|
1675
|
+
axis = vNorm(axis);
|
|
1676
|
+
const cos1 = Math.max(-1, Math.min(1, (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d)));
|
|
1677
|
+
const a1 = Math.acos(cos1);
|
|
1678
|
+
// 绕 axis 旋转 dir 得到根骨方向;若与极向量方向相反则取负角(弯向极向量)
|
|
1679
|
+
let upperDir = rotateVecAxis(dir, axis, a1);
|
|
1680
|
+
const mid0 = vAdd(j0, vMul(upperDir, L1));
|
|
1681
|
+
if (pole && vDot(vSub(mid0, j0), vSub(pole, j0)) < 0) upperDir = rotateVecAxis(dir, axis, -a1);
|
|
1682
|
+
const mid = vAdd(j0, vMul(upperDir, L1));
|
|
1683
|
+
const endTarget = vAdd(j0, vMul(dir, d));
|
|
1684
|
+
const lowerDir = vLen(vSub(endTarget, mid)) < 1e-6 ? upperDir : vNorm(vSub(endTarget, mid));
|
|
1685
|
+
return [upperDir, lowerDir];
|
|
1686
|
+
}
|
|
1687
|
+
/** 应用约束 + IK 到 local(就地修改 local 旋转)。 */
|
|
1688
|
+
export function resolveIkAndConstraints(asset, local, opts, time) {
|
|
1689
|
+
if (!asset || !local || !opts) return;
|
|
1690
|
+
const bones = asset.bones || [];
|
|
1691
|
+
const t = Math.max(0, Number(time) || 0);
|
|
1692
|
+
const shortOf = (kw) => String(kw || '').replace(/^mixamorig:?/i, '');
|
|
1693
|
+
const parentOfNode = (i) => (asset.parentOfAll ? asset.parentOfAll[i] : -1);
|
|
1694
|
+
const childNodeOf = (bi) => {
|
|
1695
|
+
const cn = BONE_CHILD[shortOf(bones[bi].name)];
|
|
1696
|
+
if (!cn) return -1;
|
|
1697
|
+
const ci = bones.findIndex((x) => shortOf(x.name) === cn);
|
|
1698
|
+
return ci >= 0 ? bones[ci].nodeIdx : -1;
|
|
1699
|
+
};
|
|
1700
|
+
/**
|
|
1701
|
+
* 把某根骨骼转到「世界方向 targetDir」:qLocal = P⁻¹ · qWorld · P(P=父节点世界旋转),
|
|
1702
|
+
* 这样旋转轴心恒为该骨骼关节(不会像旧 dirs 路径那样绕原点转整条骨架)。
|
|
1703
|
+
*/
|
|
1704
|
+
const aimBone = (worldMIn, bi, targetDir) => {
|
|
1705
|
+
const ni = bones[bi].nodeIdx;
|
|
1706
|
+
if (!(ni >= 0) || !local[ni]) return worldMIn;
|
|
1707
|
+
const want = vNorm(v3(Number(targetDir.x) || 0, Number(targetDir.y) || 0, Number(targetDir.z) || 0));
|
|
1708
|
+
if (vLen(want) < 1e-9) return worldMIn;
|
|
1709
|
+
const childNi = childNodeOf(bi);
|
|
1710
|
+
let wm = worldMIn;
|
|
1711
|
+
// 迭代修正:父级含缩放/非正交时,一次共轭会有几度系统残差;迭代 3 次收敛到 1e-6
|
|
1712
|
+
for (let iter = 0; iter < 3; iter++) {
|
|
1713
|
+
const bw = wm[ni];
|
|
1714
|
+
const head = v3(bw[12], bw[13], bw[14]);
|
|
1715
|
+
let cur;
|
|
1716
|
+
if (childNi >= 0 && wm[childNi]) {
|
|
1717
|
+
const cw = wm[childNi];
|
|
1718
|
+
cur = vNorm(v3(cw[12] - head.x, cw[13] - head.y, cw[14] - head.z));
|
|
1719
|
+
} else {
|
|
1720
|
+
cur = vNorm(v3(bw[4], bw[5], bw[6]));
|
|
1721
|
+
}
|
|
1722
|
+
if (vLen(cur) < 1e-9 || vDot(cur, want) > 0.9999999) break;
|
|
1723
|
+
const qW = quatFromTo([cur.x, cur.y, cur.z], [want.x, want.y, want.z]);
|
|
1724
|
+
const pn = parentOfNode(ni);
|
|
1725
|
+
const P = pn >= 0 && wm[pn] ? mat4RotationQuat(wm[pn]) : [0, 0, 0, 1];
|
|
1726
|
+
const qLocal = quatMul(quatConj(P), quatMul(qW, P));
|
|
1727
|
+
local[ni].r = quatMul(qLocal, local[ni].r);
|
|
1728
|
+
wm = localWorldMatrices(asset, local);
|
|
1729
|
+
}
|
|
1730
|
+
return wm;
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
// ── ① 约束(copy / limit 先做,不需要世界矩阵;damped_track 需要世界矩阵)──
|
|
1734
|
+
let worldM = null;
|
|
1735
|
+
for (const c of Array.isArray(opts.constraints) ? opts.constraints : []) {
|
|
1736
|
+
if (!c || !c.bone) continue;
|
|
1737
|
+
const bi = matchBone(bones, c.bone);
|
|
1738
|
+
if (bi < 0) continue;
|
|
1739
|
+
const ni = bones[bi].nodeIdx;
|
|
1740
|
+
if (!(ni >= 0) || !local[ni]) continue;
|
|
1741
|
+
const type = String(c.type || '').toLowerCase();
|
|
1742
|
+
if (type === 'copy_rotation' || type === 'copyrotation') {
|
|
1743
|
+
const fromBi = matchBone(bones, c.from || c.source || '');
|
|
1744
|
+
if (fromBi < 0) continue;
|
|
1745
|
+
const fni = bones[fromBi].nodeIdx;
|
|
1746
|
+
if (!(fni >= 0) || !local[fni]) continue;
|
|
1747
|
+
const mix = Math.max(0, Math.min(1, Number(c.mix === undefined ? 1 : c.mix)));
|
|
1748
|
+
local[ni].r = mix >= 1 ? local[fni].r.slice() : quatSlerp(local[ni].r, local[fni].r, mix);
|
|
1749
|
+
} else if (type === 'limit_rotation' || type === 'limitrotation') {
|
|
1750
|
+
const e = quatToEuler(local[ni].r);
|
|
1751
|
+
const mn = Array.isArray(c.min) ? c.min : [-Math.PI, -Math.PI, -Math.PI];
|
|
1752
|
+
const mx = Array.isArray(c.max) ? c.max : [Math.PI, Math.PI, Math.PI];
|
|
1753
|
+
for (let k = 0; k < 3; k++) {
|
|
1754
|
+
const lo = Number(mn[k]); const hi = Number(mx[k]);
|
|
1755
|
+
if (Number.isFinite(lo)) e[k] = Math.max(lo, e[k]);
|
|
1756
|
+
if (Number.isFinite(hi)) e[k] = Math.min(hi, e[k]);
|
|
1757
|
+
}
|
|
1758
|
+
local[ni].r = eulerToQuat(e[0], e[1], e[2]);
|
|
1759
|
+
} else if (type === 'damped_track' || type === 'dampedtrack' || type === 'track') {
|
|
1760
|
+
const tp = ikTargetPoint(c, t);
|
|
1761
|
+
if (!tp) continue;
|
|
1762
|
+
worldM = worldM || localWorldMatrices(asset, local);
|
|
1763
|
+
const head = v3(worldM[ni][12], worldM[ni][13], worldM[ni][14]);
|
|
1764
|
+
const dir = vSub(tp, head);
|
|
1765
|
+
if (vLen(dir) < 1e-6) continue;
|
|
1766
|
+
worldM = aimBone(worldM, bi, vNorm(dir));
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// ── ② IK:两骨解析(链 = [根骨, 中间骨, 末端骨])──
|
|
1771
|
+
const chains = Array.isArray(opts.ik) ? opts.ik : [];
|
|
1772
|
+
for (const spec of chains) {
|
|
1773
|
+
const names = (spec && Array.isArray(spec.chain) ? spec.chain : []).map(String);
|
|
1774
|
+
if (names.length < 3) continue;
|
|
1775
|
+
const bis = names.map((n) => matchBone(bones, n));
|
|
1776
|
+
if (bis.some((x) => x < 0)) continue;
|
|
1777
|
+
const nodes = bis.map((bi) => bones[bi].nodeIdx);
|
|
1778
|
+
if (nodes.some((n2) => !(n2 >= 0))) continue;
|
|
1779
|
+
const target = ikTargetPoint(spec, t);
|
|
1780
|
+
if (!target) continue;
|
|
1781
|
+
worldM = worldM || localWorldMatrices(asset, local);
|
|
1782
|
+
const j0 = v3(worldM[nodes[0]][12], worldM[nodes[0]][13], worldM[nodes[0]][14]);
|
|
1783
|
+
const j1 = v3(worldM[nodes[1]][12], worldM[nodes[1]][13], worldM[nodes[1]][14]);
|
|
1784
|
+
const j2 = v3(worldM[nodes[2]][12], worldM[nodes[2]][13], worldM[nodes[2]][14]);
|
|
1785
|
+
const pole = spec.pole
|
|
1786
|
+
? (Array.isArray(spec.pole) ? v3(Number(spec.pole[0]) || 0, Number(spec.pole[1]) || 0, Number(spec.pole[2]) || 0) : ikTargetPoint({ target: spec.pole }, t))
|
|
1787
|
+
: null;
|
|
1788
|
+
const solved = solveTwoBoneDirs(j0, j1, j2, target, pole || null);
|
|
1789
|
+
worldM = aimBone(worldM, bis[0], solved[0]);
|
|
1790
|
+
worldM = aimBone(worldM, bis[1], solved[1]);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* 仅应用姿态/约束/IK 到 local(不计算世界矩阵/蒙皮矩阵)。
|
|
1796
|
+
* 测试与工具用它拿「节点空间」结果;预览/导出走的 boneMatricesWithPose 内部同样调用它。
|
|
1797
|
+
*/
|
|
1798
|
+
export function applyBonePoseToLocal(asset, time, poseMap, dirs, baseEuler, rootY, opts) {
|
|
1799
|
+
if (!asset || !asset.bones || !asset.nodes) return null;
|
|
1800
|
+
|
|
1449
1801
|
if (!asset || !asset.bones || !asset.nodes) return null;
|
|
1450
1802
|
const anim = asset._anim;
|
|
1451
1803
|
const t = animLoopTime(asset, time);
|
|
@@ -1488,11 +1840,193 @@ export function boneMatricesWithPose(asset, time, poseMap, dirs, baseEuler, root
|
|
|
1488
1840
|
const hi = (asset.bones || []).findIndex((x) => String(x.name || "").replace(/^mixamorig:?/i, "") === "Hips");
|
|
1489
1841
|
if (hi >= 0) {
|
|
1490
1842
|
const ni3 = asset.bones[hi].nodeIdx;
|
|
1491
|
-
if (ni3 !== undefined && local[ni3] && local[ni3].t)
|
|
1843
|
+
if (ni3 !== undefined && local[ni3] && local[ni3].t) {
|
|
1844
|
+
// rootY 以「米」为单位,而骨骼节点位移是模型本地位(xbot 为厘米级 ~100×)
|
|
1845
|
+
const kRoot = 100;
|
|
1846
|
+
local[ni3].t[1] += rootY * kRoot;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// ①b2 动作层(BVH 重定向后的 poseKeys / NLA-lite):以姿态增量叠加到当前姿势
|
|
1851
|
+
if (opts && Array.isArray(opts.clips) && opts.clips.length) {
|
|
1852
|
+
const cpm = sampleClips(asset, opts.clips, time || 0);
|
|
1853
|
+
for (const [kk, rot] of Object.entries(cpm || {})) {
|
|
1854
|
+
const bi2 = Number(kk);
|
|
1855
|
+
const bone2 = asset.bones[bi2];
|
|
1856
|
+
if (!bone2) continue;
|
|
1857
|
+
const ni2 = bone2.nodeIdx;
|
|
1858
|
+
if (!(ni2 >= 0) || !local[ni2]) continue;
|
|
1859
|
+
const q2 = rot.length === 4 ? rot : eulerToQuat(Number(rot[0]) || 0, Number(rot[1]) || 0, Number(rot[2]) || 0);
|
|
1860
|
+
local[ni2].r = quatMul(local[ni2].r, q2);
|
|
1492
1861
|
}
|
|
1493
1862
|
}
|
|
1494
1863
|
// ①c 方向约束(基于「动画 + 基底」后的当前姿势求解,因此永远不会被动画带偏)
|
|
1495
1864
|
if (dirs && dirs.length) solveDirsIntoLocal(asset, local, dirs);
|
|
1865
|
+
// ①d 约束 + IK(FK 之后;对齐 Blender 求值顺序:姿势 → 约束 → IK)
|
|
1866
|
+
if (opts && (Array.isArray(opts.ik) || Array.isArray(opts.constraints))) resolveIkAndConstraints(asset, local, opts, time || 0);
|
|
1867
|
+
|
|
1868
|
+
return local;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** 节点空间世界矩阵(与 boneMatrices 同一套 TRS 组合;供 IK/测试/工具使用)。 */
|
|
1872
|
+
export { localWorldMatrices };
|
|
1873
|
+
|
|
1874
|
+
/* ============================================================
|
|
1875
|
+
* 动捕数据接入(Phase D):BVH 解析 → rest-delta 重定向 → 动作层混合
|
|
1876
|
+
* 参考 Blender 的骨骼 rest/pose 矩阵语义(arm_mat / pose_mat)与 NLA strip
|
|
1877
|
+
* 混合(replace/add/multiply + influence);算法自行实现(GPL 兼容性)。
|
|
1878
|
+
* ============================================================ */
|
|
1879
|
+
const normBone = (n) => String(n || '').toLowerCase().replace(/^mixamorig:?/i, '').replace(/[^a-z0-9]/g, '');
|
|
1880
|
+
/** 解析 BVH 文本 → { joints:[{name,parent,offset,channels,end}], frames:number[][], frameTime }。 */
|
|
1881
|
+
export function parseBVH(text) {
|
|
1882
|
+
const src = String(text || '');
|
|
1883
|
+
const lines = src.split(/\r?\n/);
|
|
1884
|
+
const joints = [];
|
|
1885
|
+
const stack = [];
|
|
1886
|
+
let frameTime = 1 / 30;
|
|
1887
|
+
const frames = [];
|
|
1888
|
+
let mode = 'hierarchy';
|
|
1889
|
+
let pendingFrames = 0;
|
|
1890
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1891
|
+
const line = lines[li].trim();
|
|
1892
|
+
if (!line) continue;
|
|
1893
|
+
if (/^MOTION$/i.test(line)) { mode = 'motion'; continue; }
|
|
1894
|
+
if (mode === 'motion') {
|
|
1895
|
+
const fm = /^Frames:\s*(\d+)/i.exec(line);
|
|
1896
|
+
if (fm) { pendingFrames = Number(fm[1]); continue; }
|
|
1897
|
+
const tm = /^Frame Time:\s*([\d.eE+-]+)/i.exec(line);
|
|
1898
|
+
if (tm) { frameTime = Number(tm[1]) || frameTime; continue; }
|
|
1899
|
+
const vals = line.split(/[\s,]+/).filter((x) => x !== '').map(Number).filter((x) => Number.isFinite(x));
|
|
1900
|
+
if (vals.length) frames.push(vals);
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
const head = /^(ROOT|JOINT)\s+(\S+)/i.exec(line);
|
|
1904
|
+
if (head) {
|
|
1905
|
+
const parent = stack.length ? stack[stack.length - 1] : -1;
|
|
1906
|
+
const idx = joints.length;
|
|
1907
|
+
joints.push({ name: head[2], parent, offset: [0, 0, 0], channels: [], end: false });
|
|
1908
|
+
stack.push(idx);
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
if (/^End Site/i.test(line)) {
|
|
1912
|
+
const parent = stack.length ? stack[stack.length - 1] : -1;
|
|
1913
|
+
joints.push({ name: 'End_' + joints.length, parent, offset: [0, 0, 0], channels: [], end: true });
|
|
1914
|
+
stack.push(joints.length - 1);
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
if (/^OFFSET/i.test(line)) {
|
|
1918
|
+
const p = line.split(/\s+/);
|
|
1919
|
+
const cur = joints[stack[stack.length - 1]];
|
|
1920
|
+
if (cur) cur.offset = [Number(p[1]) || 0, Number(p[2]) || 0, Number(p[3]) || 0];
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
if (/^CHANNELS/i.test(line)) {
|
|
1924
|
+
const p = line.split(/\s+/);
|
|
1925
|
+
const cur = joints[stack[stack.length - 1]];
|
|
1926
|
+
if (cur) cur.channels = p.slice(2);
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
if (line === '}') { stack.pop(); continue; }
|
|
1930
|
+
}
|
|
1931
|
+
const totalFrames = pendingFrames || frames.length;
|
|
1932
|
+
return { joints, frames: frames.slice(0, totalFrames || frames.length), frameTime };
|
|
1933
|
+
}
|
|
1934
|
+
/** BVH 关节名 → 资产骨骼索引(按规范化名精确匹配;找不到跳过)。 */
|
|
1935
|
+
export function mapBVHToBones(asset, bvh) {
|
|
1936
|
+
const bones = (asset && asset.bones) || [];
|
|
1937
|
+
const byName = new Map();
|
|
1938
|
+
bones.forEach((b, bi) => byName.set(normBone(b.name), bi));
|
|
1939
|
+
const map = [];
|
|
1940
|
+
for (const j of (bvh && bvh.joints) || []) map.push(byName.has(normBone(j.name)) ? byName.get(normBone(j.name)) : -1);
|
|
1941
|
+
return map;
|
|
1942
|
+
}
|
|
1943
|
+
/** 单帧关节局部旋转(按通道顺序,第一通道最外层)。 */
|
|
1944
|
+
export function bvhJointLocalQuat(joint, values, offset) {
|
|
1945
|
+
let q = [0, 0, 0, 1];
|
|
1946
|
+
let k = offset;
|
|
1947
|
+
for (const ch of (joint && joint.channels) || []) {
|
|
1948
|
+
const rad = (Number(values[k]) || 0) * Math.PI / 180;
|
|
1949
|
+
let qi;
|
|
1950
|
+
if (/^Xrotation$/i.test(ch)) qi = [Math.sin(rad / 2), 0, 0, Math.cos(rad / 2)];
|
|
1951
|
+
else if (/^Yrotation$/i.test(ch)) qi = [0, Math.sin(rad / 2), 0, Math.cos(rad / 2)];
|
|
1952
|
+
else if (/^Zrotation$/i.test(ch)) qi = [0, 0, Math.sin(rad / 2), Math.cos(rad / 2)];
|
|
1953
|
+
else { k++; continue; }
|
|
1954
|
+
q = quatMul(q, qi);
|
|
1955
|
+
k++;
|
|
1956
|
+
}
|
|
1957
|
+
return q;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* BVH → 目标骨架的 poseKeys(rest-delta 重定向):
|
|
1961
|
+
* q_target = q_rest_local · q_src_local(xbot 的 rest 局部旋转基本为单位四元数,故近似为局部旋转叠加)
|
|
1962
|
+
* 返回 { poseKeys:[{t,bonePose, ease}], duration, frameTime }。
|
|
1963
|
+
*/
|
|
1964
|
+
export function retargetBVH(asset, bvh, opts) {
|
|
1965
|
+
const o = opts || {};
|
|
1966
|
+
if (!asset || !bvh || !Array.isArray(bvh.frames) || !bvh.frames.length) return { poseKeys: [], duration: 0, frameTime: bvh ? bvh.frameTime : 0 };
|
|
1967
|
+
const map = mapBVHToBones(asset, bvh);
|
|
1968
|
+
const channelsBefore = [];
|
|
1969
|
+
{
|
|
1970
|
+
let acc = 0;
|
|
1971
|
+
for (const j of bvh.joints) { channelsBefore.push(acc); acc += (j.channels || []).length; }
|
|
1972
|
+
}
|
|
1973
|
+
const poseKeys = [];
|
|
1974
|
+
const frameStep = o.sampleStep ? Math.max(1, Math.round(o.sampleStep)) : 1;
|
|
1975
|
+
for (let f = 0; f < bvh.frames.length; f += frameStep) {
|
|
1976
|
+
const values = bvh.frames[f];
|
|
1977
|
+
const bonePose = {};
|
|
1978
|
+
let any = false;
|
|
1979
|
+
for (let ji = 0; ji < bvh.joints.length; ji++) {
|
|
1980
|
+
const bi = map[ji];
|
|
1981
|
+
if (bi < 0) continue;
|
|
1982
|
+
const qSrc = bvhJointLocalQuat(bvh.joints[ji], values, channelsBefore[ji]);
|
|
1983
|
+
const bone = asset.bones[bi];
|
|
1984
|
+
const qRest = bone && bone.r && bone.r.length === 4 ? bone.r : [0, 0, 0, 1];
|
|
1985
|
+
bonePose[bi] = quatMul([Number(qRest[0]) || 0, Number(qRest[1]) || 0, Number(qRest[2]) || 0, Number(qRest[3]) || 1], qSrc);
|
|
1986
|
+
any = true;
|
|
1987
|
+
}
|
|
1988
|
+
if (any) poseKeys.push({ t: f * (Number(bvh.frameTime) || 1 / 30), bonePose });
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
poseKeys,
|
|
1992
|
+
duration: bvh.frames.length * (Number(bvh.frameTime) || 1 / 30),
|
|
1993
|
+
frameTime: Number(bvh.frameTime) || 1 / 30,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* 动作层混合(NLA-lite):clip = { keys:[{t,bonePose}], t0, t1, loop, blend:'replace'|'add'|'multiply', influence }
|
|
1998
|
+
* 返回 poseMap(boneIndex → 四元数),供 skinModel 以「姿态增量」叠加。
|
|
1999
|
+
*/
|
|
2000
|
+
export function sampleClips(asset, clips, time) {
|
|
2001
|
+
const out = {};
|
|
2002
|
+
const t = Math.max(0, Number(time) || 0);
|
|
2003
|
+
for (const clip of Array.isArray(clips) ? clips : []) {
|
|
2004
|
+
const keys = Array.isArray(clip && clip.keys) ? clip.keys : [];
|
|
2005
|
+
if (!keys.length) continue;
|
|
2006
|
+
const t0 = Number(clip.t0) || 0;
|
|
2007
|
+
const lastT = Number(keys[keys.length - 1].t) || 0;
|
|
2008
|
+
const t1 = Number(clip.t1) || lastT;
|
|
2009
|
+
const dur = Math.max(1e-6, t1 - t0);
|
|
2010
|
+
let tl = t - t0;
|
|
2011
|
+
if (clip.loop) tl = ((tl % dur) + dur) % dur;
|
|
2012
|
+
tl = Math.max(0, Math.min(dur, tl));
|
|
2013
|
+
const pm = interpolatePoseKeys(keys, tl);
|
|
2014
|
+
if (!pm) continue;
|
|
2015
|
+
const influence = Math.max(0, Math.min(1, Number(clip.influence === undefined ? 1 : clip.influence)));
|
|
2016
|
+
const mode = String(clip.blend || 'replace').toLowerCase();
|
|
2017
|
+
for (const [k, rot] of Object.entries(pm)) {
|
|
2018
|
+
const q = rot.length === 4 ? rot.slice() : eulerToQuat(Number(rot[0]) || 0, Number(rot[1]) || 0, Number(rot[2]) || 0);
|
|
2019
|
+
const base = out[k] || [0, 0, 0, 1];
|
|
2020
|
+
if (mode === 'replace') out[k] = quatSlerp(base, q, influence);
|
|
2021
|
+
else out[k] = quatMul(base, quatSlerp([0, 0, 0, 1], q, influence));
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return out;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
export function boneMatricesWithPose(asset, time, poseMap, dirs, baseEuler, rootY, opts) {
|
|
2028
|
+
const local = applyBonePoseToLocal(asset, time, poseMap, dirs, baseEuler, rootY, opts);
|
|
2029
|
+
if (!local) return null;
|
|
1496
2030
|
// ② 自顶向下世界矩阵
|
|
1497
2031
|
const n = local.length;
|
|
1498
2032
|
const worldM = new Array(n);
|
|
@@ -1546,8 +2080,8 @@ export function poseBlendAt(posedefs, poseAnim, time) {
|
|
|
1546
2080
|
for (let i = 0; i < ks.length - 1; i++) if (t >= ks[i].t && t <= ks[i + 1].t) { a = ks[i]; b = ks[i + 1]; break; }
|
|
1547
2081
|
const span = b.t - a.t;
|
|
1548
2082
|
const k = span > 1e-6 ? (t - a.t) / span : 0;
|
|
1549
|
-
// smoothstep
|
|
1550
|
-
const e =
|
|
2083
|
+
// 缓动:默认 smoothstep(起止更自然);可按帧用 ease 覆盖
|
|
2084
|
+
const e = easeFactor(b.ease || a.ease || 'auto', k);
|
|
1551
2085
|
const sa = find(a.poseId), sb = find(b.poseId);
|
|
1552
2086
|
const out = [];
|
|
1553
2087
|
if (sa && e < 0.999) out.push({ base: sa.base || null, dirs: sa.dirs || [], rootY: Number(sa.rootY) || 0, w: 1 - e });
|
|
@@ -1555,8 +2089,8 @@ export function poseBlendAt(posedefs, poseAnim, time) {
|
|
|
1555
2089
|
return out.length ? out : null;
|
|
1556
2090
|
}
|
|
1557
2091
|
|
|
1558
|
-
/** 四元数球面插值(a→b,t∈[0,1]
|
|
1559
|
-
function quatSlerp(a, b, t) {
|
|
2092
|
+
/** 四元数球面插值(a→b,t∈[0,1],最短弧) */
|
|
2093
|
+
export function quatSlerp(a, b, t) {
|
|
1560
2094
|
let ax = a[0], ay = a[1], az = a[2], aw = a[3];
|
|
1561
2095
|
let bx = b[0], by = b[1], bz = b[2], bw = b[3];
|
|
1562
2096
|
let d = ax * bx + ay * by + az * bz + aw * bw;
|
|
@@ -1659,6 +2193,86 @@ export function boneMatricesWithPoseMix(asset, time, list) {
|
|
|
1659
2193
|
return { mats: out };
|
|
1660
2194
|
}
|
|
1661
2195
|
|
|
2196
|
+
/* ============================================================
|
|
2197
|
+
* 双四元数蒙皮(DQS):解决线性混合蒙皮(LBS)在大角度关节处的塌陷/糖纸
|
|
2198
|
+
* 算法对照 Blender 源码(只借鉴算法,不复制 GPL 代码;本插件 Apache-2.0):
|
|
2199
|
+
* armature_update.cc#mat4_to_dquat / armature_deform.cc 的 deform_dual_quat 混合
|
|
2200
|
+
* ============================================================ */
|
|
2201
|
+
function quatConj(q) { return [-q[0], -q[1], -q[2], q[3]]; }
|
|
2202
|
+
function quatRotateVec(q, v) {
|
|
2203
|
+
const t = quatMul(quatMul(q, [v[0], v[1], v[2], 0]), quatConj(q));
|
|
2204
|
+
return [t[0], t[1], t[2]];
|
|
2205
|
+
}
|
|
2206
|
+
/** 列主序 3x3(取自 mat4 的旋转列)→ 单位四元数 [x,y,z,w]。 */
|
|
2207
|
+
export function mat4RotationQuat(m) {
|
|
2208
|
+
const m00 = m[0], m01 = m[4], m02 = m[8];
|
|
2209
|
+
const m10 = m[1], m11 = m[5], m12 = m[9];
|
|
2210
|
+
const m20 = m[2], m21 = m[6], m22 = m[10];
|
|
2211
|
+
const tr = m00 + m11 + m22;
|
|
2212
|
+
let x, y, z, w;
|
|
2213
|
+
if (tr > 0) {
|
|
2214
|
+
const q = Math.sqrt(tr + 1) * 2;
|
|
2215
|
+
w = 0.25 * q; x = (m21 - m12) / q; y = (m02 - m20) / q; z = (m10 - m01) / q;
|
|
2216
|
+
} else if (m00 > m11 && m00 > m22) {
|
|
2217
|
+
const q = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
|
2218
|
+
w = (m21 - m12) / q; x = 0.25 * q; y = (m01 + m10) / q; z = (m02 + m20) / q;
|
|
2219
|
+
} else if (m11 > m22) {
|
|
2220
|
+
const q = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
|
2221
|
+
w = (m02 - m20) / q; x = (m01 + m10) / q; y = 0.25 * q; z = (m12 + m21) / q;
|
|
2222
|
+
} else {
|
|
2223
|
+
const q = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
|
2224
|
+
w = (m10 - m01) / q; x = (m02 + m20) / q; y = (m12 + m21) / q; z = 0.25 * q;
|
|
2225
|
+
}
|
|
2226
|
+
const l = Math.hypot(x, y, z, w) || 1;
|
|
2227
|
+
return [x / l, y / l, z / l, w / l];
|
|
2228
|
+
}
|
|
2229
|
+
/** mat4 → 单位双四元数 { r:[x,y,z,w], d:[x,y,z,w] },d = 0.5·t·r。 */
|
|
2230
|
+
export function mat4ToDualQuat(m) {
|
|
2231
|
+
const r = mat4RotationQuat(m);
|
|
2232
|
+
const t = [Number(m[12]) || 0, Number(m[13]) || 0, Number(m[14]) || 0, 0];
|
|
2233
|
+
const d = quatMul(t, r).map((v) => 0.5 * v);
|
|
2234
|
+
return { r, d };
|
|
2235
|
+
}
|
|
2236
|
+
/** 双四元数作用到点:p' = R·p·R* + 2·(D·R*).xyz。 */
|
|
2237
|
+
export function dqTransformPoint(R, D, p) {
|
|
2238
|
+
const rot = quatRotateVec(R, [p[0], p[1], p[2]]);
|
|
2239
|
+
const dr = quatMul(D, quatConj(R));
|
|
2240
|
+
return [rot[0] + 2 * dr[0], rot[1] + 2 * dr[1], rot[2] + 2 * dr[2]];
|
|
2241
|
+
}
|
|
2242
|
+
/** 单点蒙皮(测试/工具用):mode='lbs'|'dqs'。 */
|
|
2243
|
+
export function skinPointWithMats(mats, joints, weights, p, nb, mode) {
|
|
2244
|
+
const dqs = String(mode || 'lbs').toLowerCase() === 'dqs';
|
|
2245
|
+
if (!dqs) {
|
|
2246
|
+
let x = 0, y = 0, z = 0;
|
|
2247
|
+
for (let k = 0; k < 4; k++) {
|
|
2248
|
+
const w = Number(weights[k]) || 0, j = Number(joints[k]);
|
|
2249
|
+
if (!w || !(j >= 0) || j >= nb) continue;
|
|
2250
|
+
const o = j * 16;
|
|
2251
|
+
x += w * (mats[o] * p[0] + mats[o + 4] * p[1] + mats[o + 8] * p[2] + mats[o + 12]);
|
|
2252
|
+
y += w * (mats[o + 1] * p[0] + mats[o + 5] * p[1] + mats[o + 9] * p[2] + mats[o + 13]);
|
|
2253
|
+
z += w * (mats[o + 2] * p[0] + mats[o + 6] * p[1] + mats[o + 10] * p[2] + mats[o + 14]);
|
|
2254
|
+
}
|
|
2255
|
+
return [x, y, z];
|
|
2256
|
+
}
|
|
2257
|
+
let R = [0, 0, 0, 0], D = [0, 0, 0, 0], ref = null, used = 0;
|
|
2258
|
+
for (let k = 0; k < 4; k++) {
|
|
2259
|
+
const w = Number(weights[k]) || 0, j = Number(joints[k]);
|
|
2260
|
+
if (!w || !(j >= 0) || j >= nb) continue;
|
|
2261
|
+
const dq = mat4ToDualQuat(mats.subarray ? mats.subarray(j * 16, j * 16 + 16) : mats.slice(j * 16, j * 16 + 16));
|
|
2262
|
+
let r = dq.r, d = dq.d;
|
|
2263
|
+
if (!ref) ref = r;
|
|
2264
|
+
else if (r[0] * ref[0] + r[1] * ref[1] + r[2] * ref[2] + r[3] * ref[3] < 0) {
|
|
2265
|
+
r = r.map((v) => -v); d = d.map((v) => -v);
|
|
2266
|
+
}
|
|
2267
|
+
for (let q = 0; q < 4; q++) { R[q] += w * r[q]; D[q] += w * d[q]; }
|
|
2268
|
+
used++;
|
|
2269
|
+
}
|
|
2270
|
+
if (!used) return [p[0], p[1], p[2]];
|
|
2271
|
+
const rl = Math.hypot(R[0], R[1], R[2], R[3]) || 1;
|
|
2272
|
+
R = R.map((v) => v / rl); D = D.map((v) => v / rl);
|
|
2273
|
+
return dqTransformPoint(R, D, p);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1662
2276
|
export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
|
|
1663
2277
|
const clip = clipName ? asset.clipByName(clipName) : null;
|
|
1664
2278
|
asset._anim = clip;
|
|
@@ -1669,15 +2283,24 @@ export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
|
|
|
1669
2283
|
const poseBase = spec ? (spec.base || null) : null;
|
|
1670
2284
|
const poseRootY = spec ? (Number(spec.rootY) || 0) : (Number(shape && shape.rootY) || 0);
|
|
1671
2285
|
const poseBlend = shape && shape.poseBlend && shape.poseBlend.length ? shape.poseBlend : null;
|
|
1672
|
-
const wantPose = !!(poseMap || (poseDirs && poseDirs.length) || poseBase || poseBlend);
|
|
2286
|
+
const wantPose = !!(poseMap || (poseDirs && poseDirs.length) || poseBase || poseBlend || (shape && (shape.ik || shape.constraints || shape.clips)));
|
|
1673
2287
|
const bm = asset.bones && asset.bones.length
|
|
1674
2288
|
? (poseBlend ? boneMatricesWithPoseMix(asset, time || 0, poseBlend)
|
|
1675
|
-
: (wantPose ? boneMatricesWithPose(asset, time || 0, poseMap, poseDirs, poseBase, poseRootY) :
|
|
2289
|
+
: (wantPose ? boneMatricesWithPose(asset, time || 0, poseMap, poseDirs, poseBase, poseRootY, shape && (shape.ik || shape.constraints || shape.clips) ? { ik: shape.ik, constraints: shape.constraints, clips: shape.clips } : null)
|
|
2290
|
+
: boneMatrices(asset, time || 0)))
|
|
1676
2291
|
: null;
|
|
1677
2292
|
const P = asset.positions, N = asset.normals, J = asset.joints, W = asset.weights, nb = asset.bones ? asset.bones.length : 0;
|
|
1678
2293
|
const pos = outPos && outPos.length === P.length ? outPos : new Float32Array(P.length);
|
|
1679
2294
|
const nrm = outNrm && outNrm.length === P.length ? outNrm : new Float32Array(P.length);
|
|
1680
2295
|
const nv = P.length / 3;
|
|
2296
|
+
const modeRaw = String((shape && shape.skinning) || 'lbs').toLowerCase();
|
|
2297
|
+
const useDQS = modeRaw === 'dqs' || modeRaw === 'auto';
|
|
2298
|
+
// 每帧一次:把骨骼蒙皮矩阵转成双四元数(不是每顶点)
|
|
2299
|
+
let dqsBones = null;
|
|
2300
|
+
if (useDQS && bm && bm.mats && nb > 0) {
|
|
2301
|
+
dqsBones = new Array(nb);
|
|
2302
|
+
for (let j = 0; j < nb; j++) dqsBones[j] = mat4ToDualQuat(bm.mats.subarray(j * 16, j * 16 + 16));
|
|
2303
|
+
}
|
|
1681
2304
|
for (let v = 0; v < nv; v++) {
|
|
1682
2305
|
let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0;
|
|
1683
2306
|
if (!bm || !J || !W) {
|
|
@@ -1685,6 +2308,43 @@ export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
|
|
|
1685
2308
|
nrm[v * 3] = N[v * 3]; nrm[v * 3 + 1] = N[v * 3 + 1]; nrm[v * 3 + 2] = N[v * 3 + 2];
|
|
1686
2309
|
continue;
|
|
1687
2310
|
}
|
|
2311
|
+
if (dqsBones) {
|
|
2312
|
+
// 双四元数线性混合(DLB):半球对齐后加权求和,再归一化
|
|
2313
|
+
let R = [0, 0, 0, 0], D = [0, 0, 0, 0], ref = null, used = 0;
|
|
2314
|
+
for (let k = 0; k < 4; k++) {
|
|
2315
|
+
const w = W[v * 4 + k];
|
|
2316
|
+
const j = J[v * 4 + k];
|
|
2317
|
+
if (!w || j >= nb) continue;
|
|
2318
|
+
const dq = dqsBones[j];
|
|
2319
|
+
let rr = dq.r, dd = dq.d;
|
|
2320
|
+
if (!ref) ref = rr;
|
|
2321
|
+
else if (rr[0] * ref[0] + rr[1] * ref[1] + rr[2] * ref[2] + rr[3] * ref[3] < 0) {
|
|
2322
|
+
rr = [-rr[0], -rr[1], -rr[2], -rr[3]];
|
|
2323
|
+
dd = [-dd[0], -dd[1], -dd[2], -dd[3]];
|
|
2324
|
+
}
|
|
2325
|
+
R[0] += w * rr[0]; R[1] += w * rr[1]; R[2] += w * rr[2]; R[3] += w * rr[3];
|
|
2326
|
+
D[0] += w * dd[0]; D[1] += w * dd[1]; D[2] += w * dd[2]; D[3] += w * dd[3];
|
|
2327
|
+
used++;
|
|
2328
|
+
}
|
|
2329
|
+
if (!used) {
|
|
2330
|
+
pos[v * 3] = P[v * 3]; pos[v * 3 + 1] = P[v * 3 + 1]; pos[v * 3 + 2] = P[v * 3 + 2];
|
|
2331
|
+
nrm[v * 3] = N[v * 3]; nrm[v * 3 + 1] = N[v * 3 + 1]; nrm[v * 3 + 2] = N[v * 3 + 2];
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
const rl = Math.hypot(R[0], R[1], R[2], R[3]) || 1;
|
|
2335
|
+
R = [R[0] / rl, R[1] / rl, R[2] / rl, R[3] / rl];
|
|
2336
|
+
D = [D[0] / rl, D[1] / rl, D[2] / rl, D[3] / rl];
|
|
2337
|
+
// 与 skinPointWithMats 完全同源:p' = R·p·R* + 2·(D·R*).xyz
|
|
2338
|
+
const p2 = quatRotateVec(R, [P[v * 3], P[v * 3 + 1], P[v * 3 + 2]]);
|
|
2339
|
+
const dr = quatMul(D, quatConj(R));
|
|
2340
|
+
pos[v * 3] = p2[0] + 2 * dr[0];
|
|
2341
|
+
pos[v * 3 + 1] = p2[1] + 2 * dr[1];
|
|
2342
|
+
pos[v * 3 + 2] = p2[2] + 2 * dr[2];
|
|
2343
|
+
const n2 = quatRotateVec(R, [N[v * 3], N[v * 3 + 1], N[v * 3 + 2]]);
|
|
2344
|
+
const nl = Math.hypot(n2[0], n2[1], n2[2]) || 1;
|
|
2345
|
+
nrm[v * 3] = n2[0] / nl; nrm[v * 3 + 1] = n2[1] / nl; nrm[v * 3 + 2] = n2[2] / nl;
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
1688
2348
|
for (let k = 0; k < 4; k++) {
|
|
1689
2349
|
const w = W[v * 4 + k];
|
|
1690
2350
|
const j = J[v * 4 + k];
|
|
@@ -1843,6 +2503,120 @@ export function removeShot(scene, index) {
|
|
|
1843
2503
|
* ============================================================ */
|
|
1844
2504
|
|
|
1845
2505
|
/** 姿态关键帧插值:返回该时刻的 bonePose(无关键帧返回 null)。 */
|
|
2506
|
+
/* ============================================================
|
|
2507
|
+
* 动画曲线核心(四元数 / 缓动表 / 三次贝塞尔 / 通道求值)
|
|
2508
|
+
* 算法对照 Blender 源码(只借鉴算法,不复制 GPL 代码;本插件 Apache-2.0):
|
|
2509
|
+
* - blenkernel/intern/fcurve.cc#fcurve_eval_keyframes_interpolate(BEZT_IPO_BEZ 三次贝塞尔)
|
|
2510
|
+
* - animrig/intern/fcurve.cc 的 auto handle 语义(HD_AUTO / HD_AUTO_ANIM)
|
|
2511
|
+
* - blenlib 的四元数插值语义(最短弧、归一化)
|
|
2512
|
+
* ============================================================ */
|
|
2513
|
+
export function quatNormalize(q) {
|
|
2514
|
+
const l = Math.hypot(Number(q[0]) || 0, Number(q[1]) || 0, Number(q[2]) || 0, Number(q[3]) || 0) || 1;
|
|
2515
|
+
return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
|
|
2516
|
+
}
|
|
2517
|
+
/** 缓动表(in 方向;out/inout 由 easeFactor 组合)。 */
|
|
2518
|
+
const EASE_IN_FN = {
|
|
2519
|
+
sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
2520
|
+
quad: (t) => t * t,
|
|
2521
|
+
cubic: (t) => t * t * t,
|
|
2522
|
+
quart: (t) => t * t * t * t,
|
|
2523
|
+
quint: (t) => t * t * t * t * t,
|
|
2524
|
+
expo: (t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
2525
|
+
circ: (t) => 1 - Math.sqrt(Math.max(0, 1 - t * t)),
|
|
2526
|
+
back: (t) => { const c = 1.70158; return t * t * ((c + 1) * t - c); },
|
|
2527
|
+
elastic: (t) => {
|
|
2528
|
+
if (t <= 0) return 0;
|
|
2529
|
+
if (t >= 1) return 1;
|
|
2530
|
+
return -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3));
|
|
2531
|
+
},
|
|
2532
|
+
bounce: (t) => {
|
|
2533
|
+
if (t < 1 / 2.75) return 7.5625 * t * t;
|
|
2534
|
+
if (t < 2 / 2.75) { const u = t - 1.5 / 2.75; return 7.5625 * u * u + 0.75; }
|
|
2535
|
+
if (t < 2.5 / 2.75) { const u = t - 2.25 / 2.75; return 7.5625 * u * u + 0.9375; }
|
|
2536
|
+
const u = t - 2.625 / 2.75;
|
|
2537
|
+
return 7.5625 * u * u + 0.984375;
|
|
2538
|
+
},
|
|
2539
|
+
};
|
|
2540
|
+
/** 缓动:'auto'(smoothstep)/ 'linear' / 'constant' / '<曲线>_in|_out|_in_out'。 */
|
|
2541
|
+
export function easeFactor(kind, x) {
|
|
2542
|
+
const k = Math.max(0, Math.min(1, Number(x) || 0));
|
|
2543
|
+
const id = String(kind == null ? 'auto' : kind).toLowerCase().replace(/[\s-]+/g, '_');
|
|
2544
|
+
if (id === 'constant') return 0;
|
|
2545
|
+
if (id === 'linear') return k;
|
|
2546
|
+
if (id === 'auto' || id === 'smooth' || id === 'smoothstep' || id === 'bezier') return k * k * (3 - 2 * k);
|
|
2547
|
+
const m = /^(sine|quad|cubic|quart|quint|expo|circ|back|elastic|bounce)(?:_?(in_out|in|out))?$/.exec(id);
|
|
2548
|
+
if (m) {
|
|
2549
|
+
const fn = EASE_IN_FN[m[1]];
|
|
2550
|
+
const dir = m[2] || 'in_out';
|
|
2551
|
+
if (dir === 'in') return fn(k);
|
|
2552
|
+
if (dir === 'out') return 1 - fn(1 - k);
|
|
2553
|
+
return k < 0.5 ? fn(2 * k) / 2 : 1 - fn(2 - 2 * k) / 2;
|
|
2554
|
+
}
|
|
2555
|
+
return k * k * (3 - 2 * k);
|
|
2556
|
+
}
|
|
2557
|
+
/** 一维三次贝塞尔。 */
|
|
2558
|
+
function bez1(p0, p1, p2, p3, t) {
|
|
2559
|
+
const u = 1 - t;
|
|
2560
|
+
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
|
|
2561
|
+
}
|
|
2562
|
+
/** 由 x 反解贝塞尔参数 t(x 单调;手柄已限幅)。二分法稳定且精度足够。 */
|
|
2563
|
+
function bezierSolveT(x0, x1, x2, x3, x) {
|
|
2564
|
+
if (x <= x0) return 0;
|
|
2565
|
+
if (x >= x3) return 1;
|
|
2566
|
+
let lo = 0, hi = 1;
|
|
2567
|
+
for (let i = 0; i < 48; i++) {
|
|
2568
|
+
const mid = (lo + hi) / 2;
|
|
2569
|
+
if (bez1(x0, x1, x2, x3, mid) < x) lo = mid; else hi = mid;
|
|
2570
|
+
}
|
|
2571
|
+
return (lo + hi) / 2;
|
|
2572
|
+
}
|
|
2573
|
+
/** 自动切线(auto-clamped 语义):极值点取水平切线,其余用中心差分并限幅。 */
|
|
2574
|
+
function autoHandle(keys, i, val) {
|
|
2575
|
+
const k = keys[i];
|
|
2576
|
+
const prev = keys[i - 1], next = keys[i + 1];
|
|
2577
|
+
const dtNext = next ? Math.max(1e-6, next.t - k.t) : 1e-6;
|
|
2578
|
+
if (!prev || !next) return { dx: 0, dy: 0 }; // 端点:水平切线(Blender 端点同义)
|
|
2579
|
+
const v0 = val(prev), v1 = val(k), v2 = val(next);
|
|
2580
|
+
const isExtremum = (v1 >= v0 && v1 >= v2) || (v1 <= v0 && v1 <= v2);
|
|
2581
|
+
const slope = isExtremum ? 0 : (v2 - v0) / Math.max(1e-6, next.t - prev.t);
|
|
2582
|
+
const dx = Math.min(dtNext / 3, (k.t - prev.t) / 3);
|
|
2583
|
+
return { dx, dy: slope * dx };
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* 通道求值:keys = [{ t, interp?, ease?, hIn?{dx,dy}, hOut?{dx,dy} }]
|
|
2587
|
+
* interp: 'auto'(默认,smoothstep/legacy) | 'linear' | 'constant' | 'bezier'
|
|
2588
|
+
* bezier 时用显式手柄(dx=时间偏移, dy=值偏移),缺省走自动切线。
|
|
2589
|
+
*/
|
|
2590
|
+
export function evalKeyChannel(keys, valueOf, time) {
|
|
2591
|
+
const ks = (keys || []).filter((k) => k && k.t !== undefined).slice().sort((a, b) => a.t - b.t);
|
|
2592
|
+
if (!ks.length) return null;
|
|
2593
|
+
const val = (k) => { const v = Number(valueOf(k)); return Number.isFinite(v) ? v : 0; };
|
|
2594
|
+
const t = Math.max(0, Number(time) || 0);
|
|
2595
|
+
if (t <= ks[0].t) return val(ks[0]);
|
|
2596
|
+
if (t >= ks[ks.length - 1].t) return val(ks[ks.length - 1]);
|
|
2597
|
+
let i = 0;
|
|
2598
|
+
while (i < ks.length - 1 && ks[i + 1].t <= t) i++;
|
|
2599
|
+
const a = ks[i], b = ks[i + 1];
|
|
2600
|
+
const span = b.t - a.t;
|
|
2601
|
+
const k = span > 1e-6 ? (t - a.t) / span : 0;
|
|
2602
|
+
const va = val(a), vb = val(b);
|
|
2603
|
+
const interp = String((b.interp || a.interp || 'auto')).toLowerCase();
|
|
2604
|
+
if (interp === 'constant') return va;
|
|
2605
|
+
if (interp === 'bezier') {
|
|
2606
|
+
const ho = a.hOut && Number.isFinite(Number(a.hOut.dx)) ? { dx: Number(a.hOut.dx), dy: Number(a.hOut.dy) || 0 } : (() => { const h = autoHandle(ks, i, val); return { dx: h.dx, dy: h.dy }; })();
|
|
2607
|
+
const hi = b.hIn && Number.isFinite(Number(b.hIn.dx)) ? { dx: Number(b.hIn.dx), dy: Number(b.hIn.dy) || 0 } : (() => { const h = autoHandle(ks, i + 1, val); return { dx: -h.dx, dy: -h.dy }; })();
|
|
2608
|
+
let x1 = a.t + ho.dx, x2 = b.t + hi.dx;
|
|
2609
|
+
// 手柄 x 限幅(对应 Blender BKE_fcurve_correct_bezpart:防止时间轴回环)
|
|
2610
|
+
x1 = Math.min(Math.max(x1, a.t + 1e-6), b.t - 1e-6);
|
|
2611
|
+
x2 = Math.min(Math.max(x2, a.t + 1e-6), b.t - 1e-6);
|
|
2612
|
+
if (x2 < x1) { const mid = (x1 + x2) / 2; x1 = mid; x2 = mid; }
|
|
2613
|
+
const u = bezierSolveT(a.t, x1, x2, b.t, t);
|
|
2614
|
+
return bez1(va, va + ho.dy, vb + hi.dy, vb, u);
|
|
2615
|
+
}
|
|
2616
|
+
const e = interp === 'linear' ? k : easeFactor(b.ease || a.ease || 'auto', k);
|
|
2617
|
+
return va + (vb - va) * e;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
1846
2620
|
export function interpolatePoseKeys(poseKeys, time) {
|
|
1847
2621
|
if (!Array.isArray(poseKeys) || !poseKeys.length) return null;
|
|
1848
2622
|
const ks = poseKeys.filter((k) => k && k.t !== undefined).sort((a, b) => a.t - b.t);
|
|
@@ -1856,19 +2630,27 @@ export function interpolatePoseKeys(poseKeys, time) {
|
|
|
1856
2630
|
if (t >= ks[i].t && t <= ks[i + 1].t) { a = ks[i]; b = ks[i + 1]; break; }
|
|
1857
2631
|
}
|
|
1858
2632
|
const span = b.t - a.t;
|
|
1859
|
-
const
|
|
2633
|
+
const rawK = span > 1e-6 ? (t - a.t) / span : 0;
|
|
2634
|
+
// 默认线性(向后兼容既有 poseKeys 语义);需要缓动时在关键帧上显式写 ease:'auto' 等
|
|
2635
|
+
const k = easeFactor(b.ease || a.ease || 'linear', rawK);
|
|
1860
2636
|
const pa = a.bonePose || {}, pb = b.bonePose || {};
|
|
1861
|
-
// 合并两帧出现过的所有骨骼索引
|
|
1862
2637
|
const idxSet = new Set([...Object.keys(pa), ...Object.keys(pb)]);
|
|
1863
2638
|
const out = {};
|
|
1864
2639
|
for (const key of idxSet) {
|
|
1865
2640
|
const ra = pa[key] || [0, 0, 0];
|
|
1866
2641
|
const rb = pb[key] || [0, 0, 0];
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
(Number(ra[
|
|
1870
|
-
(Number(
|
|
1871
|
-
|
|
2642
|
+
if (ra.length === 4 || rb.length === 4) {
|
|
2643
|
+
// 四元数通道:最短弧 slerp(Blender bone rotation 语义);3 分量按欧拉转四元数兜底
|
|
2644
|
+
const qa = ra.length === 4 ? ra : eulerToQuat(Number(ra[0]) || 0, Number(ra[1]) || 0, Number(ra[2]) || 0);
|
|
2645
|
+
const qb = rb.length === 4 ? rb : eulerToQuat(Number(rb[0]) || 0, Number(rb[1]) || 0, Number(rb[2]) || 0);
|
|
2646
|
+
out[key] = quatSlerp(qa, qb, k);
|
|
2647
|
+
} else {
|
|
2648
|
+
out[key] = [
|
|
2649
|
+
(Number(ra[0]) || 0) + ((Number(rb[0]) || 0) - (Number(ra[0]) || 0)) * k,
|
|
2650
|
+
(Number(ra[1]) || 0) + ((Number(rb[1]) || 0) - (Number(ra[1]) || 0)) * k,
|
|
2651
|
+
(Number(ra[2]) || 0) + ((Number(rb[2]) || 0) - (Number(ra[2]) || 0)) * k,
|
|
2652
|
+
];
|
|
2653
|
+
}
|
|
1872
2654
|
}
|
|
1873
2655
|
return out;
|
|
1874
2656
|
}
|
|
@@ -1990,6 +2772,26 @@ export function buildStageFromSpec(spec) {
|
|
|
1990
2772
|
const pose = poseOf(c.pose);
|
|
1991
2773
|
if (pose) ob.poseId = pose;
|
|
1992
2774
|
if (c.material && typeof c.material === 'object') ob.material = { ...c.material };
|
|
2775
|
+
if (c.skinning && /^(lbs|dqs|auto)$/i.test(String(c.skinning))) ob.skinning = String(c.skinning).toLowerCase();
|
|
2776
|
+
if (Array.isArray(c.ik) && c.ik.length) {
|
|
2777
|
+
ob.ik = c.ik.filter((x) => x && Array.isArray(x.chain) && x.chain.length >= 3 && x.target)
|
|
2778
|
+
.map((x) => { const o = { chain: x.chain.map(String), target: x.target }; if (x.pole) o.pole = x.pole; if (x.weight !== undefined) o.weight = Number(x.weight); return o; });
|
|
2779
|
+
if (!ob.ik.length) delete ob.ik;
|
|
2780
|
+
}
|
|
2781
|
+
if (Array.isArray(c.constraints) && c.constraints.length) {
|
|
2782
|
+
ob.constraints = c.constraints.filter((x) => x && x.bone && x.type).map((x) => Object.assign({}, x));
|
|
2783
|
+
if (!ob.constraints.length) delete ob.constraints;
|
|
2784
|
+
}
|
|
2785
|
+
if (Array.isArray(c.clips) && c.clips.length) {
|
|
2786
|
+
ob.clips = c.clips.filter((x) => x && Array.isArray(x.keys) && x.keys.length).map((x) => {
|
|
2787
|
+
const o = { keys: x.keys, blend: String(x.blend || 'replace'), influence: Number(x.influence === undefined ? 1 : x.influence) };
|
|
2788
|
+
if (x.t0 !== undefined) o.t0 = Number(x.t0);
|
|
2789
|
+
if (x.t1 !== undefined) o.t1 = Number(x.t1);
|
|
2790
|
+
if (x.loop) o.loop = true;
|
|
2791
|
+
return o;
|
|
2792
|
+
});
|
|
2793
|
+
if (!ob.clips.length) delete ob.clips;
|
|
2794
|
+
}
|
|
1993
2795
|
addPath(ob, c.path, c.pathMode);
|
|
1994
2796
|
if (ob.path && c.pathFace === false) ob.pathFace = false;
|
|
1995
2797
|
if (c.locomotion && typeof c.locomotion === 'object') ob.locomotion = { ...c.locomotion };
|
|
@@ -2014,6 +2816,18 @@ export function buildStageFromSpec(spec) {
|
|
|
2014
2816
|
color: String(p.color || '#b3b8bf'),
|
|
2015
2817
|
};
|
|
2016
2818
|
if (p.material && typeof p.material === 'object') pob.material = { ...p.material };
|
|
2819
|
+
// 道具关键帧:与角色同构(t/pos/rotY/rx/rz/ease),供长枪等跟手(applyPlayhead 对所有对象统一应用)
|
|
2820
|
+
if (Array.isArray(p.keys)) {
|
|
2821
|
+
pob.keys = p.keys.map((k) => ({
|
|
2822
|
+
t: num(k.t, 0),
|
|
2823
|
+
pos: v3of(k.pos, 0),
|
|
2824
|
+
rotY: num(k.rotY, 0),
|
|
2825
|
+
rx: num(k.rx, 0),
|
|
2826
|
+
rz: num(k.rz, 0),
|
|
2827
|
+
ease: String(k.ease || 'auto'),
|
|
2828
|
+
...(k.interp ? { interp: String(k.interp) } : {}),
|
|
2829
|
+
})).sort((x, y) => x.t - y.t);
|
|
2830
|
+
}
|
|
2017
2831
|
objects.push(pob);
|
|
2018
2832
|
}
|
|
2019
2833
|
const cameras = (Array.isArray(sp.cameras) ? sp.cameras : []).map((c, i) => {
|
|
@@ -2119,6 +2933,10 @@ export function compileStageSpec(scene) {
|
|
|
2119
2933
|
if (ob.scale && (Number(ob.scale.x) !== 1 || Number(ob.scale.y) !== 1 || Number(ob.scale.z) !== 1)) c.scale = arr(ob.scale);
|
|
2120
2934
|
if (ob.poseId) c.pose = ob.poseId;
|
|
2121
2935
|
if (ob.material) c.material = { ...ob.material };
|
|
2936
|
+
if (ob.skinning) c.skinning = ob.skinning;
|
|
2937
|
+
if (Array.isArray(ob.ik) && ob.ik.length) c.ik = JSON.parse(JSON.stringify(ob.ik));
|
|
2938
|
+
if (Array.isArray(ob.constraints) && ob.constraints.length) c.constraints = JSON.parse(JSON.stringify(ob.constraints));
|
|
2939
|
+
if (Array.isArray(ob.clips) && ob.clips.length) c.clips = JSON.parse(JSON.stringify(ob.clips));
|
|
2122
2940
|
if (ob.bonePose && Object.keys(ob.bonePose).length) c.bonePose = JSON.parse(JSON.stringify(ob.bonePose));
|
|
2123
2941
|
if (Array.isArray(ob.path) && ob.path.length) {
|
|
2124
2942
|
c.path = ob.path.map(arr); c.pathMode = ob.pathMode || 'curve';
|
|
@@ -2136,6 +2954,9 @@ export function compileStageSpec(scene) {
|
|
|
2136
2954
|
scale: arr(ob.scale || v3(1, 1, 1)), color: ob.color,
|
|
2137
2955
|
visible: ob.visible === false ? false : undefined,
|
|
2138
2956
|
material: ob.material ? { ...ob.material } : undefined,
|
|
2957
|
+
...(Array.isArray(ob.keys) && ob.keys.length
|
|
2958
|
+
? { keys: ob.keys.map((k) => ({ t: round3(k.t), pos: arr(k.pos), rotY: round3(k.rotY || 0), rx: round3(k.rx || 0), rz: round3(k.rz || 0), ease: k.ease || 'auto' })) }
|
|
2959
|
+
: {}),
|
|
2139
2960
|
});
|
|
2140
2961
|
}
|
|
2141
2962
|
}
|
|
@@ -2415,6 +3236,33 @@ export function applyPlayhead(scene, t) {
|
|
|
2415
3236
|
}
|
|
2416
3237
|
if (ry) next.pos = v3(next.pos.x, next.pos.y + ry, next.pos.z);
|
|
2417
3238
|
}
|
|
3239
|
+
// 姿态中心偏移(供 gizmo / 拾取跟随身体,而非留在地面原点)
|
|
3240
|
+
if (next.kind === 'model' && gltfAssetOf(next.assetId)) {
|
|
3241
|
+
try {
|
|
3242
|
+
const a2 = gltfAssetOf(next.assetId);
|
|
3243
|
+
const clipName = next.clip || (a2.animations[0] && a2.animations[0].name) || '';
|
|
3244
|
+
const clip = clipName ? a2.clipByName(clipName) : null;
|
|
3245
|
+
let hipsY = null;
|
|
3246
|
+
if (clip && Array.isArray(clip.tracks)) {
|
|
3247
|
+
const hipTrack = clip.tracks.find((tr) => tr.path === 'translation' && tr.node === (a2.bones.find((x) => String(x.name).replace(/^mixamorig:?/i, '') === 'Hips') || {}).nodeIdx);
|
|
3248
|
+
if (hipTrack) {
|
|
3249
|
+
const tv = hipTrack.times, vv = hipTrack.vals;
|
|
3250
|
+
const n = tv.length;
|
|
3251
|
+
if (n) {
|
|
3252
|
+
let i2 = 0;
|
|
3253
|
+
const tt = Math.max(tv[0], Math.min(tv[n - 1], time));
|
|
3254
|
+
while (i2 < n - 2 && tv[i2 + 1] < tt) i2++;
|
|
3255
|
+
const k2 = (tt - tv[i2]) / Math.max(1e-6, tv[i2 + 1] - tv[i2]);
|
|
3256
|
+
hipsY = (vv[i2 * 3 + 1] * (1 - k2) + vv[(i2 + 1) * 3 + 1] * k2) / 100; // 厘米 → 米
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
if (hipsY !== null && Number.isFinite(hipsY)) {
|
|
3261
|
+
// 髋的实际世界高度就作为 gizmo 中心高度(站立≈1.04,倒地≈0.35)
|
|
3262
|
+
next.poseCenterY = Math.max(0.15, Math.min(1.2, hipsY));
|
|
3263
|
+
}
|
|
3264
|
+
} catch { /* 姿态中心算不出就退回原点 */ }
|
|
3265
|
+
}
|
|
2418
3266
|
// 姿态关键帧:优先于静态 bonePose(姿态随时间过渡)
|
|
2419
3267
|
if (Array.isArray(o.poseKeys) && o.poseKeys.length) {
|
|
2420
3268
|
const ip = interpolatePoseKeys(o.poseKeys, time);
|
|
@@ -2447,15 +3295,19 @@ export function applyPlayhead(scene, t) {
|
|
|
2447
3295
|
}
|
|
2448
3296
|
}
|
|
2449
3297
|
}
|
|
2450
|
-
// 对象关键帧:位置 / 朝向 /
|
|
3298
|
+
// 对象关键帧:位置 / 朝向 / 翻滚(逐通道求值,支持 bezier/linear/constant + ease)
|
|
2451
3299
|
if (Array.isArray(o.keys) && o.keys.length) {
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
3300
|
+
const kx = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.x : undefined), time);
|
|
3301
|
+
const ky = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.y : undefined), time);
|
|
3302
|
+
const kz = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.z : undefined), time);
|
|
3303
|
+
const kr = evalKeyChannel(o.keys, (kk) => kk.rotY, time);
|
|
3304
|
+
const kx2 = evalKeyChannel(o.keys, (kk) => kk.rx, time);
|
|
3305
|
+
const kz2 = evalKeyChannel(o.keys, (kk) => kk.rz, time);
|
|
3306
|
+
const hasPosKey = o.keys.some((kk) => kk && kk.pos);
|
|
3307
|
+
if (hasPosKey) next.pos = v3(kx === null ? o.pos.x : kx, ky === null ? o.pos.y : ky, kz === null ? o.pos.z : kz);
|
|
3308
|
+
next.rotY = kr === null ? (Number(o.rotY) || 0) : kr;
|
|
3309
|
+
next.rx = kx2 === null ? (Number(o.rx) || 0) : kx2;
|
|
3310
|
+
next.rz = kz2 === null ? (Number(o.rz) || 0) : kz2;
|
|
2459
3311
|
}
|
|
2460
3312
|
// 路径优先级高于对象位置关键帧:但只接管平面 X/Z,**竖直 Y 保留关键帧的高度**
|
|
2461
3313
|
// (否则翻越/跳跃的关键帧抬升会被路径压回地面 —— 用户实测"翻不过去")
|
|
@@ -2676,7 +3528,7 @@ export function cameraFlyStep(cam, input, dt, opts) {
|
|
|
2676
3528
|
* 单位一律是 CSS 像素 → 同一相机/同一 CSS 尺寸下两者必然一致(历史 bug:绘制用设备像素、拾取用 CSS,命中区偏移)。
|
|
2677
3529
|
*/
|
|
2678
3530
|
export function gizmoMetrics(cam, sel, hCss, originOverride) {
|
|
2679
|
-
const origin = originOverride || v3(sel.pos.x, sel.pos.y, sel.pos.z);
|
|
3531
|
+
const origin = originOverride || v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
|
|
2680
3532
|
const depth = depthOf(cam, origin);
|
|
2681
3533
|
const pxWorld = pixelSizeAt(cam, hCss, depth);
|
|
2682
3534
|
const minWorld = Math.max(0.25, (Number(sel.h) || 1.7) / 3);
|
|
@@ -2783,6 +3635,97 @@ function matchBone(bones, kw) {
|
|
|
2783
3635
|
for (let i = 0; i < bones.length; i++) if (shortBoneName(bones[i].name).toLowerCase().endsWith(want)) return i;
|
|
2784
3636
|
return -1;
|
|
2785
3637
|
}
|
|
3638
|
+
/**
|
|
3639
|
+
* 通用人形骨架映射:把任意模型的骨骼名归一化到标准语义关节。
|
|
3640
|
+
* 覆盖 Mixamo / VRM(0.x,1.0) / Daz-Genesis / MakeHuman / Unreal / Blender(.L/.R) / 裸名。
|
|
3641
|
+
* 返回 { semantic: nodeIdx } —— 动作只以语义关节表达,因此同一动作可套到任意人形模型。
|
|
3642
|
+
*/
|
|
3643
|
+
const CANON_BONES = [
|
|
3644
|
+
'Hips', 'Spine', 'Chest', 'Neck', 'Head',
|
|
3645
|
+
'LeftShoulder', 'LeftArm', 'LeftForeArm', 'LeftHand',
|
|
3646
|
+
'RightShoulder', 'RightArm', 'RightForeArm', 'RightHand',
|
|
3647
|
+
'LeftUpLeg', 'LeftLeg', 'LeftFoot', 'LeftToeBase',
|
|
3648
|
+
'RightUpLeg', 'RightLeg', 'RightFoot', 'RightToeBase',
|
|
3649
|
+
];
|
|
3650
|
+
/** 各骨架上常见的别名(key 为语义名,值为可接受的正则片段)。 */
|
|
3651
|
+
const RIG_ALIASES = {
|
|
3652
|
+
Hips: ['hips', 'hip', 'pelvis'],
|
|
3653
|
+
Spine: ['spine', 'spine1', 'abdomen', 'abdomenlower', 'lowerback'],
|
|
3654
|
+
Chest: ['chest', 'spine2', 'upperchest', 'torso', 'abdomenupper'],
|
|
3655
|
+
Neck: ['neck', 'necklower'],
|
|
3656
|
+
Head: ['head', 'skull'],
|
|
3657
|
+
LeftShoulder: ['lshoulder', 'lcollar', 'lclavicle'], LeftArm: ['larm', 'lshldr', 'lupperarm'], LeftForeArm: ['lforearm', 'llowerarm', 'lelbow'], LeftHand: ['lhand', 'lwrist'],
|
|
3658
|
+
RightShoulder: ['rshoulder', 'rcollar', 'rclavicle'], RightArm: ['rarm', 'rshldr', 'rupperarm'], RightForeArm: ['rforearm', 'rlowerarm', 'relbow'], RightHand: ['rhand', 'rwrist'],
|
|
3659
|
+
LeftUpLeg: ['lupleg', 'lthigh'], LeftLeg: ['lleg', 'lshin'], LeftFoot: ['lfoot'], LeftToeBase: ['ltoe'],
|
|
3660
|
+
RightUpLeg: ['rupleg', 'rthigh'], RightLeg: ['rleg', 'rshin'], RightFoot: ['rfoot'], RightToeBase: ['rtoe'],
|
|
3661
|
+
};
|
|
3662
|
+
/** 骨骼名归一化:去前缀、去分隔符、小写、统一左右(_.L / _L / .Left / left)。 */
|
|
3663
|
+
function canonBoneName(name) {
|
|
3664
|
+
let n = String(name || '');
|
|
3665
|
+
n = n.replace(/^mixamorig:?/i, '');
|
|
3666
|
+
n = n.replace(/^bip_?\d*_?/i, '');
|
|
3667
|
+
n = n.toLowerCase();
|
|
3668
|
+
// 统一左右标记:把 left/right、_l/_r、.l/.r、-l/-r、l_/r_ 统一成前缀 l/r
|
|
3669
|
+
let side = '';
|
|
3670
|
+
if (/(lef|^l[._\-\s]|_l$|\.l$|\bl$)/.test(n)) side = 'l';
|
|
3671
|
+
else if (/(righ|^r[._\-\s]|_r$|\.r$|\br$)/.test(n)) side = 'r';
|
|
3672
|
+
// 去掉所有分隔符与左右词
|
|
3673
|
+
n = n.replace(/[\.\-_\s]/g, '').replace(/^(left|right)/, '');
|
|
3674
|
+
n = n.replace(/(left|right)$/, '');
|
|
3675
|
+
// 语义归一:upperleg/thigh → upleg;lowerleg/shin/calf → leg;upperarm → arm;lowerarm/forearm → forearm
|
|
3676
|
+
n = n.replace(/upperleg/g, 'upleg').replace(/thigh/g, 'upleg');
|
|
3677
|
+
n = n.replace(/lowerleg/g, 'leg').replace(/shin/g, 'leg').replace(/calf/g, 'leg');
|
|
3678
|
+
n = n.replace(/upperarm/g, 'arm');
|
|
3679
|
+
n = n.replace(/lowerarm/g, 'forearm');
|
|
3680
|
+
n = n.replace(/clavicle/g, 'shoulder').replace(/collar/g, 'shoulder').replace(/shldr/g, 'shoulder');
|
|
3681
|
+
n = n.replace(/toes|toebase/g, 'toe');
|
|
3682
|
+
n = n.replace(/pelvis/g, 'hips');
|
|
3683
|
+
return side + n;
|
|
3684
|
+
}
|
|
3685
|
+
/** 把任意骨架映射到标准语义关节;返回 { semantic: nodeIdx, coverage, missing }。 */
|
|
3686
|
+
export function autoRigMap(asset) {
|
|
3687
|
+
const bones = (asset && asset.bones) || [];
|
|
3688
|
+
const nodes = (asset && asset.nodes) || [];
|
|
3689
|
+
const byCanon = new Map();
|
|
3690
|
+
for (const b of bones) {
|
|
3691
|
+
const c = canonBoneName(b.name);
|
|
3692
|
+
if (!byCanon.has(c)) byCanon.set(c, b.nodeIdx);
|
|
3693
|
+
}
|
|
3694
|
+
const semantic = {};
|
|
3695
|
+
for (const [sem, aliases] of Object.entries(RIG_ALIASES)) {
|
|
3696
|
+
let nodeIdx = -1;
|
|
3697
|
+
for (const al of aliases) {
|
|
3698
|
+
for (const [c, ni] of byCanon) {
|
|
3699
|
+
if (c === al) { nodeIdx = ni; break; }
|
|
3700
|
+
}
|
|
3701
|
+
if (nodeIdx >= 0) break;
|
|
3702
|
+
}
|
|
3703
|
+
if (nodeIdx < 0) {
|
|
3704
|
+
// 二次匹配:包含关系(处理 leftarm_01 之类的变体)
|
|
3705
|
+
for (const al of aliases) {
|
|
3706
|
+
for (const [c, ni] of byCanon) {
|
|
3707
|
+
if (c.includes(al)) { nodeIdx = ni; break; }
|
|
3708
|
+
}
|
|
3709
|
+
if (nodeIdx >= 0) break;
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
if (nodeIdx >= 0) semantic[sem] = nodeIdx;
|
|
3713
|
+
}
|
|
3714
|
+
const total = CANON_BONES.length;
|
|
3715
|
+
const got = Object.keys(semantic).length;
|
|
3716
|
+
return {
|
|
3717
|
+
semantic,
|
|
3718
|
+
coverage: got / total,
|
|
3719
|
+
missing: CANON_BONES.filter((k) => !(k in semantic)),
|
|
3720
|
+
total: nodes.length,
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
/** 读取资产某节点的 rest 局部旋转四元数。 */
|
|
3724
|
+
export function restQuatOf(asset, nodeIdx) {
|
|
3725
|
+
const n = (asset && asset.nodes && asset.nodes[nodeIdx]) || null;
|
|
3726
|
+
if (!n || !n.r) return [0, 0, 0, 1];
|
|
3727
|
+
return [Number(n.r[0]) || 0, Number(n.r[1]) || 0, Number(n.r[2]) || 0, Number(n.r[3]) || 1];
|
|
3728
|
+
}
|
|
2786
3729
|
/** 默认场景(新建预演时)。 */
|
|
2787
3730
|
export function directorDefaultScene(title) {
|
|
2788
3731
|
const t = String(title || '预演场景');
|
|
@@ -2941,7 +3884,7 @@ export function directorObjectFaces(ob) {
|
|
|
2941
3884
|
const typeId = actorTypeIdOf(presetId);
|
|
2942
3885
|
const h = Number(ob.h) || (ACTOR_PRESETS.find((p) => p.id === presetId) || {}).h || 1.78;
|
|
2943
3886
|
const sk = skinModel(asset, ob.clip || '', Number(ob.animTime) || 0, null, null,
|
|
2944
|
-
Object.assign({}, bodyShapeOf(typeId), ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}));
|
|
3887
|
+
Object.assign({}, bodyShapeOf(typeId), ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
|
|
2945
3888
|
const scale = (Number(asset.rawHeight) > 0 ? h / asset.rawHeight : 1) * (Number(ob.s) || 1);
|
|
2946
3889
|
const ry = Number(ob.rotY) || 0, c0 = Math.cos(ry), s0 = Math.sin(ry);
|
|
2947
3890
|
const idx = asset.indices || [];
|
|
@@ -2977,7 +3920,8 @@ export function directorObjectFaces(ob) {
|
|
|
2977
3920
|
if (asset && asset.positions) {
|
|
2978
3921
|
const shape = Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {},
|
|
2979
3922
|
ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {},
|
|
2980
|
-
ob.poseBlend ? { poseBlend: ob.poseBlend } : {}
|
|
3923
|
+
ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {},
|
|
3924
|
+
ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {});
|
|
2981
3925
|
const sk = skinModel(asset, ob.clip || '', Number(ob.animTime) || 0, null, null, shape);
|
|
2982
3926
|
const scale = (Number(asset.rawHeight) > 0 ? (Number(ob.h) || 1.78) / asset.rawHeight : 1);
|
|
2983
3927
|
const out = [];
|
|
@@ -3963,10 +4907,10 @@ export function createDirectorGlRenderer(canvas) {
|
|
|
3963
4907
|
const asset = gltfAssetOf(ob.assetId);
|
|
3964
4908
|
if (!asset) continue;
|
|
3965
4909
|
const poseKey = (ob.poseId || '') + '|' + (Array.isArray(ob.poseAnim) ? ob.poseAnim.map((p) => p.poseId + '@' + p.t).join(',') + '#' + (Number(ob.animTime) || 0).toFixed(3) : '') + '|' + (ob.bonePose ? Object.keys(ob.bonePose).sort().map((bi) => bi + '=' + (ob.bonePose[bi] || []).map((v) => Number(v).toFixed(3)).join(',')).join(';') : '');
|
|
3966
|
-
const kk = ob.id + ":" + (ob.clip || "") + ":" + (Number(ob.animTime) || 0).toFixed(3) + ":" + (ob.typeId || "") + ":" + poseKey + ":shadowV2";
|
|
4910
|
+
const kk = ob.id + ":" + (ob.clip || "") + ":" + (Number(ob.animTime) || 0).toFixed(3) + ":" + (ob.typeId || "") + ":" + (ob.skinning || "lbs") + ":" + (ob.ik ? JSON.stringify(ob.ik) : "") + ":" + (ob.constraints ? JSON.stringify(ob.constraints) : "") + ":" + poseKey + ":shadowV2";
|
|
3967
4911
|
let sk = skCache.get(kk);
|
|
3968
4912
|
if (!sk) {
|
|
3969
|
-
sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}));
|
|
4913
|
+
sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
|
|
3970
4914
|
if (skCache.size > 40) skCache.clear();
|
|
3971
4915
|
skCache.set(kk, sk);
|
|
3972
4916
|
}
|
|
@@ -4437,10 +5381,10 @@ export function createDirectorGlRenderer(canvas) {
|
|
|
4437
5381
|
if (asset) {
|
|
4438
5382
|
// 缓存键必须包含「体型 + 骨骼姿态」:姿态一改就得重新蒙皮,否则永远命中旧网格
|
|
4439
5383
|
const poseKey = (ob.poseId || '') + '|' + (ob.bonePose ? Object.keys(ob.bonePose).sort().map((bi) => bi + '=' + (ob.bonePose[bi] || []).map((v) => Number(v).toFixed(3)).join(',')).join(';') : '');
|
|
4440
|
-
const k = ob.id + ':' + (ob.clip || '') + ':' + (Number(ob.animTime) || 0).toFixed(3) + ':' + (ob.typeId || '') + ':' + poseKey;
|
|
5384
|
+
const k = ob.id + ':' + (ob.clip || '') + ':' + (Number(ob.animTime) || 0).toFixed(3) + ':' + (ob.typeId || '') + ':' + (ob.skinning || 'lbs') + ':' + (ob.ik ? JSON.stringify(ob.ik) : '') + ':' + (ob.constraints ? JSON.stringify(ob.constraints) : '') + ':' + poseKey;
|
|
4441
5385
|
let sk = skCache.get(k);
|
|
4442
5386
|
if (!sk) {
|
|
4443
|
-
sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}));
|
|
5387
|
+
sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
|
|
4444
5388
|
if (skCache.size > 24) skCache.clear();
|
|
4445
5389
|
skCache.set(k, sk);
|
|
4446
5390
|
}
|
|
@@ -4607,7 +5551,8 @@ export function createDirectorGlRenderer(canvas) {
|
|
|
4607
5551
|
if (o.gizmo && o.selectedId) {
|
|
4608
5552
|
const sel = (scene.objects || []).find((x) => x.id === o.selectedId);
|
|
4609
5553
|
if (sel) {
|
|
4610
|
-
|
|
5554
|
+
// gizmo 跟随姿态中心(倒地/下蹲时身体不在原点:让轴心贴住身体,而非留在地面)
|
|
5555
|
+
const origin = v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
|
|
4611
5556
|
// 屏幕恒定大小(Blender: wm_gizmo_calculate_scale):轴长 105 CSS px,环 0.62×轴长。
|
|
4612
5557
|
// 尺寸唯一来源 gizmoMetrics,拾取用同一个函数 → 不可能再出现「画的位置和点不中」。
|
|
4613
5558
|
const gm = gizmoMetrics(cam, sel, hCss, origin);
|