Оптимизированная скелетная анимация

Оптимизация скелетной анимации (СКАЧАТЬ ПРИМЕРЫ в 7z архиве)

Использование вершинных шейдеров для ускорения расчётов

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

Изменения в версии 1.2

1. Чтобы шейдер работал на видеокартнах ATI, строчка
float texNum2 = floor(texNum*255-1+0.001);
была заменена на
float texNum2 = floor(texNum*255.0-1.0+0.001);


2. По совету CyberZX чтобы уменьшить число умножений
теперь рассчитывается общая матрица трансформации вершины:
(http://www.gamedev.ru/code/forum/?id=56039)


3. Индексы костей и текстур по прежнему передаются в диапазоне
0..1, так как если передавать числа 0..255, то возникает
странное падение скорости на 30 FPS.

Использование VBO для ускорения рендеринга модели

Всем хорошо известен способ по выводу моделей с помощью связки glBegin/glEnd, когда наша программа посылает данные в видеокарту многочисленными вызовами glTexCoord2f, glColor3f, glVertex3f и glNormal3f. В таком случае 3D модель из памяти CPU (Central Processing Unit) отправляются в память GPU (Graphics Processing Unit), что занимает определённое время.

Современные видеокарты могут хранить 3D модели прямо в своей памяти. Отрисовка модели из памяти видеокарты происходит существенно быстрее, так как не тратится время на вызовы glTexCoord2f, glVertex3f и подобных функций, требующих времени на копирование данных из одной памяти (CPU) в другую (GPU). Осуществить ускоренную работу видеокарты позволяют Vertex Buffer Objects (VBOs).

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

Совместное использование VBO и шейдеров

Максимальный прирост производительности дают VBO с полностью статическими моделями: модели один раз загружаются в память видеокарты сразу после запуска программы. Под воздействием шейдеров статическая модель "оживает" непосредственно внутри видеокарты. В таком случае у нас есть два источника прироста производительности: VBO в режиме вывода статических моделей и быстрое перемножение вершин на матрицы трансформации костей внутри шейдера.

Выбор формата для хранения 3D моделей в памяти видеокарты

В описании к функции glInterleavedArrays сказано, что "For some memory architectures this is more efficient than specifying the arrays separately". Поэтому в целях оптимизации, а заодно и упрощения исходного кода, рендеринг в VBO режиме будем производить через функции glInterleavedArrays и glDrawArrays.

Для начала нам необходимо выбрать формат для хранения данных 3D модели в памяти видеокарты, который поддерживается функцией glInterleavedArrays. В документации к OpenGL перечислены следующие форматы: GL_V2F, GL_V3F, GL_C4UB_V2F, GL_C4UB_V3F, GL_C3F_V3F, GL_N3F_V3F, GL_C4F_N3F_V3F, GL_T2F_V3F, GL_T4F_V4F, GL_T2F_C4UB_V3F, GL_T2F_C3F_V3F, GL_T2F_N3F_V3F, GL_T2F_C4F_N3F_V3F, or GL_T4F_C4F_N3F_V4F.

Наиболее подходящим является формат GL_T4F_C4F_N3F_V4F, так как он позволяет хранить для одной вершины наибольшее количество данных:

  • T4F - четыре значения типа float (координаты текстуры s,t,r,q)
  • C4F - четыре значения типа float (цвет r,g,b,a)
  • N3F - три значения типа float (нормаль x,y,z)
  • V4F - четыре значения типа float (координата вершины x,y,z,w)

У вас может возникнуть вопрос, зачем нам V4F (x,y,z,w) вместо V3F (x,y,z) и четыре координаты текстуры T4F вместо двух T2F, да ещё и цвет C4F. Ответ на этот вопрос состоит в следующем: "излишки" данных мы будем использовать для хранения весов (bone weight) и индексов костей, к которым прикреплена вершина. Посмотрим, где есть излишки, которые можно использовать для наших целей:

  • T4F - можно позаимствовать две координаты текстуры r и q (float, float)
  • C4F - отсюда можно взять все четыре значения (float, float, float, float)
  • N3F - лишних данных нет - нормали нам нужны
  • V4F - можно взять четвёртую координату w (float)

Всего получилоь 7 значений типа float, а это означает возможность прикрепить одну вершину как минимум к трём костям и задать текущую текстуру:

  • float N1 - индекс первой кости
  • float N2 - вес влияния первой кости
  • float N3 - индекс второй кости
  • float N4 - вес влияния второй кости
  • float N5 - индекс третьей кости
  • float N6 - вес влияния третьей кости
  • float N7 - номер текстуры

Все эти семь значений будет использовать в своей работе вершинный шейдер. В принципе можно ухитриться, и "упаковать" в эти 7 float-ов больше данных, а затем "распаковывать" эти данные в шейдере. Но для простоты рассмотрим случай, когда на вершину влияет не более трёх костей.

Загрузка данных VBO в память видеокарты

В загрузке данных VBO нет ничего сложного: достаточно объявить в Delphi правильный тип, соответствующий выбранному нами формату данных GL_T4F_C4F_N3F_V4F, и воспользоваться функциями OpenGL для работы с VBO.

Для начала необходимо создать VBO для хранения модели. Нам понадобится создать только один буфер:

glGenBuffersARB(1, @VBOlink);

Эта функция создаст один VBO и вернёт ссылку на этот объект в переменную VBOlink. Теперь созданный VBO необходимо сделать текущим:

glBindBufferARB(GL_ARRAY_BUFFER_ARB, VBOlink);

Нечто подобное мы уже наблюдали с функцией glBindTexture(GL_TEXTURE_2D, texID) для установки текущих текстур. Перед загрузкой модель нужно привести к тому виду, в котором она будетнаходиться в памяти видеокарты (GL_T4F_C4F_N3F_V4F). Тип данных для такой вершину на Delphi будет выглядеть так:

type TVertexForVBO=packed record
ts,tt,tr,tq : GLFloat;
r,g,b,a : GLFloat;
nx,ny,nz : GLFloat;
x,y,z,w : GLFloat;
end;

После создания массива tempData из вершин TVertexForVBO и наполнения его описанными выше данными, можно приступать к загрузке данных в память видеокарты:

 glBufferDataARB( GL_ARRAY_BUFFER_ARB,
3*length(myModel.poligons)*sizeof(TVertexForVbo),
tempData,
GL_STATIC_DRAW_ARB );

На вход функции glBufferDataARB передаются следующие параметры:
GL_ARRAY_BUFFER_ARB - тип загружаемых данных
3*length(myModel.poligons)*sizeof(TVertexForVbo) - объём загружаемых данных
tempData - указатель на загружаемые данные (pointer)
GL_STATIC_DRAW_ARB - признак того, что загружаются данные статической модели (без анимации)

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

Рендеринг VBO-модели

Загрузив один раз в начале программы нашу модель в VBO, мы получаем ссылку VBOlink на этот объект. Теперь вместо того, чтобы передавать в видео карту десятки тысяч вершин, нормалей и текстурных координат достаточно одной только ссылки VBOlink, и видео карта сразу же имеет доступ к 3D модели, так как она уже находится в её памяти. Благодаря использованию функции glInterleavedArrays рендеринг осуществляется предельно просто:

glBindBufferARB(GL_ARRAY_BUFFER_ARB, myModel.VBOlink);
glInterleavedArrays(GL_T4F_C4F_N3F_V4F, 0,nil);
glDrawArrays(GL_TRIANGLES,0,3*length(myModel.poligons));

Шейдеры для скелетной анимации

Индексы и веса влияющих на вершину костей мы записывали в текстурные координаты R,Q и в цвет R,G,B,A. Вершинный шейдер выполняется для каждой вершины, поэтому мы можем сразу узнать данные о костях, к которым принадлежит данная вершина. Чтобы избежать ошибок при отсечении с помощью функции floor дробной части от значений индексов костей и текстур, к ним прибавляется 0.001 (надёжность данного метода для разных видеокарт и драйверов пока неизвестна). Исходный код вершинного шейдера:

uniform mat4 boneMat[32]; - матрицы всех костей
varying float texNum; - индекс текстуры для данной вершины
(тип varying сделан с целью передачи в фрагментный шейдер)
void main(void) { float boneIndex[3]; - индексы трёх костей float boneWeight[3]; - веса трёх костей texNum = gl_Vertex[3]; - получаем индекс текстуры vec4 fixedTexCoord = gl_MultiTexCoord0; vec4 fixedColor = gl_Color; vec4 fixedVertex = gl_Vertex; vec4 finalVertex = vec4(0,0,0,1); Получаем данные (индекс, вес) для трёх костей: boneIndex[0] = floor(fixedTexCoord[2]*255.0+0.001); boneWeight[0] = fixedTexCoord[3]; boneIndex[1] = floor(fixedColor[0]*255.0+0.001); boneWeight[1] = fixedColor[1]; boneIndex[2] = floor(fixedColor[2]*255.0+0.001); boneWeight[2] = fixedColor[3]; Домножение на 255 служит для перевода из диапазона
0..1 в диапазон 0..255, где это требуется. Прибавление
числа 0.001 сделано с целью избежать ошибок при отсечении
дробной части.

Все необходимые данные получены, можно восстанавливать
нормальные OpenGL-евские значения:
fixedTexCoord[2] = 0.0; fixedTexCoord[3] = 1.0; fixedColor[0] = 1.0; fixedColor[1] = 1.0; fixedColor[2] = 1.0; fixedColor[3] = 1.0; fixedVertex[3] = 1.0; mat4 finalMatrix = mat4(0); for (int i = 0; i < 3; i++) finalMatrix += boneWeight[i]*boneMat[int(boneIndex[i])]; Трансормация вершины с учётом весов и матриц трёх костей: finalVertex = finalMatrix*fixedVertex; finalVertex[3] = 1.0; Записываем итоговые значения (координата вершины, цвет,
текстурные координаты):
gl_Position = gl_ModelViewProjectionMatrix * finalVertex; gl_FrontColor = fixedColor; gl_TexCoord[0] = fixedTexCoord; }

Номер текстуры из вершинного шейдера через переменную texNum передаётся в фрагментный шейдер:

uniform sampler2D myTexture0; - ссылки на текстуры
uniform sampler2D myTexture1;
uniform sampler2D myTexture2;
uniform sampler2D myTexture3;
uniform sampler2D myTexture4;
uniform sampler2D myTexture5;
uniform sampler2D myTexture6;
uniform sampler2D myTexture7;


varying float texNum; - номер нужной текстуры (эту переменную
передаст сюда вершинный шейдер, т.к. тип varying)
void main(void) { Перевод из диапазона 0..1 в диапазон 0..255: float texNum2 = floor(texNum*255.0-1.0+0.001); Отрисовываем пиксель, подставляя нужную текстуру: if (texNum2==0.0) gl_FragColor = texture2D( myTexture0, gl_TexCoord[0].st ); else if (texNum2==1.0) gl_FragColor = texture2D( myTexture1, gl_TexCoord[0].st ); else if (texNum2==2.0) gl_FragColor = texture2D( myTexture2, gl_TexCoord[0].st ); else if (texNum2==3.0) gl_FragColor = texture2D( myTexture3, gl_TexCoord[0].st ); else if (texNum2==4.0) gl_FragColor = texture2D( myTexture4, gl_TexCoord[0].st ); else if (texNum2==5.0) gl_FragColor = texture2D( myTexture5, gl_TexCoord[0].st ); else if (texNum2==6.0) gl_FragColor = texture2D( myTexture6, gl_TexCoord[0].st ); else if (texNum2==7.0) gl_FragColor = texture2D( myTexture7, gl_TexCoord[0].st ); }

Загрузка шейдеров.

Загрузка шейдеров осуществляется с помощью удобного юнита shader.pas (delphi3d.net). Сначала идёт загрузка вершинного и фрагментного шейдера, почле чего мы получим две ссылки типа GLHandleARB:

vertex := LoadShaderFromFile('.\shaders\vertex.txt', GL_VERTEX_SHADER_ARB);
fragment := LoadShaderFromFile('.\shaders\fragment.txt', GL_FRAGMENT_SHADER_ARB);

Затем мы объединяем шейдеры в одну шейдерную программу с помощью функции LinkPrograms из файла shader.pas:

FShaders[0]:=LinkPrograms([vertex,fragment]);

Далее необходимо получить ссылки для обращения к переменным шейдера:

shader_boneMat := glGetUniformLocationARB(po, PGLcharARB(PChar('boneMat')));
for i:= 0 to 7 do
shader_myTexture[i] := glGetUniformLocationARB(po,
PGLcharARB(PChar('myTexture'+intToStr(i))));

Трансформация VBO-модели с помощью шейдеров в процессе рендеринга

Сначала необходмо выбрать текущий шейдер. Так как мы загрузили шейдеры в нулевой элемент массива FShaders, то включение этого шейдера будет выглядеть так:

glUseProgramObjectARB(FShaders[0]);

Текущая реализация шейдера и программы позволяет отображать модели, в которых используется не более восьми текстур. В далнейшем работу с текстурами можно усовершенствовать. Работа с текстурами в шейдере организована следующим образом: перед отрисовкой VBO-модели необходимо передать шейдеру список из восьми текстур и произвести glBindTexture для этих восьми текстур из нашей программы:

    for i:= 0 to 7 do
glUniform1iARB(shader_myTexture[i],i);


for i:= 0 to 7 do
begin
glActiveTexture(GL_TEXTURE0+i);
glBindTexture(GL_TEXTURE_2D, i+1);
end;

Далее следует копирование матриц трансформации из нашей программы на Delphi в шейдер:

glUniformMatrix4fv( shader_boneMat, 32, false,@skelState[i].shaderAbsoluteMat);

Теперь можно приступать к обычному выводу VBO-модели с помощью функций glBindBufferARB, glInterleavedArrays и glDrawArrays (см. выше).

Если вы захотите изменить количество костей с 32 на другое число, то это необходимо сделать в трёх местах:

1) в файле .\shaders\vertex.txt "uniform mat4 boneMat[32];"
2) в файле .\_model.pas "shaderAbsoluteMat: packed array[0..31] of TMatrix4x4raw;"
3) в файле .\unit1.pas "glUniformMatrix4fv( shader_boneMat, 32, false,@skelState[i].shaderAbsoluteMat);"

При загрузке моделей Half-Life 2 SMD мне удавалось создавать 57 матриц костей (boneMat[57]), а при 64 программа выдавала шейдерную ошибку.

При том же количестве матриц количество передаваемых
полезных данных можно увеличить вдве: строки матрицы 4x4 можно
использовать для хранения перемещений (x,y,z) и кватернионов
(x,y,z,w).

Полученные результаты

Совместное использование VBO и шейдеров позволило увеличить FPS в несколько раз. Возможные источники прироста производительности таковы:

  • Благодаря функции glInterleavedArrays и выбору текстуры внутри шейдера отрисовка модели осуществляется за один вызов glDrawArrays.
  • Данные о вершинах и креплении к скелету 3D модели загружаются в память видеокарты только один раз после старта программы.
  • Возможно, что при использование единого массива данных GL_T4F_C4F_N3F_V4F более эффективно происходит кеширование данных, так как считывание данных о вершине и скелетной анимации происходит практически из одного участка памяти.
  • Высокая скорость GPU при перемножении вершин модели на матрицы костей.
  • При использовании VBO все необходимые шейдеру данные уже находятся в памяти видеокарты и отсутствует потеря времени на ожидание передачи данных от CPU.

Странным является то, что передача индексов костей и текстур, отнормированных в диапазон 0..1 даёт прирост производительности на 30fps.

Результаты тестирования (модель Half-Life 1 smd, 2215 полигонов):

Количество моделей 1 2 3 4 5 30 100 300
FPS 814 634 530 460 405 99 39 15

2215 * 100 = 221500 полигонов при 39fps

Для модели Half-Life 2 smd с 3852 полигонами:

Количество моделей 1 2 3 4 5 30 100 300
FPS 775 600 493 419 361 82 26 9

3852 * 100 = 385200 полигонов при 26 fps

Оптимизированный шейдер для скелетной анимации в Delphi:
smd2v12-src.rar - исходники примера (230kb, VCL)
smd2v12-exe.rar - откомпилированный пример (354kb)

Георгий Мошкин