3D-ассеты через агента Арматура, скиннинг и анимация: как ассет начинает двигаться
0%

Арматура, скиннинг и анимация: как ассет начинает двигаться

Арматура, скиннинг и анимация: как ассет начинает двигаться

Разбор файла из главы «Экспорт в GLB» печатал две строки, на которые мы тогда не смотрели:

клипы:     []
кости:     0

Пока модель стоит на полке, это нормально. Как только она попадает в игру, возникает вопрос: чем она шевелится. И тут же — второй, который задают реже, а стоит задавать первым: должна ли она вообще шевелиться средствами файла.

Три способа заставить объект двигаться

Способ Что лежит в файле Что делает рантайм Цена
Трансформации в коде ничего сам двигает, вращает, масштабирует объект нулевая; движется объект целиком
Скелетная анимация кости, веса, клипы считает позу и деформирует поверхность вес файла, расчёт скиннинга на кадр
Морфы (ключи формы) несколько состояний геометрии смешивает их коэффициентами вес растёт с числом состояний

Правило выбора простое и почти всегда игнорируемое: скелет нужен тогда, когда деформируется сама поверхность. Если объект движется как целое — едет, вращается, подпрыгивает, — это работа кода рантайма, и она стоит ноль байт.

В игре из главы «Живой кейс» фигура перемещается по клеткам доски прыжком. Соблазн — записать прыжок клипом в файле. Решение — считать прыжок кодом: интерполяция позиции по дуге. Причин три, и все практические.

  • Прыжок обязан совпадать с логикой хода: длина зависит от того, через сколько фигур перескакивают. Клип фиксированной длительности этого не умеет.
  • Отменённый ход должен откатываться мгновенно. Анимацию, доигрывающую в никуда, придётся гасить руками.
  • Ноль байт против клипа, костей и весов на каждой из пятидесяти фигур.

Скелет понадобился бы, если бы у робота гнулись руки или менялось выражение «лица». Он не гнётся — значит, платить не за что.

Обобщение: прежде чем добавлять механизм в артефакт, спросите, не решает ли задачу то, что уже есть на другой стороне границы. Данные, которые можно вычислить, дешевле данных, которые надо хранить, версионировать и грузить.

Дальше — про случай, когда скелет всё-таки нужен.

Арматура кодом

Кости создаются в режиме правки арматуры и только там:

import bpy, math

arm_data = bpy.data.armatures.new("RobotRig")
arm_obj = bpy.data.objects.new("RobotRig", arm_data)
bpy.context.collection.objects.link(arm_obj)
bpy.context.view_layer.objects.active = arm_obj

bpy.ops.object.mode_set(mode='EDIT')
root = arm_data.edit_bones.new("root")
root.head, root.tail = (0, 0, 0), (0, 0, 0.2)

body = arm_data.edit_bones.new("body")
body.head, body.tail = (0, 0, 0.2), (0, 0, 1.1)
body.parent, body.use_connect = root, True

head = arm_data.edit_bones.new("head")
head.head, head.tail = (0, 0, 1.1), (0, 0, 1.6)
head.parent, head.use_connect = body, True
bpy.ops.object.mode_set(mode='OBJECT')

head и tail — начало и конец кости, use_connect приклеивает начало дочерней к концу родительской. Иерархия костей и есть скелет: поворот body тащит за собой head, обратное неверно.

Имена костей — часть контракта, ровно как имена мешей и материалов из главы про экспорт. Рантайм ищет кость по имени, когда надо прицепить к ней предмет или камеру; переименование head в Head ломает игру, не меняя ни одной вершины.

Отсюда полезный приём: заводите кости-держатели — короткие кости без геометрии в местах крепления (socket_hand_r, socket_back). Они ничего не деформируют и стоят почти ноль, зато у рантайма появляется официальная точка подвеса вместо магических координат в коде.

Скиннинг: почему автоматика здесь хуже правила

Каноническая привязка меша к скелету — автоматические веса:

bpy.ops.object.select_all(action='DESELECT')
mesh_obj.select_set(True)
bpy.context.view_layer.objects.active = arm_obj
bpy.ops.object.parent_set(type='ARMATURE_AUTO')

Blender считает влияние костей растеканием по поверхности: чем ближе вершина к кости, тем сильнее та на неё влияет, и влияния соседних костей плавно смешиваются. Для органики это то, что нужно: кожа у локтя обязана тянуться непрерывно.

Для робота из твёрдых деталей это ровно то, что не нужно. Кожух головы не должен «немного» слушаться кости тела — иначе на повороте он поедет и растянется, а пластик, который тянется, выглядит как ошибка, потому что ошибка и есть.

Здесь агент выигрывает у мыши тем же, чем во всём этом треке: правило выражается кодом.

GROUPS = {
    "head": lambda v: v.co.z > 1.10,
    "body": lambda v: 0.20 <= v.co.z <= 1.10,
    "root": lambda v: v.co.z < 0.20,
}

mesh_obj.parent = arm_obj
mod = mesh_obj.modifiers.new("Armature", 'ARMATURE')
mod.object = arm_obj

for bone_name, belongs in GROUPS.items():
    vg = mesh_obj.vertex_groups.new(name=bone_name)          # имя = имя кости
    vg.add([v.index for v in mesh_obj.data.vertices if belongs(v)], 1.0, 'REPLACE')

Вес 1,0 и никакого смешивания: каждая вершина принадлежит ровно одной кости. Такой скиннинг называют жёстким, и для техники он не компромисс, а правильный выбор — деталь движется как деталь.

Две ловушки, обе тихие.

Имя группы вершин обязано совпадать с именем кости. Не совпало — группа просто не участвует, ошибки нет, модель молча не двигается. Проверяется одной строкой:

assert {vg.name for vg in mesh_obj.vertex_groups} <= {b.name for b in arm_data.bones}

Вершина, не попавшая ни в одну группу, не двигается вообще. При разбиении по координате легко оставить щель на границе — и кусок меша останется висеть в воздухе, когда всё остальное поехало. Проверка та же по духу: сумма размеров групп должна равняться числу вершин.

Клипы

Поза выставляется на костях в режиме позы, ключи ставятся на свойствах кости:

import mathutils

bpy.context.view_layer.objects.active = arm_obj
bpy.ops.object.mode_set(mode='POSE')

pb = arm_obj.pose.bones["head"]
pb.rotation_mode = 'QUATERNION'

for frame, angle in ((1, 0), (12, 12), (24, 0)):
    pb.rotation_quaternion = mathutils.Quaternion((0, 0, 1), math.radians(angle))
    pb.keyframe_insert("rotation_quaternion", frame=frame)

action = arm_obj.animation_data.action
action.name = "idle"
action.use_fake_user = True     # иначе действие без пользователя пропадёт при перезагрузке
bpy.ops.object.mode_set(mode='OBJECT')

rotation_mode = 'QUATERNION' — не вкусовщина. glTF хранит повороты кватернионами, и углы Эйлера всё равно будут в них переведены; кроме того, у Эйлера есть шарнирный замок, из-за которого поворот на середине интерполяции уезжает не туда, где вы его оставили.

use_fake_user = True — из разряда «узнаёшь один раз». Действие, на которое никто не ссылается, Blender считает мусором и не сохраняет. Клип, который вы сделали и отложили, исчезает при следующей загрузке файла — без единого сообщения.

Имя действия становится именем клипа в GLB, а имя клипа — это контракт с рантаймом. Переименование idle — ломающее изменение, даже если ни одна вершина не поменялась. Об этом же — конец главы «Экспорт в GLB».

Ограничители не экспортируются

В главе «Материалы, свет и рендер» камера наводилась ограничителем TRACK_TO — и это было правильное решение для съёмки в Blender. Для экспорта оно не годится: glTF не знает ограничителей и обратной кинематики. В файле лежат только ключи трансформаций.

Значит, всё, что двигается ограничителями, надо перед экспортом запечь в обычные ключи:

bpy.ops.nla.bake(frame_start=1, frame_end=24, only_selected=False,
                 visual_keying=True, clear_constraints=True, bake_types={'POSE'})

visual_keying=True записывает итоговое положение — то, которое получилось после работы ограничителей, а не то, что записано в свойствах кости. clear_constraints=True снимает ограничители после запекания: они уже сделали своё дело, а оставленные — источник расхождения между тем, что видно в Blender, и тем, что уехало в файл.

Симптом пропущенного запекания узнаваемый: в Blender анимация играет, в движке модель стоит столбом или дёргается по прямой. Никакой ошибки при этом никто не показывает — привет из главы «Как агент смотрит на свою работу», где success уже один раз ничего не значил.

Вес анимации и откуда он берётся

bpy.ops.export_scene.gltf(
    filepath="/абсолютный/путь/robot.glb",
    export_format='GLB',
    export_apply=True,
    export_animations=True,
    export_animation_mode='ACTIONS',   # каждое действие — отдельный клип
)

Проверяем тем же разбором файла, что в главе про экспорт:

меши:       ['Robot']
материалы:  ['Robot_Shell', 'Robot_Visor', 'Robot_Gold', …]
клипы:      ['idle', 'wave']
кости:      9
треугольники: 8596

Теперь строки клипы и кости наконец не пустые — и это ровно та проверка, которой не хватало генератору моделей по картинке из главы про кейс: он отдавал геометрию, у которой обе строки нулевые навсегда.

Про вес стоит знать заранее. glTF не умеет кривых Безье: в формате есть только ступенчатая, линейная и кубическая интерполяция. Поэтому экспортёр пересчитывает анимацию покадрово — три поставленных вами ключа превращаются в двадцать четыре записанных. На одном коротком клипе это незаметно, на десяти длинных — это заметная часть файла.

Проверять надо тем же способом, что и всё остальное в этом треке: разобрать файл и посмотреть на размер данных анимации, а не рассуждать о нём. Рычаги — уменьшить частоту записи, укоротить клип, включить оптимизацию размера анимации в настройках экспорта — и каждый из них снова стоит своей цены: анимация становится грубее.

Что унести из главы

  1. Сначала спросите, нужен ли скелет. Движение объекта как целого дешевле считать кодом.
  2. Правило вместо автоматики там, где правило известно. Жёсткий скиннинг по геометрическому признаку надёжнее весов «на глаз» — и он проверяем.
  3. Имена костей и клипов — контракт, наравне с именами мешей и материалов.
  4. Всё, что держится на ограничителях, перед экспортом запекается. Формат их не знает и не скажет об этом.
  5. Проверяйте файл, а не сцену. Строки клипы и кости в разборе GLB — единственное доказательство, что анимация доехала.

Что дальше

Пока речь шла об одном ассете. Настоящая экономия от процедурного подхода начинается там, где ассетов много: во вводной главе трека сто ящиков с разными пропорциями заявлены как то, в чём агент силён, но мы этого ещё не показывали.

Дальше — «Массовые вариации»: почему просить агента сделать сто моделей — плохая идея, чем её заменить, и как получить воспроизводимый результат вместо ста разных случайностей.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше