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

OpenGL 4.4 Instancing. Часть 1.


Как же быстро идет время, казалось бы, еще вчера анонсировали OpenGL 4.4, сел я делать краткий обзор по новинкам и вот уже и 2015-й заканчивается, а Khronos Group уже трубит о Vulkan… Отсутствие свободного времени, а также анонс нового API убавили энтузиазма, и эта статья пропылилась в закромах черновиков два года. Однако, «Рим строился не за один день», и пока Vulkan API мне не удалось даже пощупать, так что эта тема может быть для кого-то все еще актуальна. Переписывать статью уже лень, так что оставил все как было два года назад J 
Итак, в древние времена я выложил несколько статей по инстансингу и выводу GUI средствами OpenGL 2.1. Однако время идет и все меняется, так вот на улице уже откуда ни возьмись 2014 год и OpenGL 4.4, а по всем каналам трубят о том, какая крутая штука этот новый инстансинг. Давайте и мы разберемся какие средства для инстансинга нам предоставляет OpenGL 4.4.


Для начала думаю стоит упомянуть соответствующие расширения:

Как видите, список расширений, появившийся со времен OpenGL 4.0 достаточно объемный, список предоставляемых ими команд – еще больше:
Таблица 1

DrawArrays
DrawElements
Single Draw

Multi Draw
MultiDrawArraysIndirectCountARB


Для удобства я их сгруппировал в таблице по способам отрисовки, так функции в колонке «DrawElements» требуют наличия индексного буфера, а в колонке DrawArrays – нет. В строке «Single Draw» представлены функция отрисовки одного пакета данных, в «Multi Draw» - позволяющие отрисовать за один раз несколько пакетов данных.
Как видно из таблицы, данные функции отличаются наличием в названии той или иной части: Indirect, Instanced, BaseInstance, BaseVertex и их комбинациями. Разберем смысл каждой из них.
Для начала вспомним немного сам процесс подготовки и отрисовки данных в случае использования индексного буфера (DrawElements):
  1. Создать и заполнить данными буферы вершинных атрибутов;
  2. Создать индексный буфер, по которому из массива вершинных координат будет формироваться список примитивов для отрисовки;
  3. Сгенерировать буферы VBO для каждого атрибута и загрузить данные в видеопамять.
  4. Привязать атрибуты к шейдеру и активировать их.
  5. Вызвать процедуру отрисовки, передав в качестве параметров тип примитивов, количество  элементов и смещение в индексном буфере.


Получив индексный буфер и смещение в нем, GPU берет первый индекс в списке и по нему выбирает соответствующую вершину, потом берест следующий индекс и так далее, пока не отрисуется заданное количество примитивов. Таким образом мы выводим один объект целиком.
Однако, иногда бывает необходимо вывести только часть этого буфера, к примеру, если в нем хранится информация о нескольких объектах. В таком случае, нам нужно иметь информацию о структуре буфера, как минимум – смещение в индексном буфере и количество элементов объекта:
struct ObjectInfo {
  GLuint offset; //Offset to the first element of the object
  GLsizei count; //Object’s elements number
}

Далее, мы можем создать список из ObjectInfo и в цикле отрисовать все объекты, к примеру так:
ObjectInfo objects[OBJS_COUNT];
[Fill objects info]
For (size_t i = 0; i < OBJS_COUNT; ++i)
  glDrawElements(face_type, objects[i].count, index_type, objects[i].offset);

Иногда может потребоваться вывести какой-то объект несколько раз, для этого мы можем легко модифицировать нашу структуру, добавив количество инстансов каждого из объектов:
struct ObjectInfo {
  GLuint offset; //Offset to the first element of the object
  GLsizei count; //Object’s elements number
  GLsizei instances; //Object’s instances number
}

И выводить это примерно таким образом:
Пример 1
for (size_t i = 0; i < OBJS_COUNT; ++i) {
  for (size_t j = 0; j < objects[i].instances; ++j) {
    //Send instanceId to a shader
    glProgramUniform1ui(progId, glGetUniformLocation(progId, “instanceId”),  j);
    glDrawElements(face_type, objects[i].count, index_type, objects[i].offset);
  }
}

Здесь я ввел дополнительную юниформу “instanceId”, через которую можно передать номер выводимого инстанса объекта. Используя эту информацию в шейдере можно выполнять какие-то действия с инстансом, к примеру – вычислять его позицию.
Ну и третий возможный вариант, это когда у нас все примитивы одинаковые, к примеру – квады (4 вершины, из которых составлено 2 треугольника посредством 6 индексов), но эти квады могут отличаются разными вершинными атрибутами, к примеру – текстурными координатами или позицией. 

Простой пример – у нас есть буфер VBO в котором каждый квад описывает один символ в текстурном атласе шрифта. У каждого квада вершинные атрибуты будут свои, к примеру – может отличаться ширина квада для немоноширного шрифта, и точно будут отличаться текстурные координаты для соответствия нужному символу. Но вот набор индексов для всех квадов будет одинаковый, обычно это {0,1,2,2,3,0}, но если все квады находятся в одном буфере VBO, то нам к номеру каждой вершины нужно будет добавить какое-то смещение baseVertex, в случае квадов baseVertex будет равен номеру квада * 4 вершины квада. Тоесть для первого квада мы используем индексы {0,1,2,2,3,0}, для второго – {0+4,1+4,2+4,2+4,3+4,0+4}, для третьего - {0+8,1+8,2+8,2+8,3+8,0+8}, и т.д. В общем случае это будет: {0+baseVertex,1+baseVertex,2+baseVertex,2+baseVertex,3+baseVertex,0+baseVertex }.
Для ясности запишем данный пример:
Пример 2

 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
const uint32_t CHARS_COUNT = 256;
vec2 vertices[CHARS_COUNT*4];
vec2 texcoords[CHARS_COUNT*4];
GLuint indices[CHARS_COUNT*6];
const float CHAR_SIZE = 1.0f / 16.0f;
for (size_t i = 0; i < CHARS_COUNT; ++i) {
  vertices[i*4+0].x = 0.0f;               vertices[i*4+0].y = 0.0f;
  vertices[i*4+1].x = 1.0f;               vertices[i*4+1].y = 0.0f;
  vertices[i*4+2].x = 1.0f;               vertices[i*4+2].y = 1.0f;
  vertices[i*4+3].x = 0.0f;               vertices[i*4+3].y = 1.0f;
 
  float tx = float(i % 16) / 16.0f; float ty = float(i / 16) / 16.0f;
  texcoords [i*4+0].x =tx; texcoords [i*4+0].y = ty;
  texcoords [i*4+1].x = tx+CHAR_SIZE; texcoords [i*4+1].y = ty;
  texcoords [i*4+2].x = tx+CHAR_SIZE; texcoords [i*4+2].y = ty+CHAR_SIZE;
  texcoords [i*4+3].x = tx; texcoords [i*4+3].y = ty+CHAR_SIZE;
 
  GLuint baseVertex = i*4;
  indices[i*6+0] = 0+baseVertex; 
  indices[i*6+1] = 1+baseVertex; 
  indices[i*6+2] = 2+baseVertex;
  indices[i*6+3] = 2+baseVertex; 
  indices[i*6+4] = 3+baseVertex; 
  indices[i*6+5] = 0+baseVertex;
}

В примере мы создаем массив из CHARS_COUNT квадов, при этом координаты вершин всех квадов одинаковые.  Предположив, что наш атлас из 256 символов представлен в виде таблички 16х16 символов, тогда в качестве текстурных координат мы берем позицию символа в таблице (текстурные координаты нормируем к 1.0). Так же хочу заметить, что текстурные координаты отличаются для каждого из квадов. Так как каждый квад у нас состоит из двух треугольников, то мы заполняем индексы соответствующих вершин этого квада. Так же хочу обратить внимание на то, что индексы для всех треугольников одинаковые, но смещены на величину baseVertex, которая вычисляется как номер символов * количество вершин в символе. Теперь мы сможем вывести все эти символы (и любое слово, составленное из них) воспользовавшись кодом из примера 1.
Собственно долгое время именно таки и делалось, но, каждый вызов команды glDraw* требует определенных манипуляций на стороне драйвера, что в свою очередь существенно сказывается на производительности при выводе множества объектов. Чтоб как-то исправить это и были добавлены расширения, о которых упоминалось в начале статьи. Давайте посмотрим как новые расширения могут помочь в решении этих задач.
Рассмотрим первый пример – вывод инстансов объектов, для этого нам помогут команды со словом «Instanced»:
for (size_t i = 0; i < OBJS_COUNT; ++i) {
  glDrawElementsInstanced(face_type, objects[i].count, index_type, objects[i].offset, objects[i].instances);
}

Как видно из синтаксиса – эта команда полностью эквивалентна команде glDrawElements, с тем отличием, что последним параметром она принимает количество выводимых инстансов. При этом, данная команда автоматически устанавливает значение встроенной юниформы gl_InstanceID.
Разработчики утверждают что это должно дать существенный прирост производительности, давайте проверим это, сравнив старый и новый варианты:
Рис.1.

На графике синяя линия соответствует времени отрисовки с использованием glDrawElements, красная – инстансинг через glDrawElementsInstanced. По оси Х показано количество выводимых инстансов, по оси Y – время в микросекундах. Как видно из графика – чем больше количество инстансов тем больший отрыв у glDrawElementsInstanced.


Теперь рассмотрим второй пример. В нем у нас все индексы отличались лишь номером базовой вершины. Чтоб не хранить огромные индексные списки были добавлены команды со словом «BaseVertex». Благодаря этому, вместо того, чтоб формировать список индексов на CHARS_COUNT*6 элементов, мы можем создать список всего на 6 элементов и использовать его при отрисовке любого из символов, лишь передавая значение базовой вершины. В нашем примере это значение вычислялось как номер объекта * 4:
Пример 3
GLuint indices[6] = { 0, 1, 2, 2, 3, 0 };
[Fill vertex and texture coordinates and build VBO buffers]
for (size_t i = 0; i < OBJS_COUNT; ++i) {
  glDrawElementsInstancedBaseVertex(face_type, objects[i].count, index_type, objects[i].offset, objects[i].instances, i*4);
  }

В данном примере я воспользовался функцией glDrawElementsInstancedBaseVertex, данная функция просто расширила возможности функции glDrawElementsInstanced за счет добавления базового индекса. Так же, в расширении shader_draw_parameters появилась возможность получить в шейдере значение baseVertex через встроенную юниформу «gl_BaseVertexARB». В целом – мы уменьшили размер индексного буфера и существенно упростили код генерации этого буфера.

Рассмотренные примеры дают существенный выигрыш в производительности и компактности кода, но ничего принципиально нового они не добавляют. Однако, с появлением вычислительных шейдеров (а ранее – с появлением Transform feedback и ImageLoadStore и даже R2VB) появилась возможность формировать данные для отрисовки «налету», формируя в шейдере как вершинные атрибуты так и параметры для отрисовки инстансов. Однако, все эти преимущества упираются в то, что рисовать эти инстансы все так же нужно через glDraw, явно передавая параметры отрисовки. Логичным шагом было реализовать неявную, не прямую передачу этих параметров в процедуру рисования, для этого были добавлены команды со словом «Indirect». Команда glDraw*Indirect принимает на вход специальную структуру, описывающую параметры отрисовки инстанса:

typedef struct {
  GLuint count;     //number of elements to be rendered.  
  GLuint primCount; //number of instances of the indexed geometry that should be drawn.
  GLuint firstIndex; //first element index  
  GLuint baseVertex; //constant that should be added to each element of indices.
  GLuint baseInstance; //base instance for use in fetching instanced vertex attributes.
} DrawElementsIndirectCommand;
Каждое поле данной структуры эквивалентно параметрам функции: glDrawElementsInstancedBaseVertexBaseInstance
Сама же структура может находиться как в системной памяти так и в буфере со специальным типом GL_DRAW_INDIRECT_BUFFER. Благодаря этому мы получили возможность формировать данный командный буфер на GPU.

Перепишем пример 3 на использование glDrawElementsIndirect:
Пример 4

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
DrawElementsIndirectCommand indirect[OBJS_COUNT];
//Filling command buffer
for (size_t i = 0; i < OBJS_COUNT; ++i) {
  indirect.count = objects[i].count ;
  indirect.primCount = objects[i].instances;
  indirect.firstIndex = 0;
  indirect.baseVertex = i*4;
  indirect.baseInstance = 0;
}
for (size_t i = 0; i < OBJS_COUNT; ++i) {
  glDrawElementsIndirect(face_type, index_type, &indirect[i]);
}

В данном примере команды хранятся в массиве indirect в системной памяти, но аналогично могут быть помещены в соответствующий буфер SSBO.
Вся прелесть от использования командного буфера раскрывается при использовании мульти-версий этой команды, так как glMultiDrawElementsIndirect позволяет за один вызов отрисовать все содержимое командного буфера! Таким образом, пример 4 может быт легко переписан в такой форме:
[Fill command buffer]
glMultiDrawElementsIndirect(face_type, indice_type, indirect, OBJS_COUNT, 0);
Разве это не прелесть? J

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

Казалось бы, теперь у нас есть все, для того, чтоб генерировать командный буфер на лету, но кое-что мы забыли – нам кроме самих команд надо бы знать и количество команд в буфере. Можно конечно извратиться и сохранить это количество команд в каком-то атомарном счетчике, потом прочесть его на стороне CPU и предать его значение в качестве предпоследнего параметра в glMultiDrawElementsIndirect. К счастью, в этом нет необходимости. В расширении indirect_parameters появилась возможность передавать количество команд как буфер специального типа GL_PARAMETER_BUFFER_ARB. Для этого предназначена функция glMultiDrawElementsIndirectCountARB. Семантически она практически не отличается от glMultiDrawElementsIndirect, с той лишь разницей, что вместо явной передачи количества команд мы передаем указатель на переменную/буфер + указываем сколько максимально команд может быть в буфере.
Таким образом, предыдущий пример можно переписать так:
[Fill command buffer]
int count = OBJS_COUNT;
glMultiDrawElementsIndirectCountARB(face_type, index_type, indirect, &count, OBJS_COUNT, 0);
Заметка, расширения shader_draw_parameters и indirect_parameters являются ARB расширениями, потому на некоторых видеокартах могут не поддерживаться.

За рамками данной статьи остались функции с окончанием BaseInstance. При вычислении номера текущей вершины используется следующая формула:
vertexIndex = [gl_InstanceId / divisor] + baseInstance
Вот этот самый baseInstance и передается в функциях с окончанием BaseInstance. Благодаря расширению shader_draw_parameters в шейдере так же можно получить значение baseInstance посредством встроенной юниформы glBaseInstanceARB. Всю эту информацию о номере вершины, о номере иснтанса, о базовом инстансе и о номере выводимого инстанса вы можете использовать любым удобным для вас способом чтобы сформировать или получить данные, уникальные как для каждого из инстансов так и для каждой вершины примитива. В прилагаемых к статье Демо вы найдете разные примеры получениях и использования таких данных для вывода произвольного текста.

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

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

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