Использование вершинных шейдеров для ускорения расчётов
Некоторые функции выполняются
на процессоре видеокарты (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)
Георгий Мошкин
|