Введение

Статья рассказывает о основных типах анимации 3D моделей в играх, а также показывает, как при помощи встроенного в 3ds max скриптового языка MaxScript осуществить экспорт анимированных персонажей.

Для любой современной 3D игры обязательным атрибутом является анимация. Будь то вращающийся кубик или симпатичная героиня очередного боевика. Именно при помощи анимации можно “оживить” всё то, что происходит на экране и тем самым, добиться более полного погружения игрока в игровой процесс.

В анимации существуют свои правила и законы. В частности, человеческий мозг за движение расценивает последовательность кадров сменяющихся с частотой не менее 18 кадров в секунду. Существует популярный миф о “25 кадре” но человеческий глаз/мозг способен отличить отдельный кадр на частоте до 220 Гц. Однако, в кинематографии обходятся 24 кадрами (PAL), т.к. помимо частоты кадров существует такая особенность человеческого глаза, как инертность зрения именно из-за этого показателя возникает эффект называемый “Motion Blur”. Он, так сказать, применяется в кинокамерах, и благодаря этому компенсируется низкая частота в кино.

Но ближе к теме. 3D анимация также как и 2D представляется набором ключевых кадров (KeyFrames). Однако, в отличии от 2D анимации (спрайтовой) здесь мы имеем возможность достаточно дёшево и качественно интерполировать модель из одного состояния в другое.

anim
Cyborg, MD2 формат (морфинг) без интерполяции

Итак, мне известны три основных вида анимации Morphing, Object Transform и Skinned Mesh, все остальные можно считать их расширенными версиями. В статье приводится пример реализации, оптимизации и описание общей характеристики каждого из методов. Я постарался поделиться своими знаниями/опытом и при этом подать эту информацию в доступной для “нормального” человека форме. Для нормального усвоения этой информации “нормальный” человек должен свободно владеть основами Delphi, OpenGL, Max Script, 3ds max и хотя бы школьным курсом математики. Также читатель должен иметь опыт экспорта моделей в свои приложения, дабы код примеров не вызывал вопросов не связанных непосредственно с экспортом анимации, ибо статья призвана помочь читателю, а не научить его…

Инструментарий

Примеры написаны на языке программирования Delphi (7 и 2006 версии) с использованием OpenGL для вывода графики и 3ds max (7 версия) в качестве редактора. Предполагается, что у читателя имеется опыт работы в 3d редакторах, и создание описанных в статье типов анимации не вызовет больших проблем. На крайний случай, в интернете найдётся множество хороших моделей… ;)

Экспорт анимации производится при помощи встроенного в 3ds max скриптового языка “Max Script” с достаточно простым и логичным (для программиста) синтаксисом. В некоторых версиях 3ds max скрипты могут не запускаться из-за русских комментариев в их коде. Выход один - удалить комментарии.

Пример базируется на eXgine, который содержит в себе всю системщину не относящуюся к теме статьи... ;)

Morphing

img1
Вершины меняют свои координаты от кадра к кадру

Анимация морфингом является наиболее простым и достаточно качественным, практически для любых целей, видом анимации. Суть данного метода заключается в представлении самой анимации как последовательности отдельных состояний вершин модели для каждого из кадров, аналогично спрайтовой анимации в 2D. Данный тип анимации применялся в большинстве игр 90-x годов, таких как Quake 1-3, Unreal, Rune и др.

К достоинствам этого метода можно отнести простоту реализации и минимальное количество вычислений.

Из существенных недостатков выделяется гигантский расход памяти и такой же размер файла модели. Но этот недостаток можно частично обойти путём оптимизации, а именно уменьшением FPS анимации и количества вершин модели. Чтобы эти изменения не так явно сказались на качестве модели – используют линейную интерполяцию вершин.

Примером для демонстрации может служить любая модель положения вершин которой меняются со временем, независимо от того, при помощи какого модификатора это реализовано (включая и “ручной” метод :)

Приведу код скрипта для экспорта модели в файл:
( mesh = undefined -- поиск экспортируемого меша for obj in objects do -- цикл по всем объектам if ((classOf obj) != BoneGeometry) and ((classOf obj) != Biped_Object) and (canConvertTo obj TriMeshGeometry) then ( mesh = obj exit -- выход из цикла ) -- сохранение в файл FileName = GetSaveFileName types:"Morphing Anim (*.mx1)|*.mx1|" if (FileName != undefined) and (mesh != undefined) then ( start = animationRange.start.frame as integer -- кадр начала анимации end = animationRange.end.frame as integer -- кадр её окончания V_Count = mesh.numVerts -- кол-во вершин F_Count = mesh.numFaces -- кол-во граней T_Count = mesh.numTVerts -- кол-во текстурных координат file = fopen FileName "wb" -- сохранение заголовка WriteLong file V_Count WriteLong file F_Count WriteLong file T_Count WriteLong file (end - start + 1) -- кол-во кадров анимации WriteLong file frameRate -- кол-во кадров в секунду -- сохранение граней for i = 1 to F_Count do ( f = (GetFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) -- текстурные координаты for i = 1 to T_Count do ( t = (GetTVert mesh i) WriteFloat file t.x WriteFloat file (1 - t.y) -- отображаем координату по Y ) -- текстурные грани for i = 1 to F_Count do ( f = (GetTVFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) -- сохранение положений вершин for frame = start to end do -- цикл по всем кадрам анимации at time frame for i = 1 to V_Count do ( p = (GetVert mesh i) -- координаты вершины в момент времени = frame WriteFloat file p.x WriteFloat file p.y WriteFloat file p.z ) fclose file print "MX1 Model Conveted" ) )

Я опустил большинство проверок на ошибки, дабы не “захламлять” код.

Абсолютно никаких оптимизаций здесь не применяется, что приводит к гигантскому размеру файла при хорошем качестве модели. Как уже говорилось выше, основной оптимизацией может послужить уменьшение количества FPS, однако есть и более изощрённые методы… Так например, в формате md2 (Quake 2) координаты вершин имеют тип ShortInt (знаковый байт). При этом, чтобы окончательно не загубить качество, каждый кадр, помимо координат вершин, несёт с собой 2 дополнительных параметра Scale (растяжение) и Translate (сдвиг), работающих по следующему принципу:
ResultVertex[i] := Vertex[i] * Scale + Translate
Translate – геометрический центр модели. Scale – коэффициент перевода из целочисленной системы координат [-128..127] в оригинальную.

В итоге, размер такой оптимизированной модели составляет ~1/4 от того что создаёт выше описанный скрипт (т.к. Single – 4 байта, ShortInt – 1 байт).

Загрузка и воспроизведение анимации не представляет особых проблем. Единственное, что хотелось бы отметить отдельно, так это линейная интерполяция. Суть её заключается в том, что в каждый момент времени имеется 2 кадра, текущий и следующий. Также, известно время прошедшее с предыдущего кадра. Имея эти данные легко “предсказать” где бы сейчас находилась вершина, двигаясь непрерывно от предыдущего к следующему положению
Vertex[CurrentFrame] := Vertex[LastFrame] + (Vertex[NextFrame] - Vertex[LastFrame]) * t
Здесь t является коэффициентом, характеризующим время, прошедшее с предыдущего кадра, и находится в диапазоне от 0 до 1.
t := frac(CurrentTime / (1000/FPS))
где CurrentTime – время с момента начала анимации, FPS – кол-во кадров анимации в секунду (1000/FPS – длительность одного кадра), а функция frac возвращает дробную часть числа.

Приведённый выше метод морфинга является базовым, существуют другие его вариации с изменением количества вершин от кадра к кадру. Метод анимации морфингом, до сих пор применяется в играх со сложными преобразованиями модели, которые невозможно (или очень сложно) реализовать как-то иначе.

Object Transform

img2
Объекты меняют своё положение от кадра к кадру

Предназначен для объектов сохраняющих свою форму по мере анимации, т.е. для недеформируемых объектов. Обычно такая модель состоит из набора отдельных объектов меняющих своё положение и ориентацию от кадра к кадру. Использовалась в играх: Tomb Raider 1-3, GTA 3, Resident Evil, MechWarrior 3 и др. Основным достоинством данного типа анимации является возможность привязки к скелету и как следствие – более высокое качество и меньший расход памяти на анимацию по сравнению с морфингом.

Object Transform анимация тесно связана с темой скелетной анимации, т.к. по сути “кость” также задаёт позицию и поворот. Основным же достоинством скелетной анимации является независимость от анимируемого меша. В GTA 3 для анимации всех NPC было использовано всего несколько анимированных “скелетов”, и как следствие, разработчики смогли уместить игру на 1 CD диск не в ущерб разнообразию игрового мира.

Я не буду строить иерархии “костей” и хранить анимацию в отдельном файле, дабы облегчить и без того сложный для новичка код:
( -- считаем пригодные объекты obj = #() for mesh in objects do -- цикл по всем объектам if ((classOf mesh) != BoneGeometry) and ((classOf mesh) != Biped_Object) and (canConvertTo mesh TriMeshGeometry) then append obj mesh M_Count = obj.count FileName = GetSaveFileName types:"Object Transform Anim (*.mx2)|*.mx2|" if (FileName != undefined) and (M_Count > 0) then ( start = animationRange.start.frame as integer -- кадр начала анимации end = animationRange.end.frame as integer -- кадр её окончания file = fopen FileName "wb" WriteLong file M_Count -- пишем кол-во объектов WriteLong file (end - start + 1) -- кол-во кадров анимации WriteLong file frameRate -- кол-во кадров в секунду -- цикл по всем объектам for mesh in obj do ( --==// ЗАГОЛОВОК ОБЪЕКТА //==-- V_Count = mesh.numVerts -- кол-во вершин F_Count = mesh.numFaces -- кол-во граней T_Count = mesh.numTVerts -- кол-во текстурных координат -- сохранение заголовка WriteLong file V_Count WriteLong file F_Count WriteLong file T_Count --==// ГЕОМЕТРИЯ //==-- at time start -- относительно первого кадра for i = 1 to V_Count do ( p = (GetVert mesh i) WriteFloat file p.x WriteFloat file p.y WriteFloat file p.z ) -- сохранение граней for i = 1 to F_Count do ( f = (GetFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) -- текстурные координаты for i = 1 to T_Count do ( t = (GetTVert mesh i) WriteFloat file t.x WriteFloat file (1 - t.y) -- отображаем координату по Y ) -- текстурные грани for i = 1 to F_Count do ( f = (GetTVFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) --==// АНИМАЦИЯ //==-- at time start m0 = (inverse mesh.objectTransform) for frame = start to end do -- цикл по всем кадрам анимации at time frame ( -- вычисление матрицы поворота относительно начального положения m = m0 * mesh.objectTransform p = m.Position -- позиция r = m.Rotation -- кватернион -- сохранение их в файл WriteFloat file p.x WriteFloat file p.y WriteFloat file p.z WriteFloat file r.x WriteFloat file r.y WriteFloat file r.z WriteFloat file r.w ) ) fclose file print "MX2 Model Conveted" ) )

Теперь необходимо сделать маленькое отступление. Для реализации данного метода необходимы более глубокие познания в математике (далеко не школьной ;) нежели в случае с морфингом. Существует три основных способа описания ориентации тела в пространстве: матрица, углы Эйлера и кватернион. Насколько мне известно, даже преподаватели ВУЗов делают “не умный” взгляд, услышав о кватернионах. Винить их в этом нет причин, т.к. тема действительно специфична. Благодаря простоте выполнения операций и преобразований, кватернионы нашли своё применение в физике, а точнее, в механике твёрдых тел. По сути, кватернион – это 4D (xyzw) вектор, описывающий ориентацию тела. Имея 3D вектор и угол поворота относительно него, искомый кватернион будет выглядеть так:
Quat(X * sin(alpha/2), Y * sin(alpha/2), Z * sin(alpha/2), cos(alpha/2))
где X, Y, Z – координаты направляющего вектора, alpha – угол поворота. Искушённый в математике читатель может заметить один из недостатков кватернионов, а именно – поворот более чем на 360 градусов одним кватернионом описать нельзя. Но это сложно назвать значимым недостатком…

Все операции над кватернионом идентичны операциям над вектором. Нам же от них нужна всего лишь простая интерполяция от одного к другому. В примерах используется линейная интерполяция. Также существует и более сложная – сферическая интерполяция, которая может понадобиться при очень низком FPS анимации.

После получения интерполированной позиции и кватерниона для объекта создаётся матрица 4x4 описывающая эти преобразования. Затем эта матрица передаётся в OpenGL и производится вывод объекта. Как видите, всё достаточно просто…

Кстати, ещё одно достоинство данного типа анимации - очень простое “расчленение” модели в реальном времени… ;)

Skinned Mesh

img3
Вершины меняют свои координаты в зависимости от положения костей

Является результатом слияния двух предыдущих. Сохраняет за собой все их достоинства, но имеет существенный (для 90-х годов) недостаток – относительно большое количество расчётов в реальном времени. Впрочем, показ тысяч таких персонажей может убить и современное железо… ;)

Основное отличие от Object Transform, в том, что к единому объекту привязывается скелет, каждая кость которого, определённым образом влияет на группу соседних вершин. Это влияние характеризуется коэффициентом – весом в диапазоне [0..1]. Обычно на одну вершину влияет не более 4 костей что упрощает вынос кода анимации в шейдер.

Данный тип анимации наиболее популярен в современных играх, и использовался в Doom 3, Half-Life 1-2, GTA San Andreas, Unreal Tournament 2004, Quake 4 и др.

Код скрипта имеет мало различий с предыдущими за исключением экспорта таблицы весов вершин.
( -- поиск меша и костей bones = #() mesh = undefined skin = undefined for obj in objects do -- цикл по всем объектам if ((classOf obj) == BoneGeometry) or ((classOf obj) == Biped_Object) then append bones obj else if (canConvertTo obj TriMeshGeometry) then if mesh == undefined then for i = 1 to obj.modifiers.count do -- поиск модификатора Skin if obj.modifiers[i].Name == "Skin" then ( mesh = obj skin = obj.modifiers[i] ) -- поиск индекса кости по имени fn FindBone Name = ( for i = 1 to bones.count do if bones[i].Name == Name then return (i - 1) ) -- сохранение в файл FileName = GetSaveFileName types:"Skinned Mesh Anim (*.mx3)|*.mx3|" if (FileName != undefined) and (mesh != undefined) then ( -- включаем модификатор Skin max modify mode modPanel.setCurrentObject skin file = fopen FileName "wb" --==// ЗАГОЛОВОК МОДЕЛИ //==-- start = animationRange.start.frame as integer -- кадр начала анимации end = animationRange.end.frame as integer -- кадр её окончания B_Count = bones.count -- кол-во костей V_Count = mesh.numVerts -- кол-во вершин F_Count = mesh.numFaces -- кол-во граней T_Count = mesh.numTVerts -- кол-во текстурных координат -- сохранение заголовка WriteLong file (end - start + 1) -- кол-во кадров анимации WriteLong file frameRate -- кол-во кадров в секунду WriteLong file B_Count WriteLong file V_Count WriteLong file F_Count WriteLong file T_Count --==// ГЕОМЕТРИЯ //==-- at time start -- относительно первого кадра for i = 1 to V_Count do ( p = (GetVert mesh i) WriteFloat file p.x WriteFloat file p.y WriteFloat file p.z ) -- сохранение граней for i = 1 to F_Count do ( f = (GetFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) -- текстурные координаты for i = 1 to T_Count do ( t = (GetTVert mesh i) WriteFloat file t.x WriteFloat file (1 - t.y) -- отображаем координату по Y ) -- текстурные грани for i = 1 to F_Count do ( f = (GetTVFace mesh i) - [1, 1, 1] WriteLong file (f.x as Integer) WriteLong file (f.y as Integer) WriteLong file (f.z as Integer) ) --==// ВЕСА ВЕРШИН //==-- for i = 1 to V_Count do ( -- кол-во костей действующих на вершину W_Count = skinOps.getVertexWeightCount skin i WriteLong file W_Count for j = 1 to W_Count do ( -- получаем имя объекта name = (skinOps.GetBoneName skin (skinOps.getVertexWeightBoneID skin i j) 0) -- сохрание веса кости WriteLong file (findbone name) -- индекс кости WriteFloat file (skinOps.getVertexWeight skin i j) -- вес ) ) --==// АНИМАЦИЯ //==-- for bone in bones do ( at time start m0 = (inverse bone.objectTransform) for frame = start to end do -- цикл по всем кадрам анимации at time frame ( -- вычисление матрицы поворота относительно начального положения m = m0 * bone.objectTransform p = m.Position r = m.Rotation -- позиция WriteFloat file p.x WriteFloat file p.y WriteFloat file p.z -- кватернион ориентации WriteFloat file r.x WriteFloat file r.y WriteFloat file r.z WriteFloat file r.w ) ) fclose file print "MX3 Model Conveted" ) )

Теперь же после получения матрицы трансформации кости, необходимо умножить её на вес этой кости для вершины и применить к вершине “статического” меша (базовому состоянию вершин). Расчёт итогового меша модели производится векторным суммированием всех полученных вершин. В коде это выглядит так:
for i := 0 to V_Count - 1 do begin RVertex[i] := Vector(0, 0, 0); for j := 0 to B_Count - 1 do RVertex[i] := RVertex[i] + Weight[i][j] * Bone[j].Matrix * Vertex[i]; end;

Оптимизацией может служить уменьшение количества воздействующих на вершину костей, что упростит код и увеличит его производительность. А в связке с Vertex Shader и Vertex Buffer Object даст потрясающий прирост скорости вывода.

Можно оптимизировать с целью уменьшения размера файла анимации. Так например, в формате md5 (Doom 3) перед каждой комбинацией Position/Rotation анимации пишется байт-флаг описывающий какие именно компоненты были изменены относительно предыдущего кадра. Следовательно, не изменённые координаты этих векторов не сохраняются в файл, что вкупе с иерархическим строением скелета даёт значительное уменьшение размера файла.

Пример рассчитан на модели анимированные с использованием стандартного 3ds max модификатора Skin.

Заключение

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

Также, благодаря наличию скелета, существуют расширения последних двух Инверсная Кинематика (IK) и "Тряпичная кукла" (RagDoll). Каждый из них - тема для отдельной, увесистой статьи :)

На все появившиеся после прочтения статьи вопросы, я всегда готов ответить

Ссылки:
Пример к статье

http://steps3d.narod.ru/tutorials/skeletal-animation-tutorial.html
http://www.gamedev.ru/articles/?id=30129
http://www.gamedev.ru/users/wat/articles/SkelAnim1