/** * EmoteGlbParser — конвертирует Babylon AnimationGroup (из GLB-файла) в * R15Animator-spec формат: { length, loop, perBoneFrames }. * * perBoneFrames: { : { times: [...], deltas: [Quaternion, ...] } } * delta — это quaternion от rest-pose ИСХОДНОГО скелета. * * При проигрывании на бекона: target = rest_bacon × delta. Это даёт * корректный retargeting если локальные оси костей одинаково ориентированы * (Mixamo → R15-bacon: да, обе используют ту же конвенцию). * * Использование: * const spec = parseEmoteGlbToSpec(animationGroup, importedSkeleton); * r15Animator.playCustomEmote(spec); */ import { Quaternion, Vector3 } from '@babylonjs/core'; // R15-логическое имя → подстроки имени Mixamo-кости (приоритет сверху-вниз). // Должно совпадать с R15Skeleton.R15_TO_MIXAMO, но мы здесь работаем с именами // нод GLB напрямую (а не через R15Skeleton-резолвер). const R15_KEYWORDS = { HumanoidRootPart: ['hips', 'humanoidrootpart', 'root'], LowerTorso: ['spine1', 'spine', 'lowertorso'], UpperTorso: ['spine2', 'uppertorso'], Head: ['head'], Neck: ['neck'], LeftUpperArm: ['leftarm', 'leftupperarm', 'leftshoulder'], LeftLowerArm: ['leftforearm', 'leftlowerarm'], LeftHand: ['lefthand'], RightUpperArm: ['rightarm', 'rightupperarm', 'rightshoulder'], RightLowerArm: ['rightforearm', 'rightlowerarm'], RightHand: ['righthand'], LeftUpperLeg: ['leftupleg', 'leftupperleg'], LeftLowerLeg: ['leftleg', 'leftlowerleg'], LeftFoot: ['leftfoot'], RightUpperLeg: ['rightupleg', 'rightupperleg'], RightLowerLeg: ['rightleg', 'rightlowerleg'], RightFoot: ['rightfoot'], }; function normName(raw) { return String(raw || '') .toLowerCase() .replace(/mixamorig/g, '') .replace(/[:_\s.\-]/g, ''); } function resolveLogicalR15(boneName) { const norm = normName(boneName); for (const logical of Object.keys(R15_KEYWORDS)) { const kws = R15_KEYWORDS[logical]; for (const kw of kws) { if (norm.includes(kw)) return logical; } } return null; } /** * Конвертировать AnimationGroup в delta-spec. * * @param {AnimationGroup} group — из импортированного GLB * @returns {object} spec = { length, loop, perBoneFrames } */ export function parseEmoteGlbToSpec(group, loop = false) { if (!group) return null; const length = group.to / 60; // glTF samplers времена в секундах, // но Babylon хранит их в frames (60 FPS) — group.from/to это frames. // ИСПРАВЛЕНО: используем длительность из targetedAnimations[0].animation // если есть, иначе через group.to / 60. // Берём реальную длительность из первой анимации (там times в секундах). let realLength = 0; if (group.targetedAnimations && group.targetedAnimations.length > 0) { for (const ta of group.targetedAnimations) { const anim = ta.animation; const keys = anim?.getKeys?.() || []; if (keys.length > 0) { const lastFrame = keys[keys.length - 1].frame; const fps = anim.framePerSecond || 60; realLength = Math.max(realLength, lastFrame / fps); } } } if (realLength === 0) realLength = (group.to - group.from) / 60; // Для каждой target-ноды: собираем keyframes её rotation_local + rest const perBone = {}; // logical → { restRotQ, times, rots } for (const ta of group.targetedAnimations || []) { const target = ta.target; if (!target) continue; const tgtName = target.name || target.id || ''; const logical = resolveLogicalR15(tgtName); if (!logical) continue; const anim = ta.animation; if (!anim) continue; const propName = anim.targetProperty; // Нас интересуют только rotation/rotationQuaternion-каналы. // Position-каналы (translation корня Hips) пропускаем — у бекона // позиция корня управляется движком (физика), нельзя её рулить. const isRot = propName === 'rotationQuaternion' || propName === 'rotation'; if (!isRot) continue; const keys = anim.getKeys() || []; if (keys.length === 0) continue; const fps = anim.framePerSecond || 60; if (!perBone[logical]) { // rest = текущее значение target до анимации (= bind pose) const restQ = target.rotationQuaternion ? target.rotationQuaternion.clone() : (target.rotation ? Quaternion.FromEulerAngles( target.rotation.x, target.rotation.y, target.rotation.z) : Quaternion.Identity()); perBone[logical] = { restQ, times: [], rots: [], }; } const slot = perBone[logical]; // Складываем keyframes. Если есть несколько каналов на одну кость // (например, rotation_x + rotation_y отдельно — для anim_type=Euler) // — приходится их сэмплировать на общей сетке. Для простоты: // если propName === 'rotation' (Vector3), конвертируем каждый кадр. for (const k of keys) { const t = k.frame / fps; let q; if (propName === 'rotationQuaternion') { q = (k.value && k.value.clone) ? k.value.clone() : null; } else { // rotation (Vector3 euler XYZ) if (k.value && typeof k.value.x === 'number') { q = Quaternion.FromEulerAngles(k.value.x, k.value.y, k.value.z); } } if (!q) continue; // Если в этом времени уже есть запись — мерджим (умножаем). // На практике одна кость → один канал rotation, мердж не нужен. const existingIdx = slot.times.indexOf(t); if (existingIdx >= 0) { slot.rots[existingIdx] = slot.rots[existingIdx].multiply(q); } else { slot.times.push(t); slot.rots.push(q); } } } // Сортируем keyframes по времени внутри каждой кости for (const logical of Object.keys(perBone)) { const slot = perBone[logical]; const pairs = slot.times.map((t, i) => [t, slot.rots[i]]); pairs.sort((a, b) => a[0] - b[0]); slot.times = pairs.map(p => p[0]); slot.rots = pairs.map(p => p[1]); // Конвертируем абсолютные rot в delta: delta = restQ⁻¹ × rot. const restInv = Quaternion.Inverse(slot.restQ); slot.deltas = slot.rots.map(q => restInv.multiply(q)); delete slot.rots; } return { length: realLength || 1.0, loop: !!loop, perBoneFrames: perBone, }; }