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 (хвосты, волосы, плащи).
Обзор
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
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'. Он анимирует любое дерево узлов, не только скелет:
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()
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предупреждает).
после stop() клипы убранного цикла всё ещё вызывают свои события клипа каждый цикл, и
play() всё ещё отказывает им как членам цикла слоя (предупреждает). Заменяй цикл через
playLoop(next), а не останавливай его, когда важно любое из двух.
One-shot'ы — anim.play()
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()
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 читается как естественный замах;
хосты без этого игнорируют). Объект — единственный хэндл слоя.
События, свойства
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()
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 — инструментарий, из которого строится контроллер локомоции; типы говорят, что
возвращает каждый.
Подводные камни
// ✗ зацикленный 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)