Building the Game: Part 3 - Skinning & Animation. Перевод на русский язык.

Оригинал статьи на английском языке на сайте TojiCode за авторством Брэндона Джонса.

Делая игру: Часть 3 — Скиннинг и Анимация

Вот здесь код для этого поста или для всех постов в серии.
А вот здесь — рабочее демо.

В предыдущем "Building the Game" мы сделали самый основной формат модели, но он годится только для статичных мешей. Нынче статичная геометрия очень важна и составляет большую часть любой сцены в нашей будущей игре. Но все же мы знаем, что самые интересные вещи, которые вы видите на экране — это те, что двигаются, будь то игрок, бегущий по экрану, или ракета, мчащаяся прямо вам в голову.

Есть много различных технологий создания движения в игре. Это может быть скользящий туда-сюда статический меш, изображающий дверь, использование физического движка, создание эффектов из частиц для дыма и искр, трансформирование текстурных координат чтобы имитировать текущую воду, либо деформация меша, чтоб тот развевался как ткань. Мы в конечном итоге и об этом поговорим в нашем цикле постов, но сегодня я желаю поговорить о большом папе анимации: Скелетной Анимации и Скиннинге!

Я не собираюсь здесь охватить основы этих тем, чтоб пост не разросся. Но если вы с этими темами незнакомы и хотите узнать побольшое, я бы порекомендовал почитать статью на Википедии о теме поста, чтобы иметь самое общее представление. Кроме того, гугление по ключсловам "Скиннинг" и "Скелетная Анимация" выдаст много хороших ресурсов. А что до меня, то я больше заинтересован в разговоре о том, как эти системы будут реализованы на моей программной основе.

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

 

"bones": [ 
        {
            "name": "root",
            "parent": -1,
            "pos": [0, 0, 0],
            "rot": [0, 0, 0, 1],
            "skinned": false
        },
      
        {
            "name": "lower-back",
            "parent": 0,
            "pos": [0, 1.723, -0.56],
            "rot": [-0.454, 0.509, 0.551, 0.479],
            "skinned": true,
            "bindPoseMat": [1, 0, 0, 0, 0, 1, 0, 0, ...]
        }, 
        ...
]

Как видите, каждая кость имеет имя, родительскую кость (-1 означает отсутствие родителя), позицию (вектор) и поворот (кватернион), булеву переменную "skinned" , и, если skinned истинно, матрицу начальной позы. Матрица начальной позы будет храниться в массиве из 16 флоатов и представляет собой инвертированную матрицу начальной позы скелетной трансформации. Нам эта информация нужна чтобы обеспечить правильную трансформацию вершин.

Сейчас эта переменная "skinned" может немного вас запутать, но за ней есть логика: для каждой кости в нашем скелете нам понадобится рекурсивно обновлять повороты и позиции, основанные на родительских костях, считать на основе этого матрицу трансформации, а затем умножать эту матрицу на матрицу начальной позы, чтобы получить конечную матрицу, которой и будут трансформироваться вершины. В обычных обстоятельствах это очень много работы, и в Javascript'е это ужасно дорого! Но не каждая кость в вашем скелете будет иметь вершины, к ней присоединённые! нередко у персонажей бывает, что корневая кость или часть спины или ещё что-нибудь, не имеют присоединённых вершин, но эти кости нам всё ещё нужны, чтобы рассчитать анимацию целиком. Если skinned ложно, то мы знаем, что мы можем пропустить рассчёт матрицы для этих вершин, что нам сэкономит немножко времени. По правде сказать, я сам точно не знаю, даст ли это какое-то значимое различие в долгосрочной перспективе, но на тех мешах, на которых я пока что тестил, это оказалось и правда полезным.
 

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

А относительно вершин, информация о скиннинге просто добавляется в вершинный буфер. В прошлый раз мы сделали флаг формата вершин, и теперь мы можем его использовать для определения того, что вершины ещё и имеют данные о скиннинге. Они упакованы в чередованных данных точно так же как положения, нормали и всё прочее в таком паттерне: "Weight 0, Weight 1, Weight 2, Bone 0, Bone 1, Bone 2". Я сначала хотел хранить индексы вершины и веса как байты, так как в любом случае мы можем обратиться лишь к небольшому числу костей для каждого меша, но после некоторых экспериментов я выяснил, что:

  1. WebGL не позволяет вам использовать байты в качестве атрибутов (Эх...)
  2. Можно, правда, вместо них использовать short'ы, но драйвера вас за это возненавидят. Вы получите гадкие проседания в скорости.

Так что я стиснул зубы и просто сделал индексы и веса флоатами. (Я перевожу индексы в инты в шейдере). Просёр байтов заставляет меня немного скрипеть зубами, но это говно вопрос ради лучшей производительности. Впрочем, я был бы очень заинтересован узнать, так же ли себя ведут в этом случае мобильные устройства.

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

Относительно реализации: был добавлен новый флаг ModelVertexFormat в model.js, как было ранее упомянуто, но основная часть кода, ответственного за загрузку заскиненного меша теперь лежит в новом классе и в новом файле: skinned-model.js. Класс SkinnedModel наследован от прототипа Model, и добавляет немного нового кода загрузки, как положено. Стоит отметить, что вместо того чтобы пытаться что-то достроить на основе кода отрисовки Model, SkinnedMesh свою собственную, полностью независимую реализацию. Это может показаться весьма неосторожным, потому что около 80% кода совпадает, и возможность абстрагировать код высока, но я не поддался такому искушению по одной простой причине:

Абстрации вас замедляют.

Если дело касается загрузки, то мне в целом по барабану. Да, мы хотим, чтобы загрузка делалась быстрее, но пара миллисекунд во время загрузки ничего не решают. Но код рендера будет выполнен сотни и тысячи раз за каждый кадр! А раз на то пошло, то хотим ли мы заставлять систему ходить через несколько слоёв вызовов функций и перенаправлений, чтобы мы могли всё обработать, и нам пришлось бы передать больше данных, чтобы убедиться, что униформы шейдеров правильно выставлены, и ... Знаете что, это не настолько уж много кода! Оно действительно стоит того, чтоб мне повторить ~40 строчек кода и поковырять их, чтобы убедиться, что каждый вариант рендера Model рендерит так быстро и напрямую, как это только возможно.

Это было про меши, а что насчёт анимаций?
 

Есть много разных способов, которыми можно хранить анимации, но я у себя сделал довольно просто: мы будем хранить список костей, которые анимированы (так что мы сможем иметь анимации которые работают только с ногами, например) и список кадров. Каждый кадр мы бужем делать слепока поворотов и положений (сейчас пока не трогаю масштаб) костей на момент того кадра, изменились они или нет. Это сделает вычисления довольно простыми. У нас будет чуть больше информации добавлено в заголовок и, ради простоты, я это делаю на JSON . (Хотя бы на первое время. Позже посмотрим, оправдывает ли экономия места переход на бинарный формат)

{
    "animVersion": 1,
    "name": "run_forward",
    "frameRate": 30,
    "duration": 633,
    "frameCount": 18,
    
    "bones": [ "player_root", "Bip001", "Bip001 Pelvis", ... ],
    
    "keyframes": [
     [
     { "pos": [ -6.148, -0.052, 0 ], 
                  "rot": [ 0, 0, 0, 1 ] },
     { "pos": [ 2.850, 1.012, 0.062 ], 
                  "rot": [ -0.431, 0.514, 0.567, 0.476 ] },
     { "pos": [ 0, 0, 0 ], 
                  "rot": [ -0.499, 0.500, 0.499, 0.5 ] },
                ...
       ],
       [
                { "pos": [ -6.148, -0.052, 0 ], 
                  "rot": [ 0, 0, 0, 1 ] }, 
     { "pos": [ -0.008, 1.003, 0.062 ], 
                  "rot": [ -0.44, 0.506, 0.573, 0.470 ] }, 
     { "pos": [ 0, 0, 0 ], 
                  "rot": [ -0.499, 0.5, 0.499, 0.5 ] }, 
                ...
      ],
      ...
   ]
}

Ещё пара моментов, о которых стоит упомянуть в этом формате: ссылки на кости сделаны по именам, а не по индексам. Я долго ходил в сомненьях на этот счёт, но в конце концов я решил, что такой способ добавляет больше гибкости. Иначе вам придётся экспортировать модель и все её анимации в один и тот же момент, чтобы быть уверенным, что индексы костей корректны (я меняю порядок костей в экспортёре чтобы соответствовать моему требованию "чайлды после родителей"). Кроме того, порядок, в котором кости расположены в списке "bones" — это также и порядок, в котором их трансформации будут в каждом кадре. Это делает всё проще.

Загрузка анимации и расчёт матриц для данного кадра находится в файле animation.js.

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

Когда дело доходит до скиннинга мешей, то там есть два основных подхода: программынй скиннинг и скиннинг на GPU. Программный скиннинг изначально означает, что после вычисления матриц костей мы умножаем все позиции вершин в коде нашего приложения (Javascript в данном случае) и передаём их на GPU каждый кадр. Так и было сделано в моей старой демке с моделью из Doom 3. Это полностью нормальный способ... если только вы не имеете дело с епучим Javascript'ом! Так как мы хотим, чтобы наш код на Javascript был как можно более лёгким, мы обратимся к скиннингу на GPU. Со скиннингом на GPU мы всё ещё будем вычислять матрицы костей на Javascript'е, так что дальше мы сможем сделать сложное смешение анимаций, но единственная вещь, которую нам придётся передавать на GPU каждый кадр, это массив матриц. Вершины меша могут оставаться статичными, и они будут трансформированы в корректную позицию в шейдере. Это бесценно в таких средах, как наша! Вы модете увидеть код шейдера скиннинга в верхней части skinned-model.js

Однако есть одна сложность, которую даёт скиннинг на GPU. Шейдеры имеют ограниченное число униформ-переменных, которые могут использоваться за один раз, и, так как наши матрицы будут переданы как uniform'ы, мы можем очень быстро выжрать этот лимит. Не стоит напоминать, что нам нужно некоторое количество униформов для других вещей, вроде информации о свете, текстурах и т.д. Что это значит в конечном итоге, так это то, что мы можем обработать лишь определённое количество костей за один вызов процедуры отрисовки. Сколько? Ну, это вообще сложный вопрос: ответ зависит от того, сколько униформов может поддерживать ваша железка и сколько униформов вам нужно для других применений, вроде освещения. Если честно, мне пока нечего ответить на этот вопрос, так что нам понадобится с этим поэкспериментировать в процессе создания игры. Пока что я выбрал число 50 и держу его в голове.

Так что, будучи ограниченным в 50 костей за одну отрисову, если у нас есть модель, которая использует больше, чем столько, нам придётся разделить меш на несколько подмешей, каждая из которых должна ссылаться не более чем на 50 костей. Конечно, мы уже на один шаг впереди игры, как вы могли вспомнить, подмеши являются частью нашего оригинального формата моделей! Ура! Всё что нам надо сделать в данном случае, это добавить немного кода в наш подмеш, чтобы сделать возможность его заскинить:

"submeshes": [

    { 
        "indexOffset": 0,
        "indexCount": 11760,
        "boneOffset": 0,
        "boneCount": 35
    }
]


Значения этих новых элементов должно быть легко понять. boneOffset — это первая кость в нашем списке, которую использует этот подмеш, а boneCount — это сколько костей нам надо передать в шейдер после неё. Это, конечно, делает очевидным предположение, что кости каждого подмеша должны быть сгруппированы вместе. Это отлично, но кроме того нам нужно, чтобы порядок костей был таков, что родители там находятся перед чайлдами. Заставить работать эти два ограничения вместе может стать внушительной задачей...

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

так или иначе, оставив в стороне теоретические изъяны, теперь, когда мы выяснили, как все наши форматы должны работать, мы должны раздобыть несколько моделек с анимацией, так что мы расширили наш экспортёр Unity. Предыдущий элемент меню "Export Selected Meshes" был расширен, чтобы работать также и с заскиненными мешами, и мы добавили "Export Selected Animations" который будет, предсказуемо, экспортировать любую анимацию, которую вы выделите в Project view. В проекте AngryBots основная цель для тестирования — основная модель игрока (main_player_lorez) и его различные анимации бега (run_forward, и т.д.)

Итак, когда файлы экспортированы, нам нужно заставить их показываться в нашем рендере. Загрузка заскиненной модели проста, мы просто меняем наш класс Model на класс SkinnedModel. (game-render.js, строка 50)


this.model = new model.SkinnedModel();
this.model.load("root/model/main_player_lorez");

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

Загрузка анимации тоже проста, сейчас мы используем очень простой и ручной способ чтобы проиграть анимацию (game-renderer.js, строка 54)



this.anim = new animation.Animation();
this.anim.load("/root/model/run_forward", function(anim) {
    // Simple hack to get the animation to play
    var frameId = 0;
    var frameTime = 1000 / anim.frameRate;
    setInterval(function() {
        if(self.model.complete) {
            anim.evaluate(frameId % anim.frameCount, self.model);
            frameId++;
        }
    }, frameTime);
});



И вдруг, заработало!
 

Конечно, на данном этапе у нас ещё полно чего нужно доделать, пока это не станет годной для игры системой анимации. Во-первых, наш метод проигрывания анимации (setTimeout со счётчиком кадров) вообще нубский, и не годится ни для чего кроме простецкой демки. Во-вторых, мы применяем анимацию прямо на скелет модели, что означает, что нам придётся дублировать модешь в случае когда понадобится другой образец, который играет другую анимацию. Как было упомянуто ранее, мы в действительности не обрабатываем ограничения количества костей в нашем экспортёре, так что у нас будут проблемы, когда мы попытаемся работать с более сложными моделями. А также у нас ещё нет даже намёка ни на смешение анимаций, ни на интерполяцию кадров. И это даже без учёта производительности. Анимация работает неплохо, но будет ли она работать быстро, когда мы попытаемся анимировать 20 разных объектов одновременно? Это лишь несколько вещей, которые нам понадобится исправить по мере доделывания игры, и многие из них будут достойны отдельного поста в блоге. 


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

Готовьтесь в следующем посте увидеть копию (а потом и ещё), так как мы будем говорить об инстансинге.


You can leave a comment with "Facebook":
Не забывайте оставлять комментарии при помощи "ВКонтакте":
Яндекс.Метрика