Animator

Скелетная анимация 3D-моделей. Каждая Model несёт заранее прикреплённый Animator в model.anim, его таблица клипов — клипы, встроенные в GLB; любое другое дерево узлов может взять его (node.aspect(Animator)) и играть клипы, нацеленные на его потомков по имени. Движок выполняет покадровую работу (переходы, blend-пространства, передачи, root motion); SDK выдаёт вызовы и отдаёт тебе промисы. Твинить значение — это animate(); 2D-клипы по спрайт-листу — SpriteAnimation.

Модель намеренно мала. На слое два рода вещей:

  • его цикл — то, что он показывает, когда больше ничего не происходит: один зацикленный клип или blend зацикленных клипов (Idle / Walk / Run вдоль оси скорости). anim.playLoop(…) задаёт его (anim.loop читает); задание другого — переход к нему.
  • one-shot'ы — anim.play(…): клип, сыгранный один раз поверх цикла, с возвратом к нему по окончании.

Любой вызов перехватывает то, что показывает слой, через свой собственный fade. Это и есть вся машина состояний. У того, что работает поверх клипов, свои страницы: IK и стопы (конечности на целях, поставленные стопы, шаги), Locomotion (движение персонажа, варп шага) и DynamicBone (хвосты, волосы, плащи).

Обзор

TypeScript
const main = async () => {
  const scene = new Scene()
  const hero = await Model.load(asset('./hero.glb'))          // клипы: Idle, Walk, Run, Jump, Kick, Aim
  scene.add(hero)
  scene.open()

  hero.anim.playLoop('Idle', { fade: 0.2 })                    // на чём он покоится
  await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 })  // one-shot поверх; после — обратно в Idle

  // 1D blend в роли цикла: его value — позиция на оси (здесь: скорость, м/с)
  const gait = hero.anim.playLoop({ Idle: 0, Walk: 1.5, Run: 5 }, { fade: 0.2 })!
  gait.value = 3

  // клипы из других файлов (Mixamo: один GLB на анимацию), производные клипы, события
  const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash')
  hero.anim
    .addClip('slash', slash)
    .addClip('kick', AnimationClip.from(hero.anim.clip('Kick')!, { from: 0.2, to: 1.1 }))
  slash.addEvent(0.4, 'hit')                                    // секунды от начала клипа
  hero.anim.on('hit', (clip) => console.log(`${clip} landed`))

  // цепочка: await разрешается в конце клипа; следующий клип переходит из его последней позы
  if (await hero.anim.play('slash', { fadeOut: 0.15 })) {
    await hero.anim.play('kick', { fadeIn: 0.15, fadeOut: 0.3 })  // потом обратно в цикл за 0.3
  }

  hero.anim.addLayer({ mask: 'Spine1' }).playLoop('Aim', { fade: 0.3 })  // руки целятся, ноги держат походку
  hero.anim.rootMotion = true                                   // клипы с перемещением двигают узел
}
main()

Клипы — AnimationClip

TypeScript
AnimationClip.load(source, clip?: string | number): Promise<AnimationClip>   // один клип из GLB (по умолчанию: первый)
AnimationClip.loadAll(source): Promise<Record<string, AnimationClip>>       // все клипы, по имени
AnimationClip.fromCurves({ duration?, tracks }): AnimationClip               // кривые в коде, привязка по ИМЕНИ узла
AnimationClip.from(clip, { mirror?, from?, to? }): AnimationClip             // ПРОИЗВОДНЫЙ: зеркало, окно (секунды, перетаймлено к 0) или оба
clip.addEvent(seconds, name): this                                           // момент на таймлайне клипа
clip.name / clip.duration / clip.trackCount                                  // имя в источнике, секунды, анимированные треки (узел, канал)

anim.clips                       // AnimationClip[] — клипы GLB в порядке файла, затем добавленные тобой
anim.clip('Run') / anim.clip(0)  // по имени / по индексу в clips (undefined = нет)
anim.addClip('slash', slash)     // добавить под именем (по умолчанию: clip.name) — чейнится; встроенное имя заменяется на месте

Клип — неизменяемые данные, чьи треки нацелены на имена костей; Animator привязывает его к своей иерархии при первом использовании (клип, чьи треки не совпали ни с одним узлом, предупреждает). load принимает путь asset() или ответ fetch и отклоняется, когда клипа нет в файле (сообщение перечисляет клипы). Безымянные встроенные клипы — animation_0, animation_1, …; clip.name — имя в исходном файле; Animator обращается к клипу по имени, под которым ты его добавил.

События принадлежат клипу: addEvent отмечает момент в секундах (с ограничением по клипу) и вызывает anim.on(name, (clipName, layer) => …) в каждом аниматоре, который его играет, включая циклы; clipName — имя, под которым аниматор его играет. Окно (from(clip, { from, to })) сохраняет события внутри себя, перетаймленные, и сохраняет имя источника. mirror: true — клип с другой стороны тела — левое ↔ правое на риге файла самого клипа (суставы спариваются по имени, сагиттальная плоскость берётся из позы покоя), так что результат одинаков на любой модели: его контакты стоп меняются сторонами, его запечённый курс поворачивает в другую сторону, его имя — <name>_M. Остановка с торможением на левой стопе становится той, что тормозит на правой — зарегистрируй её под именем, и это клип как любой другой (anim.addClip('Stop_M', AnimationClip.from(stop, { mirror: true }))). Риг несут только клипы из GLB; клип, построенный из кривых, отзеркалить нельзя (from бросает исключение).

fromCurves строит клип без DCC: tracks сопоставляет имени узла его кривые — ключи position / scale [seconds, [x, y, z]], ключи rotation [seconds, [x, y, z, w]] (кватернион), плюс interpolation: 'linear' | 'step' (по умолчанию linear). duration по умолчанию — последний ключ; name клипа — 'clip'. Он анимирует любое дерево узлов, не только скелет:

TypeScript
const rig = new Node().aspect(Animator)
const box = Mesh.box({ size: 0.2 })
box.name = 'Box'                                               // треки привязываются по имени
rig.add(box)
const bob = AnimationClip.fromCurves({ tracks: { Box: { position: [[0, [0, 0, 0]], [1, [0, 1, 0]]] } } })
rig.anim.addClip('bob', bob).play('bob')                       // box.y 0 → 1 за секунду, потом держит

Циклы — anim.playLoop()

TypeScript
anim.playLoop('Idle', { fade?, speed?, phase? }): Loop | undefined        // один клип (undefined = нечего зацикливать, предупреждение)
anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }): Loop               // 1D blend — числовые позиции
anim.playLoop({ idle: [0, 0], fwd: [0, 1], left: [-1, 0] }): Loop         // 2D blend — позиции [x, y]
anim.playLoop({ Walk: { at: 1.5, offset: 0, cycles: 1 }, Run: { at: 5, offset: 0.5, cycles: 2 } })   // члены со своим циклом походки
anim.loop                       // текущий (undefined = нет) — чтение, не вызов
loop.value = 3.2 | [x, y]       // позиция на оси / плоскости — живая, даже под one-shot'ом
loop.weights                    // { Idle: 0.6, Walking: 0.4, … } — доля каждого члена в позе сейчас
loop.phase                      // 0–1 по циклу походки (0 = левая стопа внизу, 0.5 = правая); можно задавать
  • Члены blend'а зацикливаются в фазе друг с другом (одни общие часы, взвешенная длина цикла — Walk и Run держат стопы выровненными); value выбирает смесь: 1D линейно между двумя соседями (значение ограничено диапазоном), 2D gradient-band (точно в сэмплах, гладко между).
  • Член, заданный как { at, offset, cycles }, несёт свой цикл походки — свою фазу при t = 0 (0 = левая стопа внизу, 0.5 = правая стопа внизу) и сколько циклов держит клип (цикл из двух шагов: 2); anim.clipInfo(clip).cycle() измеряет оба. Голая позиция работает в нормализованном времени, без цикла.
  • fade по умолчанию 0 (cut): playLoop(next, { fade }) переходит от того, что показывает слой — предыдущего цикла или ещё играющего one-shot'а (он обрывается, его промис разрешается в false). speed — темп цикла (по умолчанию 1). phase входит в цикл в этой точке (0–1) или в 'match' — фазе, которую слой показывает сейчас, чтобы походка, перехватывающая стартовый клип, сохранила стопы.
  • stop({ fade }) убирает цикл (и любой one-shot) → слой уходит в fade к покою. Слой без цикла держит последний кадр one-shot'а, когда тот кончается.
  • Объект Loop, возвращённый вызовом, который его задал, — это хэндл; после замены он мёртв (его value предупреждает).
Note

после stop() клипы убранного цикла всё ещё вызывают свои события клипа каждый цикл, и play() всё ещё отказывает им как членам цикла слоя (предупреждает). Заменяй цикл через playLoop(next), а не останавливай его, когда важно любое из двух.

One-shot'ы — anim.play()

TypeScript
const p = anim.play('slash', { fade?, fadeIn?, fadeOut?, speed?, restart?, start?, end?, phase?, turn? })   // → Playback
await p                 // true = дошёл до передачи (своего конца);  false = оборван (заменён / остановлен)
p.done                  // тот же промис — возвращай ЕГО из async-функций, клади в Promise.all
p.clip / p.layer / p.time / p.progress / p.playing / p.weight   // время в секундах, progress 0–1
p.phase / p.turned / p.turnLeft / p.travelled                   // фаза походки 0–1 (-1 = нет), yaw корня пока / ещё впереди (рад, + = влево), пройденный путь корня (м)
p.seek(t) / p.stop({ fade })
anim.busy               // one-shot ещё не передал управление
anim.active             // что каждый слой показывает в этом кадре: [{ name, clip, weight, time, loop }]
anim.time('slash') / anim.seek('slash', t)   // плейхед клипа, секунды (-1 = не привязан) / прокрутить его (в паре со speed = 0)
anim.stop({ fade })     // всё, каждый слой → поза покоя
  • Fade'ы по умолчанию 0 (cut): fade задаёт оба конца, fadeIn / fadeOut — каждый свой. Fade — это инерциализированный переход, не кроссфейд: новый клип показывается сразу, а разница с позой, которую показывал слой (и её скорость), затухает за время fade. Цикл уступает клипу за fadeIn; после последнего кадра клипа он возвращается за fadeOut.
  • Передача. Клип играет до своего конца — ничего не обрезается — и промис разрешается там. То, что ты запускаешь сразу после await, — это то, во что переходит его последняя поза: другой play() (цепочка one-shot'ов), playLoop(next) или ничего (обратно в текущий цикл за fadeOut). Окно отмены (начать следующую атаку до того, как эта восстановилась) — игровая логика: событие клипа, p.progress или просто play(next), когда игра так решит, — никогда не fade.
  • start / end — сыграть окно клипа, в секундах клипа: войти в start (когда проигрывание перематывается — см. restart) и закончить в end: передача срабатывает там, слой без цикла держит этот кадр, события за ним никогда не срабатывают. Замах без восстановления, одно движение из плотного дубля. Окно, которое живёт в таблице клипов, — это AnimationClip.from(clip, { from, to }); у playLoop нет start — его точка входа phase, оконный цикл — производный клип.
  • speed — темп этого клипа (по умолчанию 1); phase входит в клип походки в точке его цикла (0–1 или 'match' — см. циклы; клипы без цикла её игнорируют).
  • turn (градусы, + = влево) — ВАРП ПОВОРОТА: узел поворачивается на столько в сумме за клип вместо того, что говорит запечённый курс клипа (старт на 45°, сыгранный с turn: 60, поворачивает на 60). Нужны rootMotion + rootRotation и клип с запечённым курсом (lecodes assets marks / retarget); клип, поворачивающий меньше чем на 10°, не трогается. anim.setTurn(clip, deg) перенацеливает его во время игры (undefined = выкл).
  • Новый play() всегда перехватывает; чтобы не перезапускать атаку на середине замаха, защитись через busy: if (!anim.busy) anim.play('slash').
  • restart → перемотать, даже если клип уже играет (по умолчанию: перематывать, только если не играет; клип, который уже передал управление, перематывается всегда).
  • Клип, который является членом цикла слоя, не может быть one-shot'ом (предупреждает) — используй playLoop(). Неизвестное имя предупреждает и возвращает Playback, разрешающийся в false.
  • Возврат Playback из async-функции сплющивает его в Promise<boolean>; возвращай p.done или сделай функцию синхронной, когда вызывающему нужен хэндл.

Слои — anim.addLayer()

TypeScript
const upper = anim.addLayer({ mask?: 'Spine1' | ['LeftArm', 'RightArm'], additive?: boolean, weight?: number })
upper.playLoop(…) / upper.play(…) / upper.stop(…) / upper.loop / upper.busy / upper.active / upper.phase   // те же глаголы, в своей области
upper.weight = 0.5              // вклад 0–1 (по умолчанию 1)
upper.anticipate(0.5)           // СЛЕДУЮЩИЙ play / playLoop на этом слое начнётся с замаха

Собственные playLoop/play/stop Animator'а и есть его базовый слой; слои накладываются над ним в порядке создания. mask ограничивает слой поддеревьями названных костей (переопредели верх тела, пока ноги держат blend; имя, не найденное под моделью, предупреждает). additive: true смешивает дельту каждого клипа от его собственного первого кадра поверх слоёв ниже (отдача, дыхание, наклон — делай кадр 0 нейтральным). Слой входит в fade на своём первом переходе и, остановленный, уходит в fade к слоям ниже. anticipate(amount) заставляет следующий переход начаться с движением каждого сустава против движения нового клипа (amount × его скорость; 0.3–1 читается как естественный замах; хосты без этого игнорируют). Объект — единственный хэндл слоя.

События, свойства

TypeScript
anim.on('hit', (clip: string, layer: Layer) => …) / anim.off(…)   // события клипа (clip.addEvent) — чейнится
anim.speed = 0.3                  // глобальный темп: slow-mo; 0 = пауза
anim.rootMotion = true            // см. ниже
anim.rootRotation = true          // с rootMotion: поворот корня тоже двигает узел
anim.phase                        // где базовый слой в цикле походки, 0–1 (-1 = нет цикла)
anim.motion = velocity            // мировая скорость тела (м/с), для варпа шага — см. Locomotion
anim.lod = 'full'                 // 'auto' (по умолчанию) | 'full' — см. Model, уровень детализации
anim.sockets(bone, { … })         // именованные фреймы на кости, для anchored IK — см. IK и стопы

Root motion. С rootMotion = true горизонтальное перемещение корневой кости снимается с позы (персонаж остаётся на месте в своём собственном фрейме) и вместо этого двигает узел — или, когда узел или предок имеет CharacterController, подаётся ему как скорость, чтобы перемещение сталкивалось и скользило как любое движение. Вертикальная часть остаётся на кости (высота прыжка — из клипа). Для клипов, чьи бёдра действительно перемещаются: Mixamo без «In Place», выпады, перекаты; локомоция на месте ничего не вносит, так что оба вида сочетаются в одном аниматоре. rootRotation (по умолчанию выкл — покачивание бёдер при ходьбе тоже поворот) делает yaw корня тоже root motion, так что клип поворота оставляет узел смотрящим туда, куда его увёл; это для ригов, чей корень несёт курс (lecodes assets retarget --root-rotation yaw).

Измерение клипов — anim.clipInfo()

TypeScript
const info = anim.clipInfo('Walk')!     // undefined = нет такого клипа; привязывает его при первом запросе
info.travel / info.speed / info.turn    // путь корня (м), средняя скорость корня (м/с), суммарный yaw корня (рад, + = влево)
info.inPlace / info.cyclic / info.hasPhase
info.contacts                           // [{ side: 'left' | 'right', from, to, at: [x, y, z], still? }] — секунды, пространство модели
info.cycle()                            // { offset, cycles, residual, steps } — то, чего хочет { offset, cycles } члена blend'а
info.phaseAt(t) / info.travelAt(t) / info.turnAt(t) / info.speedAt(t) / info.directionAt(t)   // запечённые кривые во времени клипа t (с)

То, что движок измерил на клипе, привязанном к этому скелету (в отличие от anim.clip(), который — данные файла): путь и поворот корня, контакты стоп (из меток клипа или классифицированные по движению), фаза походки. Остальное в ClipInfo — hands, treads, pelvisYawAt, timeAtTurn, fitTo / exitTo / alignTo (как далеко поза одного клипа от позы другого), kneePoleAt, physicsAt, featuresAt — инструментарий, из которого строится контроллер локомоции; типы говорят, что возвращает каждый.

Подводные камни

TypeScript
// ✗ зацикленный one-shot
anim.play('Run', { loop: true })            // такой опции нет
// ✓ anim.playLoop('Run')

// ✗ короче fadeOut, чтобы пропустить восстановление клипа — fadeOut это переход ПОСЛЕ конца, он ничего не обрезает
anim.play('slash', { fadeOut: 0.05 })
// ✓ anim.play('slash', { end: 0.6, fadeOut: 0.2 })  — или AnimationClip.from(slash, { to: 0.6 }) в таблице клипов

// ✗ нормализованное время событий
clip.addEvent(0.4, 'hit')                   // это СЕКУНДЫ
// ✓ clip.addEvent(0.4 * clip.duration, 'hit') для «40 % от начала»

// ✗ управление заменённым циклом
const a = anim.playLoop('Idle'); anim.playLoop('Crouch'); a.value = 1   // a мёртв (предупреждает; anim.loop — живой)

Заметки по платформам

Один вычислитель (creator-anim) работает везде, где работает 3D-движок: нативно на десктопе, мобильных и безоконном (--sim) хостах, в своей wasm-сборке под headless-рендерером (lecodes render / lecodes test без флага хоста). В Web Lite нет 3D.

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

  • Model — загрузка, model.anim, уровень детализации (model.lod, anim.lod)
  • IK и стопы — двухкостные / look-at цепи, сокеты, фиксация стоп, шаги
  • Locomotion — движение персонажа; варп шага и ориентации
  • DynamicBone — вторичное движение на цепочках костей
  • Node — иерархия, node.bone(), аспекты
  • Физика и формы — CharacterController (коллайдер root motion)