/** * R15Animator — процедурные анимации R15-скелета для веб-плеера. * * Порт `RubloxAnimator.gd` (Godot) / `R15Animator.kt` (Kotlin). R15-скины * приходят БЕЗ glTF-анимаций — позы считаются кодом покадрово. * * Принцип (как в нативных плеерах): * поза кости = restRotation × Quaternion(axis, angle × intensity) * intensity ∈ [-1..1] интерполируется по фазе анимации (ключевые точки). * * Состояния: idle / walk / run / jump / fall. Между состояниями — * cross-fade (lerp кватернионов) за BLEND_TIME. * * Babylon: bone.getRotationQuaternion(Space.LOCAL) даёт текущий поворот, * bone.setRotationQuaternion(q, Space.LOCAL) — ставит. rest берём один раз * при setup (bone.getRotationQuaternion на старте = bind-поза). */ import { Quaternion, Vector3, Space } from '@babylonjs/core'; const DEG = Math.PI / 180; const BLEND_TIME = 0.18; // сек на cross-fade между состояниями // Логические R15-кости, которыми анимируем (резолвятся через R15Skeleton). const B = { SPINE: 'UpperTorso', LEFT_UPLEG: 'LeftUpperLeg', LEFT_LEG: 'LeftLowerLeg', RIGHT_UPLEG: 'RightUpperLeg', RIGHT_LEG: 'RightLowerLeg', LEFT_ARM: 'LeftUpperArm', LEFT_FOREARM: 'LeftLowerArm', RIGHT_ARM: 'RightUpperArm', RIGHT_FOREARM: 'RightLowerArm', HEAD: 'Head', }; // Оси вращения (локальные оси кости). Vector3.Right = (1,0,0) — основная ось // сгиба конечностей в Mixamo-риге. const AXIS_RIGHT = new Vector3(1, 0, 0); const AXIS_UP = new Vector3(0, 1, 0); /** * Линейная интерполяция intensity по массивам ключей. * times — отсортированные timestamps, values — значения intensity. * phase — текущее время внутри цикла анимации. */ function lerpKeys(times, values, phase) { if (phase <= times[0]) return values[0]; if (phase >= times[times.length - 1]) return values[values.length - 1]; for (let i = 0; i < times.length - 1; i++) { if (phase >= times[i] && phase <= times[i + 1]) { const t = (phase - times[i]) / (times[i + 1] - times[i]); return values[i] + (values[i + 1] - values[i]) * t; } } return values[values.length - 1]; } /** * Описание одной анимации: длина + список треков костей. * Трек: { bone, axis, angleDeg, times, values }. * times/values — ключи intensity. loop — зацикливать ли фазу. */ function makeAnim(length, loop, tracks) { return { length, loop, tracks }; } // Стандартные анимации (для всех скинов кроме bacon-hair-подобных). // Углы калиброваны под Mixamo-bind как в Godot RubloxAnimator. const ANIMS_STD = { idle: makeAnim(3.0, true, [ // Лёгкое «дыхание» — корпус чуть качается вокруг X. { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 3, times: [0.0, 1.5, 3.0], values: [0.0, 1.0, 0.0] }, ]), walk: makeAnim(0.7, true, [ // Бёдра шагают попеременно, голени сгибаются назад при подъёме. { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, 1.0, 0.0, -1.0, 0.0] }, { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, -1.0, 0.0, 1.0, 0.0] }, { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 80, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [1.0, 0.1, 0.4, 0.6, 1.0] }, { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 80, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.4, 0.6, 1.0, 0.1, 0.4] }, // Руки swing вперёд-назад по X. { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: 25, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, -1.0, 0.0, 1.0, 0.0] }, { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: 25, times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, 1.0, 0.0, -1.0, 0.0] }, ]), run: makeAnim(0.45, true, [ { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, 1.0, 0.0, -1.0, 0.0] }, { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, -1.0, 0.0, 1.0, 0.0] }, { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 65, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.3, 0.7, 0.8, 0.3, 0.3] }, { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 65, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.8, 0.3, 0.3, 0.7, 0.8] }, { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, -1.0, 0.0, 1.0, 0.0] }, { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: 45, times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, 1.0, 0.0, -1.0, 0.0] }, // Локти согнуты ~75° (поза бегуна). { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -75, times: [0.0, 0.45], values: [1.0, 1.0] }, { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -75, times: [0.0, 0.45], values: [1.0, 1.0] }, // Лёгкий наклон корпуса вперёд. { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -10, times: [0.0, 0.45], values: [1.0, 1.0] }, ]), jump: makeAnim(0.5, true, [ // Бёдра вперёд, голени поджаты назад, руки вверх. { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: -35, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: -35, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 80, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 80, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -110, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -110, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -25, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -25, times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] }, ]), fall: makeAnim(0.8, true, [ // Лёгкий наклон корпуса вперёд. { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10, times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] }, ]), // === ЭМОЦИИ (game.player.playAnimation) === // Разовые анимации поверх авто-состояния. loop=false — играют один раз, // потом аниматор возвращается к idle/walk/... // Помахать правой рукой — рука поднята вверх-вбок, кисть качается. wave: makeAnim(1.6, false, [ // Правая рука вверх (поднимается, держится, опускается). { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -150, times: [0.0, 0.3, 1.3, 1.6], values: [0.0, 1.0, 1.0, 0.0] }, // Предплечье качается из стороны в сторону (само махание). { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -40, times: [0.0, 0.3, 0.55, 0.8, 1.05, 1.3, 1.6], values: [0.0, 1.0, -1.0, 1.0, -1.0, 1.0, 0.0] }, ]), // Танец — корпус качается, обе руки двигаются попеременно вверх. dance: makeAnim(2.4, false, [ { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 14, times: [0.0, 0.6, 1.2, 1.8, 2.4], values: [0.0, 1.0, -0.6, 1.0, 0.0] }, { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -120, times: [0.0, 0.6, 1.2, 1.8, 2.4], values: [0.0, 1.0, 0.2, 1.0, 0.0] }, { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -120, times: [0.0, 0.6, 1.2, 1.8, 2.4], values: [0.0, 0.2, 1.0, 0.2, 0.0] }, // Бёдра чуть пружинят — топчется на месте. { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 20, times: [0.0, 0.6, 1.2, 1.8, 2.4], values: [0.0, 1.0, 0.0, 1.0, 0.0] }, { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 20, times: [0.0, 0.6, 1.2, 1.8, 2.4], values: [0.0, 0.0, 1.0, 0.0, 0.0] }, ]), // Радость/победа — обе руки резко вверх и держатся. cheer: makeAnim(1.8, false, [ { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -160, times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] }, { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -160, times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] }, // Корпус чуть назад от энтузиазма. { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -8, times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] }, ]), // Присесть — бёдра и голени согнуты, держится в позе сидя. sit: makeAnim(3.0, false, [ { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: -90, times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] }, { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: -90, times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] }, { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 90, times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] }, { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 90, times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] }, ]), // Рисование баллончиком — правая рука ПОДНЯТА вперёд к «стене» // и интенсивно ТРЯСЁТСЯ (мазки). Длительность 4с — на всю фазу // граффити в кат-сцене раннера. Рука держится поднятой всё время. paint: makeAnim(4.0, false, [ // правая рука поднята вперёд-вверх и ДЕРЖИТСЯ так почти всё время { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -100, times: [0.0, 0.3, 3.7, 4.0], values: [0.0, 1.0, 1.0, 0.0] }, // предплечье быстро ходит вверх-вниз — мазки баллончиком { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -60, times: [0.0, 0.3, 0.6, 0.9, 1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3.0, 3.3, 3.6, 4.0], values: [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0] }, // вся рука покачивается вбок — горизонтальные мазки { bone: B.RIGHT_ARM, axis: AXIS_UP, angleDeg: 32, times: [0.0, 0.5, 1.1, 1.7, 2.3, 2.9, 3.5, 4.0], values: [0.0, 1.0, -0.7, 1.0, -0.7, 1.0, -0.4, 0.0] }, // корпус повёрнут к стене и слегка качается в такт { bone: B.SPINE, axis: AXIS_UP, angleDeg: 16, times: [0.0, 0.3, 3.7, 4.0], values: [0.0, 1.0, 1.0, 0.0] }, { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 7, times: [0.0, 0.7, 1.4, 2.1, 2.8, 3.5, 4.0], values: [0.0, 1.0, 0.2, 1.0, 0.2, 1.0, 0.0] }, ]), }; // Имена клипов-эмоций — для playEmote (отличить от состояний движения). const EMOTE_NAMES = ['wave', 'dance', 'cheer', 'sit', 'paint']; // Диагональные оси для F-pose плеч (руки в bind под 45°). // Калибровано в Godot для bacon-hair: bind-dir LeftArm ≈ (-0.71,0,-0.71). const FPOSE_LEFT_AXIS = new Vector3(0.71, 0, -0.71); const FPOSE_RIGHT_AXIS = new Vector3(0.71, 0, 0.71); /** * Построить набор анимаций для F-pose скинов: как ANIMS_STD, но swing * плеч идёт по диагональным осям вместо чистого X. Глубокая копия треков * с подменой оси для LEFT_ARM / RIGHT_ARM. */ function buildFPoseAnims() { const out = {}; for (const key in ANIMS_STD) { const src = ANIMS_STD[key]; out[key] = makeAnim(src.length, src.loop, src.tracks.map((tr) => { let axis = tr.axis; if (tr.bone === B.LEFT_ARM) axis = FPOSE_LEFT_AXIS; else if (tr.bone === B.RIGHT_ARM) axis = FPOSE_RIGHT_AXIS; return { ...tr, axis }; })); } return out; } export class R15Animator { /** * @param {R15Skeleton} r15 — резолвер костей. * @param {object} overrides — per-skin overrides из манифеста. */ constructor(r15, overrides = {}) { this.r15 = r15; this.overrides = overrides || {}; // Аниматор отключён для скинов со сломанным ригом. this.disabled = !!this.overrides.disable_animator; // Кеш: логическое имя кости → { bone, restQuat }. // restQuat — поворот кости в bind-позе (снимаем один раз СЕЙЧАС, // пока ничего не анимировано). this._bones = {}; const resolvedList = []; const missingList = []; let withTNode = 0; for (const logical of Object.values(B)) { const bone = r15.resolveBone(logical); if (bone) { // Если у кости есть привязанный TransformNode (glTF-нода) — // Babylon синхронизирует кость ОТ него. Тогда анимировать // надо TransformNode, а не саму кость. const tNode = bone.getTransformNode ? bone.getTransformNode() : null; if (tNode) withTNode++; // rest-поворот: с tNode берём из него, иначе из кости. let restQ; if (tNode) { restQ = tNode.rotationQuaternion ? tNode.rotationQuaternion.clone() : Quaternion.FromEulerAngles( tNode.rotation.x, tNode.rotation.y, tNode.rotation.z); } else { restQ = bone.getRotationQuaternion(Space.LOCAL); if (!restQ) { restQ = bone.rotationQuaternion ? bone.rotationQuaternion.clone() : Quaternion.Identity(); } else { restQ = restQ.clone(); } } this._bones[logical] = { bone, tNode, restQ }; resolvedList.push(`${logical}→${bone.name}`); } else { missingList.push(logical); } } // eslint-disable-next-line no-console console.log('[R15Animator] костей с TransformNode: ' + withTNode + ' / ' + resolvedList.length); // eslint-disable-next-line no-console console.log('[R15Animator] создан. disabled=' + this.disabled + ' костей зарезолвлено=' + resolvedList.length + ' / нет=' + missingList.length + (missingList.length ? ' (отсутствуют: ' + missingList.join(',') + ')' : '')); // eslint-disable-next-line no-console console.log('[R15Animator] кости: ' + resolvedList.join(' | ')); // Per-skin override: F-pose рук. // Некоторые скины (классический Roblox-стиль, bacon-hair) имеют руки // в bind-позе под ~45° в стороны, а не вертикально. Для них ось // swing'а плеча — не чистый X, а диагональ. Включается флагом // overrides.arm_fpose в манифесте (подбирается на устройстве). // Если флаг есть — собираем модифицированный набор анимаций с // диагональными осями для плеч. if (this.overrides.arm_fpose) { this._anims = buildFPoseAnims(); } else { this._anims = ANIMS_STD; } // Текущее и предыдущее состояние для cross-fade. this._curState = 'idle'; this._prevState = 'idle'; this._blendT = BLEND_TIME; // >= BLEND_TIME → блендинг завершён this._curPhase = 0; this._prevPhase = 0; // Активная эмоция (game.player.playAnimation). null = нет эмоции. // { name, phase, length } — играет поверх авто-состояния, не loop. this._emote = null; // Переиспользуемые временные кватернионы (без аллокаций в кадре). this._tmpA = new Quaternion(); this._tmpB = new Quaternion(); this._tmpOut = new Quaternion(); } /** Сменить состояние (idle/walk/run/jump/fall). Запускает cross-fade. */ setState(state) { if (!this._anims[state]) state = 'idle'; if (state === this._curState) return; this._prevState = this._curState; this._prevPhase = this._curPhase; this._curState = state; this._curPhase = 0; this._blendT = 0; } /** * Проиграть эмоцию (wave/dance/cheer/sit) один раз поверх движения. * Эмоция перебивает авто-анимацию пока играет, потом аниматор * возвращается к idle/walk/... Возвращает true если эмоция найдена. */ playEmote(name) { if (!EMOTE_NAMES.includes(name) || !this._anims[name]) return false; this._emote = { name, phase: 0, length: this._anims[name].length }; return true; } /** * Проиграть кастомный emote, спарсенный из GLB-файла дизайнера. * spec — результат EmoteGlbParser.parseEmoteGlbToSpec(): * { length, loop, perBoneFrames: { logical: { restQ, times, deltas } } } * * При apply: target_bone = rest_bacon × delta. Это корректный retargeting * (так как delta уже относительно rest исходного скелета, а локальные * оси костей одинаково ориентированы в Mixamo и в r15-бекона). */ playCustomEmote(spec) { if (!spec || !spec.perBoneFrames) return false; this._customEmoteSpec = spec; this._emote = { name: '__custom__', phase: 0, length: spec.length || 1.0, loop: !!spec.loop, custom: true, }; return true; } /** Прервать текущую эмоцию досрочно. */ stopEmote() { this._emote = null; this._customEmoteSpec = null; } /** Список доступных эмоций. */ static getEmoteNames() { return EMOTE_NAMES.slice(); } /** * Применить анимацию за кадр. dt — секунды. * Вычисляет позу для curState (и prevState если идёт блендинг), * ставит кватернионы на кости. */ update(dt) { if (this.disabled) return; // === ДИАГНОСТИКА: раз в ~120 вызовов === if (this._dbgCnt === undefined) this._dbgCnt = 0; this._dbgCnt++; if (this._dbgCnt % 120 === 1) { // Снимок: какую позу СЕЙЧАС имеет кость RightUpperLeg // (если анимация работает — её rotationQuaternion меняется). const probe = this._bones.RightUpperLeg || this._bones.UpperTorso; let curQ = '?'; let via = '?'; if (probe) { if (probe.tNode) { via = 'tNode'; const q = probe.tNode.rotationQuaternion; curQ = q ? `(${q.x.toFixed(3)},${q.y.toFixed(3)},${q.z.toFixed(3)},${q.w.toFixed(3)})` : 'null'; } else if (probe.bone) { via = 'bone'; const q = probe.bone.getRotationQuaternion ? probe.bone.getRotationQuaternion(Space.LOCAL) : null; curQ = q ? `(${q.x.toFixed(3)},${q.y.toFixed(3)},${q.z.toFixed(3)},${q.w.toFixed(3)})` : 'null'; } } // eslint-disable-next-line no-console console.log('[R15Animator] update: state=' + this._curState + ' dt=' + dt.toFixed(4) + ' phase=' + this._curPhase.toFixed(3) + ' bones=' + Object.keys(this._bones).length + ' probeBone=' + (probe ? probe.bone.name : 'НЕТ') + ' via=' + via + ' curRotQ=' + curQ); } // === ЭМОЦИЯ === // Если играет эмоция — она перебивает авто-анимацию. Продвигаем её // фазу; когда доиграла (не loop) — снимаем, аниматор вернётся к idle/walk. if (this._emote) { this._emote.phase += dt; const done = this._emote.phase >= this._emote.length; if (done) { if (this._emote.loop) { this._emote.phase = this._emote.phase % this._emote.length; } else { this._emote = null; this._customEmoteSpec = null; } } if (this._emote) { let emPoses; if (this._emote.custom && this._customEmoteSpec) { // Кастомный emote из GLB-файла дизайнера. Каждая кость // имеет свои keyframes (delta-quaternion от rest-исходника). // target_bacon = rest_bacon × delta_interpolated. emPoses = this._collectCustomEmotePose( this._customEmoteSpec, this._emote.phase); } else { const emAnim = this._anims[this._emote.name]; emPoses = {}; this._collectPose(emAnim, this._emote.phase, emPoses, 1); } // Применяем позу эмоции; кости вне её треков — rest. for (const logical in this._bones) { const rec = this._bones[logical]; const target = emPoses[logical] || rec.restQ; if (rec.tNode) { if (!rec.tNode.rotationQuaternion) { rec.tNode.rotationQuaternion = target.clone(); } else { rec.tNode.rotationQuaternion.copyFrom(target); } } else { rec.bone.setRotationQuaternion(target, Space.LOCAL); } } return; // в этот кадр обычную анимацию не считаем } } // Продвигаем фазы. const curAnim = this._anims[this._curState]; this._curPhase += dt; if (curAnim.loop && this._curPhase > curAnim.length) { this._curPhase %= curAnim.length; } else if (this._curPhase > curAnim.length) { this._curPhase = curAnim.length; } let blendAlpha = 1.0; let prevAnim = null; if (this._blendT < BLEND_TIME) { this._blendT += dt; blendAlpha = Math.min(1.0, this._blendT / BLEND_TIME); prevAnim = this._anims[this._prevState]; this._prevPhase += dt; if (prevAnim.loop && this._prevPhase > prevAnim.length) { this._prevPhase %= prevAnim.length; } } // Поза для каждой анимированной кости: сначала собираем target- // повороты из curAnim (и prevAnim при блендинге), затем ставим. // Кости, которых нет в треках анимации, остаются в rest. const poses = {}; // logical → Quaternion (target поворот) this._collectPose(curAnim, this._curPhase, poses, blendAlpha < 1 ? null : 1); if (prevAnim && blendAlpha < 1) { // Блендинг: сначала prev-поза, потом lerp к cur-позе. const prevPoses = {}; this._collectPose(prevAnim, this._prevPhase, prevPoses, null); const curPoses = {}; this._collectPose(curAnim, this._curPhase, curPoses, null); // Объединяем ключи обеих поз. const keys = new Set([ ...Object.keys(prevPoses), ...Object.keys(curPoses), ]); for (const logical of keys) { const rec = this._bones[logical]; if (!rec) continue; const from = prevPoses[logical] || rec.restQ; const to = curPoses[logical] || rec.restQ; Quaternion.SlerpToRef(from, to, blendAlpha, this._tmpOut); poses[logical] = this._tmpOut.clone(); } } // Применяем позы. Кости без позы → rest. // Если у кости есть привязанный TransformNode (glTF-нода) — Babylon // синхронизирует кость ОТ него, поэтому пишем поворот в TransformNode. // Иначе — напрямую в кость через setRotationQuaternion. for (const logical in this._bones) { const rec = this._bones[logical]; const target = poses[logical] || rec.restQ; if (rec.tNode) { if (!rec.tNode.rotationQuaternion) { rec.tNode.rotationQuaternion = target.clone(); } else { rec.tNode.rotationQuaternion.copyFrom(target); } } else { rec.bone.setRotationQuaternion(target, Space.LOCAL); } } } /** * Собрать target-повороты для кастомного emote из GLB. * spec.perBoneFrames[logical] = { restQ (исходного), times, deltas }. * Интерполируем deltas (Slerp) → умножаем на rest_target_skeleton. */ _collectCustomEmotePose(spec, phase) { const out = {}; const pbf = spec.perBoneFrames || {}; for (const logical of Object.keys(pbf)) { const rec = this._bones[logical]; if (!rec) continue; const slot = pbf[logical]; const times = slot.times; const deltas = slot.deltas; if (!times || !deltas || times.length === 0) continue; // Интерполируем delta-кватернионы через Slerp let delta; if (phase <= times[0]) { delta = deltas[0]; } else if (phase >= times[times.length - 1]) { delta = deltas[deltas.length - 1]; } else { let i = 0; while (i < times.length - 1 && phase > times[i + 1]) i++; const t = (phase - times[i]) / (times[i + 1] - times[i]); delta = Quaternion.Slerp(deltas[i], deltas[i + 1], t); } // target = rest_бекона × delta out[logical] = rec.restQ.multiply(delta); } return out; } /** * Собрать target-повороты костей для анимации в фазе phase. * Записывает в out: logical → Quaternion (rest × Δ). */ _collectPose(anim, phase, out, _unused) { for (const track of anim.tracks) { const rec = this._bones[track.bone]; if (!rec) continue; const intensity = lerpKeys(track.times, track.values, phase); const angle = track.angleDeg * DEG * intensity; // Δ = Quaternion вокруг локальной оси кости. Quaternion.RotationAxisToRef(track.axis, angle, this._tmpA); // target = restQ × Δ rec.restQ.multiplyToRef(this._tmpA, this._tmpB); // несколько треков на одну кость складываются (редко, но // run имеет ARM и FOREARM раздельно — разные кости, ок). if (out[track.bone]) { out[track.bone].multiplyInPlace(this._tmpA); } else { out[track.bone] = this._tmpB.clone(); } } } /** Вернуть все кости в bind-позу (при выгрузке/смене скина). */ resetToRest() { for (const logical in this._bones) { const rec = this._bones[logical]; rec.bone.setRotationQuaternion(rec.restQ, Space.LOCAL); } } }