воскресенье, 22 июля 2012 г.

Инстансинг. Часть вторая, псевдоинстансинг.

В предыдущей части статьи мы разобрались с основной проблемой инстансинга и убедились что оптимизировав алгоритм рендеринга можно существенно повысить производительность. Но все эти оптимизации касались лишь алгоритмов, сейчас же мы разберем оптимизацию последней части - установку трансформаций. Почему это так важно - потому что эта процедура находится внутри цикла и выполняется тысячи раз, вызываясь для каждого сабмеша каждого меша, потому даже небольшой выигрыш способен дать существенный прирост производительности.
Предположим что наш движок достаточно продвинутый, чтоб заранее вычислить модельную матрицу каждого объекта, с учетом иерархии объектов и всякие glPush/PopMatrix и glMultMatrix остались в прошлом. В итоге остается два способа установки трансформаций - использование прямой загрузки матрицы трансформации через glLoadMatrix и передача этой самой матрицы через юниформы шейдера (glUniformMatrix4fv),  выбор зависит от версии OpenGL и личных предпочтений. Как же можно оптимизировать одну команду? Оказывается можно, данный подход был изложен в презентации NVidia: GLSL Pseudo-Instancing.
Рис.1. Сравнение производительности инстансинга и псевдоинстансинга


В первую очередь нужно вспомнить что OpenGL для трансформации использует модельно-видовую матрицу, которую вначале нужно получить. Как она получается - мы на каждом кадре рендера берем текущую модельную матрицу камеры (естественно обратную) и умножаем ее на модельную матрицу объекта. Тут так же два пути - мы можем это сделать на стороне CPU, просто перемножив эти матрицы, и можем заставить сделать за нас OpenGL, воспользовавшись серией команд glPushMatrix+glMultMatrix+glPopMatrix. В первом случае мы сильно нагружаем CPU, во-втором - точно так же нагружаем CPU (эти преобразования выполняются драйвером на стороне CPU, попутно вычисляются NormalMatrix, MVP, обратные матрицы, и только после этого результирующие матрицы отправляются в шейдер). Таким образом, для 1000 инстансов нам придется выполнить 1000 операций умножения матриц на каждом кадре (можно предположить что видовая матрица не будет меняться каждый кадр и оптимизировать расчеты, но это лишь частный случай решения). Для CPU, с его скалярной архитектурой, это может оказаться серьезной проблемой, что мы и видели на примере кубика с 12 полигонами и шарика с 270 полигонами, с другой стороны, GPU с такой задачей справляется "на ура", но передача еще одной матрицы трансформации из 16 флоатов так же требует времени.

Так как матрица вида одинакова для всех объектов на сцене, то NVidia предложила очень простое решение - вынести установку этой матрицы за цикл, а в цикле передавать лишь модельную матрицу объекта, рассчитывая конечную модельно-видовую матрицу прямо в шейдере. Выглядит это примерно так:
 Листинг 3.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
for i:=0 to MasterProxy.SubMeshes.Count-1 do begin
  SubMesh:=MasterProxy.SubMeshes[i];
  SubMesh.ApplyShader;
  SubMesh.ApplyMaterial;
  SubMesh.ApplyTexture;
  SubMesh.ApplyBlending;
  SubMesh.ApplyVBO;
  CurrentShader.SetViewMatrix;
  for j:=0 to MeshObjects.Count-1 do begin
    CurrentShader.SetModelMatrix(MeshObjects[j].ModelMatrix);
      SubMesh.Render;
  end; 
  SubMesh.UnApplyVBO;
  SubMesh.UnApplyTransformations;
  SubMesh.UnApplyBlending; 
  SubMesh.UnApplyTexture;
  SubMesh.UnApplyMaterial;
  SubMesh.UnApplyShader;
end;

Отличие от предыдущего примера только в этих двух строчках (8 и 10), в которых и происходит установка соответствующих матриц. Но на этом оптимизации не заканчиваются. Никто лучше инженеров NVidia не знает работу их же драйверов, а они пишут:
Driver must map abstract uniform variables into real physical hardware registers. From the hardware’s perspective, the large number of constant updates is not ideal either. Constant updates can incur hardware flushes in the vertex processing engines.
Ну или по русски - драйверу приходится отображать абстрактные переменные, соответствующие юниформам, в реальную физическую память, потому, с точки зрения GPU, большое число обновлений констант далеко не лучшее решение и может привести к простою GPU.  Так же они пишут что:
OpenGL has a notion of persistent vertex attributes that can be passed down by immediate mode calls like glTexCoord(). These API calls are very efficient on the driver side; they don’t require validation or potentially complex remapping. They are also very efficient on the hardware side; they do not result in hardware flushes in the vertex processing engines.
в OpenGL имеется понятие постоянных вершинных атрибутов (таких как glTexCoord), которые могут быть переданы в режиме незамедлительного исполнения. Эти вызовы API очень производительные на стороне драйвера, так как не требуют валидации и потенциально сложных отображений. Так же они очень эффективны на стороне GPU, так как не приводят к его простою.

Резюмируя написанное - NVidia рекомендует использовать общие вершинные атрибуты для того, чтоб эффективно передавать модельные матрицы множества инстансов.
Так как видовая матрица передается только один раз на все иснтансы, то ее можно передавать обычными средствами, через юниформы.
Учитывая вышесказанное, процедура "SetModelMatrix", из примера выше, запишется в таком вот виде:
Листинг 4.
1
2
3
4
5
6
procedure SetModelMatrix(const WordlMatrix: TMatrix4f);  
begin
  glMultiTexCoord4fv(GL_TEXTURE1, WorldMatrix[0]);
  glMultiTexCoord4fv(GL_TEXTURE2, WorldMatrix[1]);
  glMultiTexCoord4fv(GL_TEXTURE3, WorldMatrix[2]);
end;


Так же стоит обратить внимание на тот факт, что мы передаем только три строки матрицы трансформации, так как четвертая строка всегда имеет вид (0,0,0,1) и может быть "восстановлена" в шейдере, а мы таким образом сэкономили передачу 16 байт.
В случае OpenGL 3.x+ у нас уже нет стандартных вершинных атрибутов, но мы таким же образом можем воспользоваться пользовательскими атрибутами, устанавливаемыми через glVertexAttrib.

Ну и наконец мы можем немного оптимизировать шейдер, заменив умножение матриц на скалярное произведение, таким образом шейдер псевдоинстансинга примет вид:
Листинг 5.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
uniform mat4 viewMatrix;     
uniform vec3 lightPositionView;
varying vec4 color;

void main(void)  
{
  vec4 positionWorld;
  vec4 positionView; 
  positionWorld.x = dot(gl_MultiTexCoord1, gl_Vertex);  
  positionWorld.y = dot(gl_MultiTexCoord2, gl_Vertex);  
  positionWorld.z = dot(gl_MultiTexCoord3, gl_Vertex);  
  positionWorld.w = 1.0;  
  positionView = viewMatrix * positionWorld;  
  gl_Position = gl_ProjectionMatrix * positionView;  

  vec3 normalWorld;
  normalWorld.x = dot(gl_MultiTexCoord1.xyz, gl_Normal);
  normalWorld.y = dot(gl_MultiTexCoord2.xyz, gl_Normal);
  normalWorld.z = dot(gl_MultiTexCoord3.xyz, gl_Normal);
  normalWorld = normalize(normalWorld);

  vec3 normalView = mat3(viewMatrix) * normalWorld;
  vec3 lightVectorView = normalize(lightPositionView - positionView.xyz);
  vec3 eyeVectorView = normalize(-positionView.xyz);
  color.xyz = ((0.8 * max(dot(normalView, lightVectorView), 0.0)) + 0.2) * gl_Color.xyz;  
} 

Здесь я привел полный шейдер, включающий трансформацию вершины, нормали и простейший расчет освещения. Здесь - viewMatrix - глобальная матрица вида, lightPositionView - позиция источника света в пространстве камеры, positionWorld/normalWorld - позиция вершины/нормаль в мировом пространстве, после трансформации нашей модельной матрицей, positionView/normalView - позиция вершины/нормаль в пространстве камеры, ну и color - цвет вершины с учетом освещения.

К сожалению, в рамках движка (VBOMesh или GLScene) реализовать эту технику несколько проблематично, так как нужно перекрыть стандартный рендер объекта (в GLScene без создания нового класса объекта, со своим рендером, это вообще невозможно сделать), потому для теста я реализовал простейший рендер на базе VBOMesh:
https://sites.google.com/site/vbomesh/ProxyPseudoInst.rar

В демке, относительно предыдущей, добавилось два типа рендера - PseudoInstancing(Uniform) PseudoInstancing(Generic). В чем отличие между данными подходами - в "Generic" используется описанный выше подход, с передачей матриц трансформации через стандартные вершинные атрибуты, в "Uniform" - матрица трансформации передается через юниформы.

Для начала сравним результаты тестов, так как их очень много, то сведу их в таблицу:

Тип / фпс
Proxy
Instancing
Pseudo(Uniform)
Pseudo(Generic)
Фриформа(366)
60
304
343
 309
Куб (12)
169
 500
 650
 1359
Сфера1 (270)
169
 500
654
572
Сфера2 (1054)
167
 190
 192
182

В первую очередь из таблицы видно что любой инстансинг существенно превосходит по производительности рендеринг инстансов как отдельных объектов (Proxy), дальше ситуация интереснее, потому разберем попарно три случая: Instancing-Uniform, Instancing-Generic и Uniform-Generic.

Первый случай - Instancing-Uniform, тут все предсказуемо, после того, как мы избавились от перемножения матриц на стороне CPU мы получили небольшой прирост фпс для всех объектов, особенно это заметно на кубе и сфере1, прирост составил целых 150фпс. Тем не менее, разницы между 12 и 270 полигонами все так же нет, что сигнализирует о том, что рисование происходит быстрее чем передача параметров. Ситуация с выводом сферы2 противоположна - рендеринг длится существенно дольше передачи параметров (мы помним что рендеринг VBO ассинхронный), потому за время рендеринга инстанса CPU успевает передать новую порцию данных.

Второй случай - Instancing-Generic - его поведение практически идентично предыдущему случаю, почти везде мы имеем существенный прирост производительности, особенно при выводе низкополигональной геометрии, так вывод 1024 псевдоинстансов кубов оказался в 8 раз быстрее вывода прокси и в 2.7 раза быстрее вывода инстансов, но вот при выводе высокополигональной сцены мы получили непонятное падение производительности, да и остальные цифры оказались ниже чем у юниформ-инстансинга...

Третий случай - Uniform-Generic. Это самое интересное, так как так расхваленный мной (ну и NVidia) метод оказался в большинстве случаев намного хуже прямой передачи юниформы с мировой матрицей объекта, можно было-бы начать ругаться, но 2-х кратный прирост производительности на кубике заставляет задуматься... Почему же так? Увы, точный ответ мне не известен (если кто может дать пруфлинк - буду благодарен), но частично природу такого поведения раскрывает последний случай - вывод сферы с 1054 полигонами, а именно тот факт, что фпс даже ниже чем при прямом выводе инстансов. Наиболее правдоподобным является предположение, основанное на природе общих атрибутов. Суть в том, что стандартные вершинные атрибуты, переданные через glColor, glNormal, glMultiTexCoord и прочие, дублируются для всех последующих вершин (остается вопрос где именно происходит это дублирование - драйвером на стороне CPU или GPU, но суть от этого не меняется). Ели вершин не много (как у куба), то это происходит очень быстро, о чем нам и рассказывала NVidia, но с ростом числа вершин необходимо дублировать все больше и больше вершин, в результате это оказывается дороже прямой передачи юниформы.

Таким образом, выбирая между "Uniform" и "Generic"- инстансингом нужно ориентироваться на количество полигонов у инстанса, при малом количестве полигонов (до сотни) и жалании выжать максимум фпс - следует отдать преимущество "Generic"-инстансингу, при большем количестве полигонов и нежелании лишний раз перегружать шину лишними данными - лучше воспользоваться "Uniform"-инстансингом, если же количество полигонов больше тысячи - то можно смело использовать обычный инстансинг, получая соизмеримый фпс, но избавившись от использования шейдера.

Ну и напоследок можно напомнить что аналогичным образом мы можем передавать в шейдер и другие данные, к примеру идентификатор инстанса, и по нему делать выборку свойств материала или координат текстурных блоков, к примеру, из заранее переданного массива/буфера юниформ, или констант, включенных прямо в код шейдера. Таким образом можно не только клонировать объекты но и добавить им разнообразия.


С выходом OpenGL 3.0 у программистов появился еще один инструмент под названием "аппартный инстансинг", реализуемый посредством команд "glDraw*Instanced" и новой константы в шейдере "gl_InstanceID", которая является тем самым идентификатором инстанса. Подробнее можно почитать здесь:
Расширение EXT_draw_instanced/ARB_draw_instanced

Тем не менее это достаточно спорное нововведение. С одной стороны - это позволяет снизить количество вызовов команд "glDraw*" до одной, с другой стороны - это же является и проблемой, так как мы теперь рисуем абсолютно все инстансы, не зависимо от их видимости, в то время как псевдоинстансинг позволяет нам контролировать вывод каждого из инстансов.   В результате может оказаться такая ситуация, что при фрастум куллинге серии инстансов, фпс псевдоинстансинга окажется выше чем у аппаратного инстансинга. Частично этого удается избежать за счет группировки инстансов в блоки, для каждого из которых отдельно проводится проверка видимости и вывод этого блока через один вызов "glDraw*Instanced", но при работе с "относительно высокополигональными" инстансами (особенно если к ним применятся сложный вершинный шейдер, к примеру скелетной анимации), может оказаться что отбрасывание лишь одного инстанса покроет весь выигрыш от использования аппаратного инстансинга. Если же все инстансы видимы, то тут аппаратный инстансинг является бесспорным лидером, что можно увидеть в этой демке:

OpenGL Geometry Instancing: GeForce GTX 480 vs Radeon HD 5870

В эпоху геометрических шейдеров появился еще один способ клонирования объектов, путем копирования геометрии существующего объекта прямо в геометрическом шейдере, собенно это актуально для низкополигональной геометрии, такой как спрайты, частицы, листва, трава и прочее. Так же, объединив возможности аппаратного инстансинга и геометрического шейдера можно реализовать аппаратную проверку видимости инстанса перед его отрисовкой, о реализации данной техники можно почитать в этой статье:
Instance culling using geometry shaders

или глянуть в этой демке:
http://rastergrid.com/blog/wp-content/uploads/2010/06/nature12_win32.zip

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

7 комментариев:

  1. Отличная и полезная статья, спасибо тебе,пиши ещё, очень полезно и интересно.
    Алексей, а можешь выложить обновленные модули VBO, а то для запуска демки нету объявленного метода
    RenderMeshes(Buff^,Buff.SubMeshes);
    а также:
    VBOWorld.AddRenderEvent(RenderingInstances);
    __
    Хотя все модули обновлены с репозитория, только вот они там не самой свежей версии лежат.
    И кстати, по ссылке нет страницы: http://vbomesh.blogspot.com/p/blog-page.html

    ОтветитьУдалить
  2. Я щас вношу ряд изменений в модули VBOMesh, как закончу - выложу обновление.
    В принципе это простые методы, RenderMeshes уже есть в uVBO, нужно просто его объявить в interface.

    Метод AddRenderEvent тоже очень простой:
    function TMeshCollection.AddRenderEvent(
    REvent: TObjectRenderEvents): TRenderEventItem;
    var mi: TRenderEventItem;
    begin
    mi:=TRenderEventItem.Create;
    mi.Name:='RenderEvent'+inttostr(FMeshList.Count); Parent:=nil;
    mi.RenderEvent:=REvent;
    result:=mi; FMeshList.Add(mi);
    FStructureChanged:=true;
    end;

    Но можно и без него обойтись, просто поместить соответствующий обработчик в событие в любое доступное событие, это либо TVBOMesh.onBeforeRender/onAfterRender, или onBeforeRender/onAfterRender мастер объекта.

    На счет ссылок - вроде поправил. Блог что-то с ума сходит, начал страницам давать одинаковые имена и гробить существующие ссылки...

    ОтветитьУдалить
  3. Спасибо за ответы.
    Буду с нетерпением ждать обновленные модули, особенно что касается правильного отображения анимированных моделей, а также проверку OcclusionCulling, ну и всего остального, конечно же. :)

    ОтветитьУдалить
  4. Спасибо, отличные статьи!
    На моей старенькой GeForce 7600 GS 256MB такие результаты:
    --Instancing--
    Фриформа(366): 14,8 фпс
    Куб (12): 65,2 фпс
    Сфера1 (270): 29,7 фпс
    Сфера2 (1054): 7,6 фпс
    --Proxy--
    Фриформа(366): 7,1 фпс
    Куб (12): 20,1 фпс
    Сфера1 (270): 20,1 фпс
    Сфера2 (1054): 7,6 фпс
    --Pseudo(Uniform)--
    Фриформа(366): 13,8 фпс
    Куб (12): 86,5 фпс
    Сфера1 (270): 25,8 фпс
    Сфера2 (1054): 6,1 фпс
    --Pseudo(Generic)--
    Фриформа(366): 13,2 фпс
    Куб (12): 172 фпс
    Сфера1 (270): 25,6 фпс
    Сфера2 (1054): 5,6 фпс

    Неудобно, что в демке при выборе Pseudo рендерятся сразу все модели, хотелось еще так сказать в динамике глянуть)

    ОтветитьУдалить
  5. Еще пожелание к теме следующей статьи: можешь рассказать архитектуру/структуру своего движка, как хранятся/взаимодействуют различные элементы/системы движка, как ссылаются друг на друга(на прямую, по ссылкам,), обращения к еще недогруженным, устройтво хранилища, и прочее, почему остановился (вкратце) именно на таких решениях. Ну короче такая обзорная статья, объясняющая общий принцип работы, с пояснением некоторых ньансов.
    Это так, если не сильно затруднит :)

    ОтветитьУдалить
  6. "рендерятся сразу все модели" - имеешь в виду без проверки видимости? Проблема в том, что для проверки видимости нужно произвести трансформацию координат инстанса модельно-видовой матрицей, а мы как раз избавились от этой процедуры, перенеся вычисление модельно-видовой матрицы в шейдер. Можно было бы пойти другим путем, просто вычислять вектор от камеры до инстанса и отбрасывать все объекты, для которых косинус угла отрицателен или больше FOV, это существенно быстрее перемножения матрицы с последующей трансформацией координат инстанса и проверки на попадание в пирамиду видимости. Я подумаю над тем, чтоб добавить это в демку.
    На счет "увидеть в динамике" - ну можно прокрутить ползунок количества объектов, для псевдоинстансинга эта процедура выполняется мгновенно.

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

    Статья по архитектуре самого движка тоже будет, пока можешь глянуть "VBOMesh.png" в корне репозитория проекта:
    http://glsnewton.googlecode.com/svn/trunk/VBOMesh.png

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

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

    ОтветитьУдалить
  7. Ок, спасибо!
    С нетерпением жду новых статей:)

    ОтветитьУдалить