воскресенье, 8 ноября 2015 г.

OpenGL 4.4 Instancing. Часть 2.

В предыдущей главе мы рассмотрели несколько типов инстансинга, в частности нас больше всего интересовало рисование посредством glDrawElementsInstanced и glDrawElementsIndirect, а так же их производных – glMultiDrawElementsInstanced и glMultiDrawElementsIndirect. Давайте сравним результаты их работы.


Для сравнения производительности мною были написаны несколько демок с использованием некоторых из способов вывода инстансов. В качестве демки я взял пример «многа букф», в котором на экран выводится 10 000 символов (табличка 100х100):
Рис.1.

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

Пару слов о самом фреймворке. Для максимальной простоты демок я воспользовался некоторыми готовыми библиотеками, в частности:
·         FreeGlut – библиотека позволяющая создавать окно под Windows, Linux и Android, а так же реализует цикл отрисовки и получение ввода.
·         GLEW - библиотека загрузки расширений OpenGL. Используемая версия 1.10.0 содержит все расширения OpenGL 4.4.
·         GLM - математическая библиотека, предоставляющая все необходимые функции для 3D-графики.

Все перечисленные библиотеки являются OpenSource и кросплатформенными.
Сам фреймворк реализует несколько примитивов OpenGL для упрощения кода. В частности это объект шейдера (Shader), 2D-текстуры (Texture2D), меш (Mesh) и работу с SSBO. Так же была написан статический класс gl_helpers, который упрощает создание основных примитивов OpenGL и доступ к юниформам шейдера. Ну и чтоб было что показать – были заготовлены несколько примитивов – плоскость, шарик и специальный объект шрифтового меша (о нем я расскажу позже).

Так как окружение у всех демок одинаковое, то логика демок была вынесена отдельно и подключалась через модуль common.cpp. Для сборки использовался MinGW, сам проект создан в Code::blocks.
Для для выбора демки в common.h мы должны объявить дефайн с именем демки: «#define DEMO1», имена дефайнов можно посмотреть в common.cpp, там же, для оценки производительности имеется дефайн: BENCHMARK, включив его вы сможете получить результаты для каждого из тестов. Все результаты усреднены за 100 итераций.

Как мы уже рассмотрели в прошлой части статьи, не смотря на все разнообразие функций принципиальных отличий там всего два – это либо функции “glMultiDraw*” или “glDraw*Instanced*”. Принципиальное отличие между ними заключается в том, что «multi» версия разворачивается драйвером в серию команд glDraw*, в то время как версия с «instanced» честно выполняется на стороне GPU. Разницу в производительности можно оценить по следующему графику:
Рис.2.

На данном графике по вертикале отмечено время в миллисекундах, по горизонтали – количество выводимых примитивов (квадов).
Расшифровка названий:
DrawElements – каждый полигон выводится через отдельный вызов glDrawElements, взят для сравнения
DEInstanced – для вывода используется glDrawElementsInstanced
MDEIndirect – для вывода используется glMultiDrawElementsIndirect
MDEBV – для вывода используется glMultiDrawElementsBaseVertex
DEIBV – для вывода используется glDrawElementsInstancedBaseVertex
AttrDivisor – для вывода используется glDrawElementsInstanced с использованием делителя для аттрибутов

На графике отчетливо видно, что результаты разделились на три группы, первая группа, аутсайдеры, DrawElements и DrawElementsInstancedBaseVertex. Если с первой все понятно (разве что кроме скачка при 8к+ квадов), то поведение DrawElementsInstancedBaseVertex объясняется тем, что в примере количество инстансов было равно 1, тоесть работа данной функции была эквивалентна обычному вызову DrawElements, с тем отличием, что в первом случае позиция символа передавалась явно, через юниформы (2 x vec2), а во втором случае – передавался лишь номер базовой вершины (как параметр функции) и номер символа (2 х int32). Как видно с результатов, этого оказалось достаточно чтоб избежать боттлнека на передаче юниформ.

Вторая группа – это «мульти» версии – MultiDrawElementsBaseVertex и MultiDrawElementsIndirect. Как видно с графика – их результаты практически совпадают. Преимущество данных функций в том, что мы полностью избавились от передачи юниформ, передавая все необходимое через параметры функций, что дало примерно трехкратный прирост производительности относительно предыдущей группы. Это только в очередной раз подтвердило то, что мы и так знали из предыдущего исследования, что драйверу требуется намного больше усилий на передачу юниформы чем на передачу параметра или атрибута, причем количество передаваемых параметров функции MultiDrawElementsIndirect практически  не оказало влияния на результат, при этом мы имеем возможность выводить произвольную геометрию, включая инстансинг отдельных объектов, что существенно повысило гибкость при выводе сложной сцены.

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

Первым разберем способ вывода инстансов посредством glDrawElementsInstanced. Начнем с разбора вершинного шейдера:

#version 440
  out vec2 TexCoord;
  layout(location = 0) in vec3 in_Position;
  layout(location = 2) in vec2 in_TexCoord;
 
  layout(std140, binding=0) buffer Instances { vec4 offsets[]; };
 
  void main ()
  {
    //TexCoord as offset in the font atlas by InstanceID
    TexCoord = in_TexCoord.xy/16.0 + offsets[gl_InstanceID].zw;
    gl_Position = vec4(in_Position.xy+offsets[gl_InstanceID].xy, 0.0, 1.0);
  }

Так как все демки написаны под OpenGL 4.4 то я сразу указал версию шейдера. В шейдер у нас передается два вершинных атрибута – позиция и текстурные координаты. Так же, через SSBO, я передал массив со смещениями для каждого из инстансов. Так как предполагается вывод спрайтов в нормированных экранных координатах, то никакие видовые и проекционные трансформации нам не нужны. Размер символа был задан явно через вершинные координаты. Каждый элемент offsets содержит данные о смещении координат спрайта и смещение текстурных координат в атласе. Так как в демке я использовал текстурный атлас размером 16х16 символов, а оригинальные текстурные координаты спрайта было нормированные, то в качестве масштабного коэффициента использовалась 1/16. Дальше я просто прибавлял полученные смещения.

Результирующий код рисования выглядит так:
  InstanceOffsets->Bind(0); //Bind SSBO with instance data to 0 binding point
  plane->Bind(true); //Bind plane VBO buffer and draw instanced
  glDrawElementsInstanced(plane->getFaceType(), plane->getElementsCount(), GL_UNSIGNED_INT,0, INSTANCES_NUM);
  plane->Bind(false);

InstanceOffsets – содержит буфер SSBO со смещениями позиций и текстурных координат.
plane->Bind – бинд/сброс соответствующих буферов VBO
plane->getFaceType() – возвращает тип грани примитива, в нашем случае это GL_TRIANGLES
plane->getElementsCount() – в нашем случае это два треугольника или 6 элементов.

Полный код вы найдете в demo2.inl.

Таким образом, по заранее подготовленным данным, мы одной командой отрисовываем INSTANCES_NUM объектов.

Более интересный случай, это использование новой функции glMultiDrawElementsIndirect. В идеале я мог бы воспользоваться тем же кодом что и в предыдущем примере, с одной лишь разницей – заменив в шейдере gl_InstanceID на gl_DrawIDARB, или пойдя еще дальше, просто указав количество инстансов через командный буфер, получив результат полностью эквивалентный предыдущему примеру. Однако, использование командного буфера позволяет нам более гибко управлять параметрами инстансов, чем мы и воспользовались в предыдущей главе в Примере 4. Здесь мы сделаем то же самое. Сформируем буфер VBO в котором будут храниться координаты всех символов в текстуре:

    for (size_t i = 0; i < rows; ++i) {
      for (size_t j = 0; j < columns; ++j) {
        uint32_t idx = i*columns+j;
        float tx = float(j) / float(columns);
        float ty = float(i) / float(rows);

        float texcoords[8] = {
           tx,     (ty+h),
          (tx+w),  (ty+h),
          (tx+w),   ty,
           tx,      ty,
        };

        gl_helpers::uploadData(font_mesh->tId, sizeof(texcoords), texcoords, 
                               idx*sizeof(texcoords));
      }
    }

Координаты всех вершин спрайтов будут одинаковые, размер спрайта задается явно, в момент создания буфера:
    float vertices[12] = {
       0.0f,  0.0f, 0.0f,
       width, 0.0f, 0.0f,
       width, height, 0.0f,
       0.0f,  height, 0.0f,
    };

Это можно было бы сделать и в шейдере, предав размер через юниформы, но таким образом я сэкономил на одном умножении векторов J

Как я уже показывал в примере 4 из прошлой статьи, так как у нас координаты вершин всех спрайтов одинаковые, то нам достаточно уметь выводить только один четырехугольник, меняя лишь базовую вершину. Потому нам будет достаточно лишь объявить массив из 6 индексов (2 треугольника):
uint32_t indices[6] = { 0, 1, 2, 2, 3, 0 };

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

DrawElementsIndirectCommand *cmd = IndirectCmdBuffer->getData();
  for (size_t i = 0; i < INSTANCES_COUNT; ++i) {
    cmd->count = 6;
    cmd->primCount = 1;
    cmd->firstIndex = 0;
    cmd->baseVertex = (rand() % 256u)*4u; //fill base vertex for each of characters
    cmd->baseInstance = 0;
    ++cmd;
  }

Таким образом, я в цикле, прохожу по всем инстансам и для каждого из них заполняю значения командного буфера, при этом указываю, что количество элементов в инстансе у нас 6, количество примитивов, выводимых за один раз у нас 1 (иначе мы бы получили предыдущий пример), говорим что первым индексом у нас будет нулевой индекс, базовую вершину мы рассчитываем как код случайного символа * на 4 вершины квада символа. baseInstance мы не используем, потому он у нас всегда будет равен 0 (а в более ранних версиях OpenGL он вообще помечен как Reserved).

IndirectCmdBuffer – это GL_DRAW_INDIRECT_BUFFER, каждым элементом которого является DrawElementsIndirectCommand. При реализации SSBO я воспользовался расширением buffer_storage, для получения персистентной ссылки на буфер для более простого обновления данных в нем.
Ну и чтоб все это заработало нам нужно добавить вершинный шейдер:


  out vec2 TexCoord;
  layout(location = 0) in vec3 in_Position;
  layout(location = 2) in vec2 in_TexCoord;
  layout(std430, binding=0) buffer Instances { vec2 offsets[]; };
  void main ()  {
    TexCoord = in_TexCoord.xy;
    gl_Position = vec4(in_Position.xy+offsets[gl_DrawIDARB].xy, 0.0, 1.0);
  }

Код шейдера максимально простой, мы просто получаем смещение координат инстанса по номеру DrawCall, при этом текстурные координаты остаются без изменения. Единственное на что стоит обратить внимание – в отличии от предыдущего примера, у нас offsets имеет тип vec2, однако стандарт std140 предполагает выравнивание на границу vec4, поэтому здесь указан стандарт std430, чтоб на стороне CPU было проще формировать данные об инстансах.

Все что нам теперь осталось сделать – отрисовать это все через MultiDrawElementsIndirect:
  glBindBuffer(GL_DRAW_INDIRECT_BUFFER, IndirectCmdBuffer->getBuffId());
  OffsetsBuffer->Bind(0);
  plane->Bind(true);
    glMultiDrawElementsIndirect(plane->getFaceType(), GL_UNSIGNED_INT, NULL, INSTANCES_NUM, 0);
  plane->Bind(false);

При этом, командный буфер мы можем хранить как в видеопамяти (как на примере выше) так и в системной памяти, подставив в качестве третьего параметра IndirectCommands в вызов glMultiDrawElementsIndirect вместо NULL. На производительности это не сказывается, но открывает ряд возможностей по формированию командного буфера в вычислительном шейдере. Полный код примера можно найти в demo3.inl.

Ну и в завершение разберу пример из первой группы с использованием DrawElementsInstancedBaseVertex, не смотря на то, что результат она показала не самый лучший.


  uniform int InstanceID;
  out vec2 TexCoord;

  layout(location = 0) in vec3 in_Position;
  layout(location = 2) in vec2 in_TexCoord;

  layout(std430, binding=0) buffer Instances { vec2 offsets[]; };

  void main ()
  {
    TexCoord = in_TexCoord.xy;
    gl_Position = vec4(in_Position.xy+offsets[InstanceID].xy, 0.0, 1.0);
  }

Из кода шейдера видно что у нас так же есть массив смещений символов, но номер инстанса мы передаем через юниформу. Сам процесс отрисовки выглядит так:

  OffsetsBuffer->Bind(0);
  //Bind plane VBO buffer and draw instanced
  plane->Bind(true);
    for (size_t i = 0; i < INSTANCES_NUM; ++i) {
      gl_helpers::setUniform(shader, "InstanceID", i); //set InstanceID value
      //Draw 1 instance with corresponding base vertex
      glDrawElementsInstancedBaseVertex(plane->getFaceType(), elemCounts[i], GL_UNSIGNED_INT, 0, 1, baseVertices[i]);
    }
  plane->Bind(false);

Соответствующий код можно найти в примере demo5.inl

Однако, за кадром остался еще один интересный эффект – область на графике до 1600 квадов, после которой происходит скачок:
Рис.3.
Как видно из графика – за счет каких-то оптимизаций, при небольшом количестве полигонов, DrawElements вполне может дать фору «мульти» версиям команд J



Для того чтоб немного разнообразить примеры я решил проверить производительность наиболее перспективных техник на выводе большого объема динамических данных. В качестве такового была выбрана книга «Война и Мир». Текст выводится постранично, каждая страница заполняется отдельно, таким образом можно сравнить не только скорость рендеринга инстансов но и скорость обновления данных. Однако, уже после реализации данного демо, исключив из кода все статические данные, получилось что при использовании DrawElementsInstanced для задания символа необходимо передать значение vec4, При использовании MultiDrawElementsIndirect достаточно передачи одного GLuint и одного vec2, что в сумме всего на 4 байта меньше чем в предыдущем примере. 




В результате, вывод тома «Война и Мир» (загрузка данных в видеопамять + отрисовка) с использованием DrawElementsInstanced  потребовал 7940мс, на MultiDrawElementsIndirect ушло 12362мс. Таким образом, DrawElementsInstanced оказался в 1,56 раза быстрее. Если сравнивать с выводом статических инстансов, то разрыв между этими техниками оказался существенно меньше (1,56 против 3,24 для статики), возможно из-за сокращения объема передаваемых данных на 4 байта.
Полный код демо вы можете найти в demo7.inl и demo8.inl соответственно.


Таким образом, если у вас небольшое число инстансов (до 1600 квадов) можно использовать любую технику, если преобладают статические данные – стоит обратить внимание на использование функций инстансинга (*Draw*Instanced), если же стоит вопрос гибкости и акцент идет на динамические данные – пожертвовав парой микросекунд вполне можно использовать MultiDrawElementsIndirect. 

Фреймворк с полным кодом описанных демо можно скачать тут:
https://sites.google.com/site/vbomesh/GL4xInstancing.zip

Предыдущая часть.

Комментариев нет:

Отправить комментарий