Ragdoll

Скелет модели, отданный физике: одно капсульное тело на каждую перечисленную кость, соединённое с родительской частью swing-twist суставом (конус плюс диапазон кручения, или одноосный шарнир для коленей и локтей), который несёт мотор. Включается для падения — смерти, нокдауна, взрыва — и выключается, чтобы встать; с питанием он также делает реакции на удар у персонажа, который остаётся в своей анимации. Аспект на скиннутой Model, построенный на 3D-физике.

Обзор

TypeScript
Physics.configure({ gravity: [0, -9.81, 0] })
const scene = new Scene()
scene.add(Mesh.box({ material: Material.lit({ color: '#6b8f5a' }), size: [40, 1, 40], position: [0, -0.5, 0] })
  .aspect(Shape, {}).aspect(Physics, { motion: 'static' }))
scene.open()

let rag: Ragdoll | null = null
Model.load(asset('./hero.glb')).then((hero) => {
  scene.add(hero)
  rag = hero.aspect(Ragdoll, { mass: 75 }).ragdoll          // 'humanoid': части найдены по имени кости
  rag.activate({ velocity: [0, 0, -2] })                    // падает из позы, в которой находится, отброшенный назад на 2 м/с
})

class Fallen extends System<'fallen'> {
  private told = false
  update() {
    if (this.told || !rag?.settled) return                  // каждая часть в покое — заморожена там, где лежит
    this.told = true
    console.log(`down: ${rag.facing}, head toward ${rag.heading.toFixed(0)}°, hips at y ${rag.root!.worldPosition.y.toFixed(2)}`)
  }
}
scene.system(Fallen)

Ragdoll строится один раз, при прикреплении, из позы, в которой находится модель — эта поза является нейтральной для ограничений суставов. activate() роняет тело из позы, которую оно показывает сейчас, каждая кость сохраняет движение, которое имела за последние два кадра (бегун продолжает двигаться, махнувшая рука продолжает мах); velocity — запуск, добавленный сверху.

Части

TypeScript
node.aspect(Ragdoll, { parts: 'humanoid' })                 // по умолчанию
node.aspect(Ragdoll, { parts: [
  { bone: 'Hips', to: 'Spine', radius: 0.12, mass: 15 },
  { bone: 'Spine', to: 'Head', radius: 0.11, mass: 25, swing: 25 },          // родитель: предыдущая часть
  { bone: 'Head', radius: 0.1, length: 0.2, mass: 6 },                       // лист: 0.2 м дальше по линии родителя
  { bone: 'LeftUpLeg', to: 'LeftLeg', parent: 'Hips', radius: 0.08, mass: 10 },
  { bone: 'LeftLeg', to: 'LeftFoot', radius: 0.06, mass: 5, hinge: { axis: [1, 0, 0], min: 0, max: 140 } },
] })

interface RagdollPart {
  bone: string          // кость, на которой сидит тело — её начало является суставом
  to?: string           // кость, на которую указывает капсула; опущено = лист длиной `length`
  parent?: string       // часть, с которой она свисает; по умолчанию предыдущая часть (у корня нет)
  radius?: number       // м, по умолчанию 0.05
  length?: number       // м — длина листа, или переопределение расстояния до `to`
  mass?: number         // ДОЛЯ этой части в массе ragdoll (относительная), по умолчанию 1
  swing?: number        // полуугол конуса, градусы, по умолчанию 30 (игнорируется с `hinge`)
  twist?: number        // полуугол кручения, градусы, по умолчанию 20
  hinge?: RagdollHinge  // { axis: Vec3Like, min, max } — одноосный сустав: ось в пространстве покоя МОДЕЛИ, градусы
}

'humanoid' находит стандартные кости по имени — сначала имена в стиле Mixamo, MotusMan и Unity, затем в стиле Unreal (pelvis, thigh_l, …), с игнорированием префикса вида mixamorig: — и раскладывает человека ~1.8 м: бёдра, одна капсула позвоночника до шеи, голова, двухсегментные руки и ноги, кисти и стопы там, где они есть в риге, колени и локти как шарниры, которые сгибаются естественным образом для модели, стоящей в T- или A-позе лицом к +Z. Риг без кости бёдер бросает исключение; назови части сам. Список перечисляет родителей раньше детей, а неизвестная кость бросает исключение при прикреплении.

Опции

TypeScript
node.aspect(Ragdoll, {
  mass?: number             // кг, по умолчанию 70 — делится между частями по их долям `mass`
  friction?: number         // каждая часть, по умолчанию 0.6
  linearDamping?: number    // по умолчанию 0 = собственные 0.05 Jolt
  angularDamping?: number   // по умолчанию 0 = собственные 0.05 Jolt; больше — быстрее успокаивает болтающееся тело
  collide?: 'static' | 'all'   // по умолчанию 'static' (ниже)
  freeze?: boolean          // по умолчанию true: обмякшее тело в покое становится статичным там, где лежит
  freezeAfter?: number      // с обмякшим до заморозки в любом случае; по умолчанию 0 = без предела
  driveFrequency?: number   // Гц, пружина моторов, по умолчанию 6
  driveDamping?: number     // коэффициент, по умолчанию 1 (критический)
  driveTorque?: number      // Н·м на кг части при силе 1, по умолчанию 12
  jointFriction?: number    // Н·м на кг части, по умолчанию 0.3 — обмякшее тело складывается, а не шлёпается
  limits?: 'clips' | 'table'   // по умолчанию 'clips' (ниже)
  limitsMargin?: number     // градусы, добавляемые с каждой стороны выученного диапазона, по умолчанию 10
})

Всё здесь читается один раз, при прикреплении — переконфигурация живого ragdoll не меняет ничего, кроме strength / anchored; убери аспект и прикрепи снова, чтобы перестроить.

  • collide: 'static' — упавшее тело — декорация, не препятствие: его останавливают только статичный мир и другие такие же ragdoll'ы; динамические тела, контроллеры персонажей и транспорт проходят сквозь, так что оно никогда ничего не толкает и не блокирует. 'all' — обычное динамическое тело, которое врезается во всё (и будится всем).
  • freeze — как только каждая часть обмякшего тела пришла в покой (или через freezeAfter секунд обмякания), части становятся статичными там, где лежат: поза держится, ничто не может их разбудить, решатель их пропускает; settled читается как true. Управляемое или заякоренное тело никогда не замораживается. activate() снова делает его динамическим.
  • limits: 'clips' — ограничения суставов выучиваются из анимации: при первой активации, которая находит клипы на аниматоре модели, диапазон swing и twist каждого сустава по всем привязанным клипам (+ limitsMargin) становится его ограничением; до этого, и с 'table', применяются конусы и шарниры частей.

Падение и чтение тела

TypeScript
rag.activate(opts?: RagdollActivateOptions): boolean   // { velocity?: Vec3Like (запуск, м/с), strength?, anchored? }
rag.active                  // физика владеет костями
rag.settled                 // активен, и каждая часть спит или заморожена
rag.root                    // узел кости корневой части (бёдра): где находится тело
rag.facing                  // RagdollFacing: 'up' (на спине) | 'down' (ничком) | 'side'
rag.heading                 // градусы: yaw, смотрящий туда, куда указывает голова — для дубля вставания
rag.bones                   // имена костей, несущих часть, по порядку
rag.bodyOf(bone): number    // id твёрдого тела части (0 = нет)
rag.impulse(bone, v, at?): boolean   // Н·с на одну часть, в мировой точке или через её центр

activate() возвращает false, когда нечего активировать (см. зависимость от хоста). Пока активен, движок записывает позы тел в кости каждый кадр после аниматора, так что перечисленные кости скрывают всё, что играет под ними; кости без части (пальцы рук и ног, кости позвоночника между двумя частями) следуют анимации, пока тело управляется, и держат свою позу, когда оно обмякает. Всё, что смонтировано на кости, едет вместе с ней. Physics.raycast попадает в части и сообщает узел кости, так что выстрел знает, в какую конечность попал. impulse и hit принимают имя части, как оно перечислено в bones (имя из таблицы гуманоида, например 'Head', даже на риге с префиксом mixamorig:).

CharacterController персонажа — тебе выключать для падения (node.removeAspect(CharacterController)): оставленная стоять капсула не даст телу успокоиться.

С питанием: реакции на удар и обмякание

TypeScript
rag.strength = 1            // 0..1: как сильно каждый сустав тянется к позе, которую показывает аниматор
rag.anchored = true         // бёдра кинематически следуют анимации (пошатывание, не падение)
rag.hit(bone, v, at?, opts?: RagdollHitOptions): boolean   // { duration?, blend?, strength? }
rag.goLimp(seconds = 0.4)   // суставы сдаются: strength плавно уходит в 0, якорь снимается

hit — вздрагивание одним вызовом: неактивное тело включает питание, заякоренное к своей анимации (с strength, по умолчанию 1), ударенная часть получает импульс (Н·с, в мировой точке), и через duration (с, по умолчанию 0.6) кости возвращаются к аниматору через blend (с, по умолчанию 0.25). На обмякшем активном теле (труп) это просто импульс. strength и anchored сохраняются между активациями. goLimp — смерть, которая не выключает: тело, подстреленное на ходу, на мгновение сохраняет позу и затем оседает.

Вставание

TypeScript
rag.deactivate(opts?: RagdollDeactivateOptions)   // { blend?: с, за которые аниматор выходит из упавшей позы; по умолчанию 0 = cut }

Поза аниматора снова показывается со следующего кадра — через blend, если попросить: сыграй дубль вставания сразу после, с его собственным fade, и он начнётся оттуда, где лежит тело (facing выбирает дубль, heading — его направление). Переставь узел модели под бёдра до этого вызова: кости возвращаются относительно его нового трансформа.

Зависимость от хоста

Нужна 3D-физика — защищайся через Physics.supported. Headless-рендерер отказывает частям: аспект прикрепляется, activate() возвращает false, и тело никогда не падает. Проверяй ragdoll через lecodes render --sim или --desktop (physics.md).

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

  • Физика — тела, рейкасты, CharacterController
  • Model, Анимация — скелет и клипы, за которыми следует тело с питанием