IK и стопы

Поправки, которые движок вносит в анимированную позу. IK-цепи — аспекты на костях: IK.TwoBone ставит конец конечности на цель (руку на рукоять, стопу на ступеньку), IK.LookAt поворачивает кость к цели (голову, турель). Стопы — стадия каждого Animator, anim.feet: какие кости — стопы, фиксация стопы, IK по земле и события шагов. В JS ничего не работает покадрово — аспекты описывают, движок решает.

Где они работают в кадре: updateBefore() → физика → аниматоры (клипы, root motion, варп, стопы) → update() → IK-цепи → динамические кости → скиннинг. Цель, которую игра двигает в update(), достигается в том же кадре; кость цепи, прочитанная в update(), всё ещё показывает позу до решения (читай её в updateBefore(), который видит финальную позу предыдущего кадра).

Обзор

TypeScript
const main = async () => {
  const scene = new Scene()
  const hero = await Model.load(asset('./hero.glb'))
  scene.add(hero)
  scene.open()
  hero.anim.playLoop('Idle')                                     // IK поправляет позу: что-то должно играть

  const grip = new Node()                                        // узел: движок читает его каждый кадр
  grip.position = [0.3, 1.2, 0.4]
  scene.add(grip)
  const hand = hero.bone('LeftHand')!.aspect(IK.TwoBone, { target: grip, pole: [0.6, 1.2, -1] })   // LeftArm ▸ LeftForeArm ▸ LeftHand
  hero.bone('Head')!.aspect(IK.LookAt, { target: scene.camera, axis: [0, 0, 1], limit: 60 })

  hand.ik.weight = 0.5                                           // плавно вмешать / вымешать
  console.log(`hand off its target: ${hand.ik.error.toFixed(3)} m`)

  hero.anim.feet.set({ lock: true, ik: true })                   // поставленные стопы стоят на месте; стопы на земле
  hero.anim.feet.onStep((side, at) => console.log(`${side} foot down at ${at.x.toFixed(2)}, ${at.z.toFixed(2)}`))
}
main()
Note

на загруженной Model, чей Animator ещё не использовал ни одного клипа (ничего не играло и не измерялось), прикрепление цепи схлопывает весь скелет в начало координат модели — даже при weight: 0. Как только клип привязан (сыгран — остановлен с тех пор или нет — или прочитан через anim.time() / anim.clipInfo()), цепи работают. Сначала запусти цикл. Голое дерево узлов не затронуто.

Двухкостный IK — IK.TwoBone

TypeScript
bone.aspect(IK.TwoBone, { target?, pole?, rotation?, rotationWeight?, weight?, enabled?, anchor?, anchorTime?, anchorPin? })
bone.ik                     // аспект — каждая опция это живое свойство
bone.ik.error               // после последнего решения: метры, на которые конечная кость всё ещё не дотянулась до цели (0 = достигнута)
Опция По умолчанию Значение
target — Куда идёт начало конечной кости: Node (движок читает его каждый кадр) или мировая точка
pole анимированный изгиб Подсказка изгиба — средний сустав тянется к ней: Node или мировая точка
rotation анимированный Мировой поворот, который принимает конечная кость: Node (его мировой поворот) или Quat — рука на рукояти, стопа плашмя на склоне
rotationWeight 1 Доля rotation, поверх weight
weight 1 0–1 смешивание всего решения
enabled true false = нет решения
anchor, anchorTime, anchorPin '', 0.05, 0 Ехать на сокете вместо цели — ниже

Прикрепляй к КОНЕЧНОЙ кости: цепь — это она, её родитель и её прародитель (плечо ▸ предплечье ▸ кисть). Прародитель и родитель поворачиваются так, чтобы начало конечной кости достигло target; цель вне досягаемости вытягивает конечность к ней, а error говорит, насколько она не дотянулась. Цепь принадлежит ригу кости — ближайшему предку, несущему Animator (каждая Model); голое дерево узлов получает Animator на своём верхнем узле. Отсоединение аспекта убирает цепь.

Look-at — IK.LookAt

TypeScript
bone.aspect(IK.LookAt, { target?, axis?, limit?, weight?, enabled? })
bone.lookAt                 // аспект (weight / enabled / error, как выше)
Опция По умолчанию Значение
target — Node (читается каждый кадр) или мировая точка
axis [0, 0, -1] ЛОКАЛЬНОЕ направление кости, которое должно смотреть на цель — риги различаются (многие головы Mixamo смотрят вдоль +Z)
limit 80 Максимальное отклонение от анимированного направления, градусы
weight 1 0–1 смешивание
enabled true false = нет решения

Прикрепляй к самой кости. Цель за пределами limit поворачивает кость на limit градусов к ней и не дальше.

Note

аксессор — lookAt, так что на этой кости он заменяет собственный метод узла lookAt(). Сценарный аспект LookAt (аксессор facing) — другая вещь: он поворачивает целый узел, в JS.

Сокеты и привязанные цепи

TypeScript
anim.sockets(bone: Node | string, { [name]: Node | { position?, quaternion? } }, { set? }): Animator
anim.removeSocket(name, set?)
hand.aspect(IK.TwoBone, { anchor: 'grip_l', anchorTime?, anchorPin? })
clip.anchors(bone: string, { donor?, spans?: [from, to, socket, rotation?, pin?][] }): AnimationClip   // секунды
clip.clearAnchors(bone)
hand.ik.anchorDelta         // { distance (м), angle (рад) } — насколько живой сокет сдвинул руку относительно дубля

Для рук на оружии. Сокет — именованный фрейм на кости рига: Node (пустышка на оружии — читается каждый кадр, так что сокет на движущейся части следует за ней) или фиксированный { position, quaternion } в пространстве кости. Набор по умолчанию — ЖИВОЙ (оружие в руках); { set: 'tr15' } именует набор ДОНОРА — те же сокеты на оружии, на котором были созданы дубли. Привязанная цепь (anchor: 'grip_l' — target, pole и rotation тогда игнорируются) сохраняет позу анимированной руки относительно её сокета на доноре и воспроизводит её относительно живого: сдвинутую и повёрнутую на разницу двух сокетов, никогда не переделанную. rotationWeight — доля поворота сокета, которую она берёт.

Якорные отрезки клипа перемещают руку между сокетами, пока он играет: [from, to, socket] в секундах клипа ('' = свободна: рука едет на своём собственном суставе), rotation 0–1 (по умолчанию 1), pin 0–1 (по умолчанию 0 — рука на пути к магазину сохраняет собственное движение дубля). Между отрезками рука на собственном сокете цепи, удерживаемая там через anchorPin (0–1: 1 = точно на сокете, так что ничто в позе ниже не может её сдвинуть). Рука движется между сокетами на пружине с полупериодом anchorTime секунд. Клип без якорей не участвует; AnimationClip.from переносит отрезки, перетаймленные (без их pin, который откатывается к 0), а зеркальный клип сохраняет их на той же кости.

TypeScript
arms.anim.sockets('ik_hand_gun', { grip_r: gun.gripR, grip_l: gun.gripL, mag: gun.mag })
arms.anim.sockets('ik_hand_gun', { grip_l: { position: [0.03, -0.12, -0.05] } }, { set: 'tr15' })
arms.bone('hand_l')!.aspect(IK.TwoBone, { anchor: 'grip_l' })
arms.anim.clip('Reload')!.anchors('hand_l', { donor: 'tr15', spans: [[0.9, 1.4, 'mag'], [1.4, 2.0, ''], [2.0, 2.4, 'mag']] })

Стопы — anim.feet

TypeScript
anim.feet.set({ lock?, ik?, left?, right?, pelvis?, … })   // меняются только переданные ключи — чейнится
anim.feet.lock = true / anim.feet.ik = true / …             // каждая опция это ещё и свойство
anim.feet.options                                           // что было задано
anim.feet.state('left')     // FootState | undefined — { locked, weight, anchor: Vec3, target: Vec3 }, мировые
anim.feet.kneeAxis('left')  // откалиброванная ось шарнира колена (в локале бедра) + её разброс (рад); undefined = нет цепи ноги
anim.feet.onStep((side, position) => …) / anim.feet.offStep(cb)   // стопа поставлена: 'left' | 'right', мировой Vec3
Опция По умолчанию Значение
lock false ФИКСАЦИЯ СТОПЫ: стопа, которую показываемый клип называет поставленной, остаётся там, где приземлилась — от пятки к носку, перекатываясь, как перекатывает клип — нога пересчитывается, пока тело движется дальше
ik false IK ПО ЗЕМЛЕ: каждая стопа на земле, прощупанной под ней (лестница, склон, бордюр), выровнена по её нормали, таз опущен, чтобы нога дотянулась. Щупает физический мир; без него земля — собственная плоскость узла
left, right классифицируются Кости контакта с каждой стороны — 'LeftFoot' или ['LeftFoot', 'LeftToeBase']. По умолчанию: находятся по имени (Mixamo / Unity / Blender / UE). Их задание перезапекает контакты каждого клипа
pelvis 0.35 Метры, на которые таз может опуститься ради земли
unlockDistance 0.10 Метры разницы, которые зафиксированная стопа поглощает максимум — дальше якорь следует за анимацией вместо борьбы с ней
lockIn, lockOut 0.08, 0.12 Плавность входа / выхода фиксации, секунды
align 1 0–1: насколько стопа наклоняется на нормаль земли
probe 0.6 Дальность луча-щупа выше и ниже лодыжки, метры
plantSpeed 0.2 Фиксация ставит стопу, только когда анимированная лодыжка медленнее этого, м/с

onStep срабатывает для того, что показывает БАЗОВЫЙ слой, на каждом контакте клипа — метках контакта, которые пайплайн ассетов записывает в GLB (lecodes assets marks), или контактах, обнаруженных по движению, когда их нет; клип без контактов ничего не вызывает. state() — undefined, пока ничего не играло, и на хостах без стадии стоп. Locomotion задаёт стопы сам (фиксация включена по умолчанию).

Смотрите также

  • Animator — клипы, циклы, one-shot'ы, слои
  • Locomotion — движение персонажа, варп шага и ориентации
  • DynamicBone — вторичное движение, решается после цепей
  • Аспекты — update / updateBefore, порядок