Skip to content

Instantly share code, notes, and snippets.

@kovrov
Last active April 9, 2024 17:01
Show Gist options
  • Save kovrov/1f58d18f03b3d3d654679d9aeb39f123 to your computer and use it in GitHub Desktop.
Save kovrov/1f58d18f03b3d3d654679d9aeb39f123 to your computer and use it in GitHub Desktop.

Стоимость OpenGL команд

http://www.gamedev.ru/code/articles/opengl_overhead

Автор: Анатолий Герлиц

В современных проектах для получения красивой картинки отрисовываются тысячи различных объектов: персонажи, строения, ландшафт, природа, эффекты и т.д.
Разумеется есть разные способы отобразить на экране геометрию. В этой статье мы рассмотрим как сделать это эффективно, посчитаем и сравним стоимость различных API вызовов для отрисовки геометрии.
Рассмотрим стоимость API вызовов:
    - смену различных стейтов по отдельности (фрейм буферов, вершинных буферов, шейдеров, констант, текстур)
    - различные типы инстансинга геометрии, сравним по скорости
    - несколько практических примеров, как стоит оптимизировать рендер (отрисовку) геометрии в проекте
В данной статье буду рассматривать только Opengl. Возможно в следующих статьях рассмотрю и другие API.
Не буду детально рассказывать про параметры и вариации каждого API вызова. Для этого есть справочники.
Конфигурация компьютера на котором производились расчеты: Intel Core i5-4460 3.2GHz., Radeon R9 380. Во всех расчетах - время в ms.

Смена стейтов

Итак, мы хотим видеть на экране 'богатую' картинку, много уникальных объектов с большим количеством деталей. Для этого движок перебирает все видимые объекты в кадре, устанавливает их параметры (вершинные буфера, шейдеры, параметры материала, текстуры) и посылает на рендер. Все эти действия выполняются специальными API командами. Рассмотрим их, проведем несколько тестов, чтобы понять как оптимальней организовывать отрисовку геометрии.

Разберемся со стоимостью различных OpenGL вызовов: дип (dip), смена fbo, шейдеров, вершинных буферов, текстур, констант (параметров передаваемых в шейдер).

Дипы

DIP (draw indexed primitive) - команда для отрисовки геометрии, чаще всего - треугольников. Нужно конечно сперва подготовить - указать какую геометрию собираемся отображать, с каким шейдером, установить параметры. Но именно эта команда выполняет отрисовку геометрии на экране.
В стоимость dip'а обычно включают все сопутствующие смены стейтов, а не саму конмаду. Разумеется все зависит от количества смен стейтов.
Для начала рассмотрим простейший случай - стоимость 1к простых дипов. Без смен стейтов.

void simple_dips()
{
  glBindVertexArray(ws_complex_geometry_vao_id); //какую геометрию будем выводить
  simple_geometry_shader.bind(); //с каким шейдером/материалом

  //много простых dip'ов
  for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
    glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i+1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES*sizeof(int))); //simple dip
}

Таблица 1. Стоимость простых дипов в зависимости от их количества.

2000 1000 500 100
0.4 0.21 0.107 0.0255

Время всего кадра несколько больше чем время исполнения основной процедуры. В среднем по всем тестам на 0.2 ms больше.
Здесь и далее в таблицах указывается стоимость проведенного теста целиком. Стоимость API вызова будет посчитана позже, в отдельно таблице.

Смена FBO

FBO (frame buffer object) - объект, который позволяет выводить изображение не на экран, а в другую поверхность, которую в последствии можно использовать как текстуру для обработки в шейдере. Менять fbo приходится не так часто как другие элементы, но в тоже время смена обходится достаточно дорого для cpu.

void fbo_change_test()
{
//очищаем fbo
  glViewport(0, 0, window_width, window_height);
  glClearColor(0.0f / 255.0f, 0.0f / 255.0f, 0.0f / 255.0f, 0.0);
  for (int i = 0; i < NUM_DIFFERENT_FBOS; i++)
  {
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_buffer[i % NUM_DIFFERENT_FBOS]);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
  }

//подготовить дип
  glBindVertexArray(ws_complex_geometry_vao_id); //какую геометрию будем выводить
  simple_geometry_shader.bind(); //с каким шейдером/материалом

//установить fbo, отрисовать 1 объект... повторить N раз
  for (int i = 0; i < NUM_FBO_CHANGES; i++)
  {
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_buffer[i % NUM_DIFFERENT_FBOS]); //установить fbo
    glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
  }
  glBindFramebuffer(GL_FRAMEBUFFER, 0); //восстанавливаем рендер 'на экран'
}

Таблица 2. Стоимость смены fbo (сверху-количество, время в ms.).

400 200 100 25
2.72 1.42 0.73 0.257

Менять fbo приходится как правило для пост-эффектов и различных проходов, вспомогательных процедур: отражения, рендер в кубмапу, рендер виртуальных текстур и т.д. Многие вещи вроде виртуальных текстур можно организовывать в атласы, чтобы устанавливать fbo только 1 раз и менять, например, только вьюпорт. Рендер в кубмапу можно заменить на dual paraboloid технику, либо другую где требуется меньше смен fbo. Дело конечно не только в смене fbo но и в количестве проходов рендера сцены/объектов/смен материалов и т. д. В общем случае - чем меньше переключений стейтов, тем лучше.

Смена шейдера

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

void shaders_change_test()
{
  glBindVertexArray(ws_complex_geometry_vao_id);

  for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
  {
    simple_color_shader[i%NUM_DIFFERENT_SIMPLE_SHADERS].bind(); //установим шейдер
    glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
  }
}

Таблица 3. Стоимость смен шейдера (сверху - количество, время в ms.)

2000 1000 500 100
5.16 2.6 1.28 0.257

В установку шейдера также входит установка мировой матрицы. Иначе у нас ничего не отрендерится. Стоимость установки параметров шейдера мы посчитаем дальше.

Смена параметров шейдера

Часто материалы делают универсальными, со множеством опций. Чтобы получать разновидности материала. Это легкий способ сделать картинку разнообразной, каждого персонажа/объект уникальным.
Соответственно, нужно как то передать шейдеру эти параметры. Делается это специальными командами: glUniform*

uniforms_changes_test_shader.bind();
glBindVertexArray(ws_complex_geometry_vao_id);

for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
{
  //установить параметры для этого объекта
  for (int j = 0; j < NUM_UNIFORM_CHANGES_PER_DIP; j++)
    glUniform4fv(ColorShader_uniformLocation[j], 1,
            &randomColors[(i*NUM_UNIFORM_CHANGES_PER_DIP + j) % MAX_RANDOM_COLORS].x);

   glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
}

По отдельности выставлять параметры конечно накладно и часто упаковывают данные всех объектов в 1 буфер, пересылают на гпу 1й командой весь набор данных.

Остается только для каждого объекта указать по какому смещению его данные находятся в этом большом массиве.

//установим буфер, в который будем записывать данные объектов
glBindBuffer(GL_SHADER_STORAGE_BUFFER, instances_uniforms_ssbo);

//мапим, чтобы передать данные с оперативной памяти на gpu
float *gpu_data = (float*)glMapBufferRange(GL_SHADER_STORAGE_BUFFER, 0,
            CURRENT_NUM_INSTANCES * NUM_UNIFORM_CHANGES_PER_DIP * sizeof(vec4),
            GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT);

//собственно - копирование данных
memcpy(gpu_data, &all_instances_uniform_data[0],
            CURRENT_NUM_INSTANCES * NUM_UNIFORM_CHANGES_PER_DIP * sizeof(vec4));

//говорим, что закончили пересылку данных
glUnmapBuffer(GL_SHADER_STORAGE_BUFFER);

//'привязываем' наш буфер с данными к шейдеру
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instances_uniforms_ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);

//подготовка дипа
uniforms_changes_ssbo_shader.bind();
glBindVertexArray(ws_complex_geometry_vao_id);

//находим в шейдере переменную, в которую будем передавать смещение инстанс данных для каждого объекта
static int uniformsInstancing_data_varLocation =
            glGetUniformLocation(uniforms_changes_ssbo_shader.programm_id, "instance_data_location");

for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
{
  //устанавливаем параметр шейдера, записываем в переменную -
  //по какому смещению находятся инстанс данные этого объекта
  glUniform1i(uniformsInstancing_data_varLocation, i*NUM_UNIFORM_CHANGES_PER_DIP);
  glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
}

Таблица 4. Стоимость смены переменных шейдера и стоимость инстансинга с учетом пересылки данных на GPU (сверху - количество дипов, время в ms.).

Тип теста 2000 1000 500 100
UNIFORMS_SIMPLE_CHANGE_TEST 2.25 1.1 0.54 0.1145
UNIFORMS_SSBO_TEST 1.3 0.628 0.32 0.0725

Использование glMapBuffer(GL_SHADER_STORAGE_BUFFER, GL_WRITE_ONLY); вызывает синхронизацию CPU и GPU. Следует использовать glMapBufferRange с флагом GL_MAP_UNSYNCHRONIZED_BIT, чтобы не вызывать синхронизацию. Но при этом нужно гарантировать, что переписываемые данные не используются на стороне GPU. Иначе будут артефакты. Мы можем переписывать данные в тот момент когда GPU их читает. Чтобы полностью решить эту проблему следует использовать тройную буферизацию. В то время когда текущий буфер используется для записи данных, 2 других могут использоваться GPU. Есть более оптимальный способ мапинга буфера с флагами GL_MAP_PERSISTENT_BIT и GL_MAP_COHERENT_BIT.

Смена вершинных буферов

В сцене много объектов, с разной геометрией, которую часто распологайют в разных вершинных буферах. Чтобы отрендерить другой объект, с другой геометрией  даже с тем же материалом, нужно сменить вершинных буфер. Есть техники, которые позволяют эффективно рендерить различную геометрию с одним материалом за 1 дип: MultiDrawIndirect, Dynamic vertex pulling. Такая геометрия должна находиться в 1 вершинном буфере. В общем, просто выгодно объединять несколько разных объектов в 1 буфер, чтобы делать меньше переключений.

void vbo_change_test()
{
  simple_geometry_shader.bind();

  for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
  {
    glBindVertexArray(separate_geometry_vao_id[i % NUM_SIMPLE_VERTEX_BUFFERS]); //меняем вершинный буфер, vbo
    glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
  }
}

Таблица 5. Стоимость смены вершинных буферов (сверху - количество, время в ms).

2048 1024 512 128
1.6 0.785 0.396 0.086

Смена текстур

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

void textures_change_test()
{
  glBindVertexArray(ws_complex_geometry_vao_id);
  int counter = 0;

  //переключаемся между 2 тестами
  if (test_type == ARRAY_OF_TEXTURES_TEST)
  {
    array_of_textures_shader.bind();

    for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
    {
      //устанавливаем текстуры для данного объекта / дипа
      for (int j = 0; j < NUM_TEXTURES_IN_COMPLEX_MATERIAL; j++)
      {
        glActiveTexture(GL_TEXTURE0 + j);
        glBindTexture(GL_TEXTURE_2D, array_of_textures[counter % TEX_ARRAY_SIZE]);
        glBindSampler(j, Sampler_linear);
        counter++;
      }
      glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
    }
  }
  else
  if (test_type == TEXTURES_ARRAY_TEST)
  {
    //установим текстурный массив для всех дипов сразу
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture_array_id);
    glBindSampler(0, Sampler_linear);

    //переменная в шейдере, в которую передаем индексы текстур, используемых дынным объектом
    static int textureArray_usedTex_varLocation = glGetUniformLocation(textureArray_shader.programm_id, "used_textures_i");
    textureArray_shader.bind();

    float used_textures_i[6];
    for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
    {
      //заполняем данные - какие текстуры использует данный объект
      for (int j = 0; j < 6; j++)
      {
        used_textures_i[j] = counter % TEX_ARRAY_SIZE;
        counter++;
      }
      glUniform1fv(textureArray_usedTex_varLocation, 6, &used_textures_i[0]); //передаем параметр в шейдер
      glDrawRangeElements(GL_TRIANGLES, i * BOX_NUM_VERTS, (i + 1) * BOX_NUM_VERTS, BOX_NUM_INDICES,
            GL_UNSIGNED_INT, (GLvoid*)(i*BOX_NUM_INDICES * sizeof(int))); //дип, рендерим объект
    }
  }
}

Таблица 6. Тест на стоимость смены текстур (сверху - количество, время в ms.).

Тип теста 2048 1024 512 128
ARRAY_OF_TEXTURES_TEST 6.2 3.12 1.577 0.315
ARRAY_OF_TEXTURES_TEST 1.42 0.7 0.35 0.08

Сравнительная оценка стоимости стейтов

Ниже приведена таблица со стомостью/временем выполнения всех проведенных тестов.

Таблица 7. Время выполнения тестов на смену различных стейтов (сверху - количество дипов, время в ms.).

Тип теста 2048 1024 512 128
SIMPLE_DIPS_TEST 0.4 0.21 0.107 0.0255
FBO_CHANGE_TEST 2.72 1.42 0.73 0.257
SHADERS_CHANGE_TEST 5.16 2.6 1.28 0.257
UNIFORMS_SIMPLE_CHANGE_TEST 2.25 1.1 0.54 0.1145
UNIFORMS_SSBO_CHANGE_TEST 1.3 0.628 0.32 0.0725
VBO_CHANGE_TEST 1.6 0.785 0.396 0.086
ARRAY_OF_TEXTURES_TEST 6.2 3.12 1.577 0.315
TEXTURES_ARRAY_TEST 1.42 0.7 0.35 0.08

Исходя из результатов тестов, можно примерно оценить стоимость изменения каждого стейта. Абсолютная стоимость указана на 1000 вызовов API функции. Относительную стоимость считаем по отношению к стоимости дипа (glDrawRangeElements).
Таблица 8. Стоимость API вызова. Intel Core i5-4460 3.2GHz. Время в ms.

API вызов абсолютная стоимость относительная стоимость %
glBindFramebuffer 7.1 3550%
glUseProgram 2.04 1020%
glBindVertexArray 0.765 382%
glBindTexture 0.584 292%
glDrawRangeElements 0.2 100%
glUniform4fv 0.09 45%

Стоит конечно весьма осторожно относиться к данным измерениям, т.к. они будут меняться в зависимости от версии драйвера и железа.

Инстансинг

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

Можно сложить все параметры объектов в 1 буфер, переслать на gpu и выполнить 1 дип. Хранение данных в буферах само по себе является неплохой оптимизацией - экономим на том что не надо постоянно менять параметры шейдера. К тому же, если данные инстансов не меняются (например, мы точно знает что эта геометрия статическая), то можно не пересылать их на гпу постоянно. В целом, для оптимального рендеринга стоит сперва упаковать все инстанс данные в 1 буфер и передать на гпу одной командой. Для каждого дипа передавать только смещение по которому находятся его данные. Используя индекс инстанса (gl_InstanceID) можно добраться до данных конкретного объекта.

Вариантов хранения данных в Opengl достаточно много: vertex buffer (VBO), uniform buffer (UBO), texture buffer (TBO), shader storage buffer (SSBO), textures. Все зависит в каком буфере хранятся данные. Есть различные особенности, которые и рассмотрим.

Текстурный инстансинг

Все данные хранятся в текстуре. Для эффективного обновления текстур лучше использовать специальные структуры - Pixel Buffer Object (PBO), которые позволяют асинхронно передавать данные на GPU. CPU не ждет пока данные передадутся и продолжает работу.

Код создания:

//создаем 2 буфера.
//Пока один используется для передачи уже подготовленных данных в непосредственно в текстуру,
//второй буфер мы используем для заполнения новыми данными.
GLuint textureInstancingPBO[2], textureInstancingDataTex;
glGenBuffersARB(2, textureInstancingPBO);
glBindBufferARB(GL_PIXEL_UNPACK_BUFFER_ARB, textureInstancingPBO[0]);
//GL_STREAM_DRAW_ARB означает что мы будем менять данные часто, каждый кадр
glBufferDataARB(GL_PIXEL_UNPACK_BUFFER_ARB, INSTANCES_DATA_SIZE, 0, GL_STREAM_DRAW_ARB);
glBindBufferARB(GL_PIXEL_UNPACK_BUFFER_ARB, textureInstancingPBO[1]);
glBufferDataARB(GL_PIXEL_UNPACK_BUFFER_ARB, INSTANCES_DATA_SIZE, 0, GL_STREAM_DRAW_ARB);
glBindBufferARB(GL_PIXEL_UNPACK_BUFFER_ARB, 0);

//создаем текстуру, в которой будем хранить непосредственно данные инстансов
glGenTextures(1, &textureInstancingDataTex);
glBindTexture(GL_TEXTURE_2D, textureInstancingDataTex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_R, GL_REPEAT);

//в каждой строке храним данные NUM_INSTANCES_PER_LINE объектов. 128 в нашем случае
//Для каждого объекта храним PER_INSTANCE_DATA_VECTORS данных-векторов. 2 в данном примере
//GL_RGBA32F — у нас float32 данные
// complex_mesh_instances_data исходные данные инстансов, если мы не собираемся обновлять данные в текстуре
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F,
       NUM_INSTANCES_PER_LINE * PER_INSTANCE_DATA_VECTORS, MAX_INSTANCES / NUM_INSTANCES_PER_LINE, 0,
       GL_RGBA, GL_FLOAT, &complex_mesh_instances_data[0]);
glBindTexture(GL_TEXTURE_2D, 0);

Обновление текстуры:

glBindTexture(GL_TEXTURE_2D, textureInstancingDataTex);
glBindBufferARB(GL_PIXEL_UNPACK_BUFFER, textureInstancingPBO[current_frame_index]);

//копируем пиксели из PBO в текстуру
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0,
       NUM_INSTANCES_PER_LINE * PER_INSTANCE_DATA_VECTORS, MAX_INSTANCES / NUM_INSTANCES_PER_LINE,
       GL_RGBA, GL_FLOAT, 0);

//устанавливаем PBO в который будем записывать новые данные
glBindBufferARB(GL_PIXEL_UNPACK_BUFFER, textureInstancingPBO[next_frame_index]);

//вызываем glBufferDataARB() с NULL указателем, чтобы не было 'синхронизации' с гпу
glBufferData(GL_PIXEL_UNPACK_BUFFER, INSTANCES_DATA_SIZE, 0, GL_STREAM_DRAW_ARB);

gpu_data = (float*)glMapBuffer(GL_PIXEL_UNPACK_BUFFER, GL_WRITE_ONLY_ARB);
if (gpu_data)
{
  //скопировать данные на gpu
  memcpy(gpu_data, &complex_mesh_instances_data[0], INSTANCES_DATA_SIZE);
  
  //говорим, что закончили пересылку данных
  glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER);
}

Код рендера через текстурный инстансинг:

//устанавливаем текстуру с данными инстансов
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureInstancingDataTex);
glBindSampler(0, Sampler_nearest);

//что рисуем, геометрия
glBindVertexArray(geometry_vao_id);

//шейдер которым рисуем
tex_instancing_shader.bind();

static GLint location = glGetUniformLocation(tex_instancing_shader.programm_id, "s_texture_0");
if (location >= 0)
  glUniform1i(location, 0);

//отрисовываем группу объектов, дип
glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, CURRENT_NUM_INSTANCES);

Вершинный шейдер для доступа к данным:

#version 150 core

//вершинные атрибуты
in vec3 s_pos;
in vec3 s_normal;
in vec2 s_uv;

//матрица трансформаций камеры
uniform mat4 ModelViewProjectionMatrix;

uniform sampler2D s_texture_0; //текстура в которой хранятся данные инстансов

out vec2 uv;
out vec3 instance_color;

void main()
{
  const vec2 texel_size = vec2(1.0 / 256.0, 1.0 / 16.0);
  const int objects_per_row = 128;
  const vec2 half_texel = vec2(0.5, 0.5); //Opengl texel расположен в центре клетки

  //вычисляем текстурные координаты по которым находятся данные инстансов
  //gl_InstanceID % objects_per_row — номер объекта в строке
  //умножаем на 2, т. к. у каждого объекта по 2 вектора с данными
  //gl_InstanceID / objects_per_row — в какой строке находятся данные объекта
  //умножение на  texel_size переводит целочисленный индекс текселя в интервал 0..1 для семплирования
  vec2 texel_uv =
     (vec2((gl_InstanceID % objects_per_row) * 2, floor(gl_InstanceID / objects_per_row)) + half_texel) * texel_size;
  
  //собственно семплирование данных из текстуры, 2 текселя подряд
  vec4 instance_pos = textureLod(s_texture_0, texel_uv, 0);
  instance_color = textureLod(s_texture_0, texel_uv + vec2(texel_size.x, 0.0), 0).xyz;

  uv = s_uv;
  gl_Position = ModelViewProjectionMatrix * vec4(s_pos + instance_pos.xyz, 1.0);
}

Инстансинг через вершинный буфер

Идея в том, чтобы можно держать данные инстансов в вершинном буфере и передавать их как вершинные атрибуты.

Код создания самого буфера опустим, он выглядит тривиально. Нашей задачей является — модифицировать информацию о вершине для шейдера (vertex declaration, vdecl).

//...код создания основного vdecl
glBindVertexArray(geometry_vao_vbo_instancing_id); //продолжаем модифицировать vdecl

//устанавливаем буфер с инстанс данными
glBindBuffer(GL_ARRAY_BUFFER, all_instances_data_vbo);

//размер инстанс данных для одного объекта
const int per_instance_data_size = sizeof(vec4) * PER_INSTANCE_DATA_VECTORS;

//устанавливаем 4й атрибут вершины, в котором 4 float'а, находится по 0 смещению
glEnableVertexAttribArray(4);
glVertexAttribPointer((GLuint)4, 4, GL_FLOAT, GL_FALSE, per_instance_data_size, (GLvoid*)(0));
glVertexAttribDivisor(4, 1);

//устанавливаем 5й атрибут вершины, в котором 4 float'а, находится по sizeof(vec4) смещению
glEnableVertexAttribArray(5);
glVertexAttribPointer((GLuint)5, 4, GL_FLOAT, GL_FALSE, per_instance_data_size, (GLvoid*)(sizeof(vec4)));
glVertexAttribDivisor(5, 1);

glBindVertexArray(0);

Код рендера:

vbo_instancing_shader.bind();
//устанавливаем наш вершинный буфер с модифицированным vertex declaration (vdecl)
glBindVertexArray(geometry_vao_vbo_instancing_id);
glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, CURRENT_NUM_INSTANCES);

Вершинный шейдер для доступа к данным:

#version 150 core

//вершинные атрибуты
in vec3 s_pos;
in vec3 s_normal;
in vec2 s_uv;
in vec4 s_attribute_3; //some_data;

in vec4 s_attribute_4; //instance pos !
in vec4 s_attribute_5; //instance color !

//матрица трансформаций камеры
uniform mat4 ModelViewProjectionMatrix;

out vec3 instance_color;

void main()
{
  instance_color = s_attribute_5.xyz;
  gl_Position = ModelViewProjectionMatrix * vec4(s_pos + s_attribute_4.xyz, 1.0);
}

Uniform buffer instancing, Texture buffer instancing, SSBO buffer instancing

3 метода в целом очень похожи друг на друга, различаются только типом создаваемого буфера.
Uniform buffer (UBO) отличается небольшим размером, но теоретически должен быть быстрее остальных.
Texture buffer (TBO) имеет очень большой размер. В нем можно уместить данные всех объектов сцены, данные скелетной  трансформации.
Shader Storage Buffer (SSBO) теоретически обладает  обоими свойствами - быстрый с большим размером. К тому в него можно писать данные. Единственно, это новое расширение и старым железом не поддерживается.

Uniform buffer

Код создания:

glGenBuffers(1, &dips_uniform_buffer);
glBindBuffer(GL_UNIFORM_BUFFER, dips_uniform_buffer);
glBufferData(GL_UNIFORM_BUFFER, INSTANCES_DATA_SIZE, &complex_mesh_instances_data[0], GL_STATIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

//юниформ буфер нужно привязать к шейдеру специальной командой
GLint instanceData_UBO_location = glGetUniformLocation(ubo_instancing_shader.programm_id, "instance_data");
glUniformBufferEXT(ubo_instancing_shader.programm_id, iinstanceData_UBO_location, dips_uniform_buffer);

Рендер:

ubo_instancing_shader.bind();
glBindVertexArray(geometry_vao_id);
glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, CURRENT_NUM_INSTANCES);

Вершинный шейдер:

#version 150 core
#extension GL_EXT_bindable_uniform : enable
#extension GL_EXT_gpu_shader4 : enable

in vec3 s_pos;
in vec3 s_normal;
in vec2 s_uv;

uniform mat4 ModelViewProjectionMatrix;
bindable uniform vec4 instance_data[4096]; //наш uniform буфер с инстанс данными

out vec3 instance_color;

void main()
{
  vec4 instance_pos = instance_data[gl_InstanceID*2];
  instance_color = instance_data[gl_InstanceID*2+1].xyz;
  gl_Position = ModelViewProjectionMatrix * vec4(s_pos + instance_pos.xyz, 1.0);
}

TBO

Код создания:

glGenBuffers(1, &dips_texture_buffer);
glBindBuffer(GL_TEXTURE_BUFFER, dips_texture_buffer);
glBufferData(GL_TEXTURE_BUFFER, INSTANCES_DATA_SIZE, &complex_mesh_instances_data[0], GL_STATIC_DRAW);
glGenTextures(1, &dips_texture_buffer_tex);
glBindBuffer(GL_TEXTURE_BUFFER, 0);

Рендер:

glBindVertexArray(geometry_vao_id);
tbo_instancing_shader.bind();

//устанавливаем tbo в шейдер, как 'специальную' текстуру
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, dips_texture_buffer_tex);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, dips_texture_buffer);

glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, CURRENT_NUM_INSTANCES);

Вершинный шейдер:

#version 150 core

in vec3 s_pos;
in vec3 s_normal;
in vec2 s_uv;

uniform mat4 ModelViewProjectionMatrix;
uniform samplerBuffer s_texture_0; //наш tbo с инстанс данными

out vec3 instance_color;

void main()
{
  //семплирование данных из буфера
  vec4 instance_pos = texelFetch(s_texture_0, gl_InstanceID*2);
  instance_color = texelFetch(s_texture_0, gl_InstanceID*2+1).xyz;
  gl_Position = ModelViewProjectionMatrix * vec4(s_pos + instance_pos.xyz, 1.0);
}

SSBO

Код создания:

glGenBuffers(1, &ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);
glBufferData(GL_SHADER_STORAGE_BUFFER, INSTANCES_DATA_SIZE, &complex_mesh_instances_data[0], GL_STATIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); // unbind

Рендер:

glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);
//привязываем к точке с индексом 0, в шейдере это также будет отражено
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);

ssbo_instancing_shader.bind();
glBindVertexArray(geometry_vao_id);
glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, CURRENT_NUM_INSTANCES);
glBindVertexArray(0);

Вершинный шейдер:

#version 430
#extension GL_ARB_shader_storage_buffer_object : require

in vec3 s_pos;
in vec3 s_normal;
in vec2 s_uv;

uniform mat4 ModelViewProjectionMatrix;

//наш ssbo, мы ранее привязывали его к 0 'токе'
layout(std430, binding = 0) buffer ssboData
{
    vec4 instance_data[4096];
};

out vec3 instance_color;

void main()
{
  vec4 instance_pos = instance_data[gl_InstanceID*2];
  instance_color = instance_data[gl_InstanceID*2+1].xyz;
  gl_Position = ModelViewProjectionMatrix * vec4(s_pos + instance_pos.xyz, 1.0);
}

Uniforms instancing

Достаточно простой. Имеем возможность передать через glUniform* некоторое  количество векторов с данными. Максимальное количество зависит от видеокарты. Получить максимальное количесто можно при помощи вызова glGetIntegerv с параметром GL_MAX_VERTEX_UNIFORM_VECTORS. Для R9 380 вернет 4096. Минимальное значение 256.

uniforms_instancing_shader.bind();
glBindVertexArray(geometry_vao_id);

//получаем расположение шейдерной переменной-массива, в которую и будем писать данные
static int uniformsInstancing_data_varLocation =
  glGetUniformLocation(uniforms_instancing_shader.programm_id, "instance_data");

//инстанс данные можно залить одной командой если хватает векторов
//для наглядности разделим на группы, потому-что, как правило, данных сильно больше.
for (int i = 0; i < UNIFORMS_INSTANCING_NUM_GROUPS; i++)
{
  //записываем порцию данных
  glUniform4fv(uniformsInstancing_data_varLocation, UNIFORMS_INSTANCING_MAX_CONSTANTS_FOR_INSTANCING,
        &complex_mesh_instances_data[i*UNIFORMS_INSTANCING_MAX_CONSTANTS_FOR_INSTANCING].x);
  //собственно отрисовка текущей группы
  glDrawElementsInstanced(GL_TRIANGLES, BOX_NUM_INDICES, GL_UNSIGNED_INT, NULL, UNIFORMS_INSTANCING_OBJECTS_PER_DIP);
}

Multi draw indirect

Отдельно рассмотрим команду, которая позволяет рисовать огромное количество дипов за 1 вызов. Это очень полезная команда которая позволяет рендерить группы инстансов с разной геометрией. Ей передается массив, который описывает параметры дипов: количество индексов, смещени данных в вершинном буфере, количество инстансов и т. д. Ограничения в том, что вся выводимая геометрия должна храниться в одном vbo и рендериться одним шейдером. Дополнительным плюсом является то, что этот массив с информацией о дипах можно сформировать на стороне GPU, что очень удобно для GPU кулинга объектов, например.

//заполнить indirect buffers
for (int i = 0; i < CURRENT_NUM_INSTANCES; i++)
{
  multi_draw_indirect_buffer[i].vertexCount = BOX_NUM_INDICES;
  multi_draw_indirect_buffer[i].instanceCount = 1;
  multi_draw_indirect_buffer[i].firstVertex = i*BOX_NUM_INDICES;
  multi_draw_indirect_buffer[i].baseVertex = 0;
  multi_draw_indirect_buffer[i].baseInstance = 0;
}
    
glBindVertexArray(ws_complex_geometry_vao_id);
simple_geometry_shader.bind();

//собственно вызов
glMultiDrawElementsIndirect(GL_TRIANGLES,
  GL_UNSIGNED_INT,
  (GLvoid*)&multi_draw_indirect_buffer[0],
  CURRENT_NUM_INSTANCES,
  0);

По сути данная команда выполняет несколько glDrawElementsInstancedIndirect за один вызов. Есть правда неприятная особенность в поведении. Что каждый такой glDrawElementsInstanced будет иметь независимый gl_InstanceID, т. е. Каждый раз сбрасываться в 0 при новом glDraw*. Что усложняет доступ к данным соответствующего инстанса. Обходится эта проблема модифицированием vdecl каждого типа объектов, посылаемых на рендер. Можно почитать Surviving without gl_DrawID.

Стоит заметить, что glMultiDrawElementsIndirect выполнил сразу огромное количество дипов (для наглядности), одной командой.  Для этого и предназначен. Не стоит сравнивать его скорость с остальными типами инстансинга.

Сравнение типов инстансинга о скорости

Таблица 8. Стоимость типов инстансинга (время в ms.). Количество инстансов = 2000

Тип инстансинга x1 x10 x100
UBO_INSTANCING 0.0067 0.02 0.15
TBO_INSTANCING 0.0245 0.06 0.49
SSBO_INSTANCING 0.009 0.0225 0.17
VBO_INSTANCING 0.01 0.0213 0.155
TEXTURE_INSTANCING 0.018 0.0262 0.183
UNIFORMS_INSTANCING 0.058 0.58 6.03
MULTI_DRAW_INDIRECT 0.136 1.33 13.53

Как видно UBO быстрее TBO. Но в TBO можно хранить гораздо больше информации. Что делает его пригодным для хранения например трансформаций.
Если есть такая возможность, то стоит использовать SSBO для хранения данных, т.к. он и быстрый и обладает большим размером.
Текстурный инстансинг тоже хорошая альтернатива UBO. Поддерживается 'старым' железом, можно хранить огромное количество информации. Немного неудобно обновлять.
Передавать данные каждый раз через шейдерные переменные очевидно оказалось самым медленным.
glMultiDrawElementsIndirect в тестах выполнил 2к, 20к и 200к дипов ! Правда мы тестировали просто x10 и x100 повторений теста. Такое количество дипов можно было бы сделать и одной командой. Единственно, стоит заметить, что при таком количестве дипов сам масив с их описанием будет достаточно большим.

Рекомендации по оптимизации и выводы

В данной статье провели анализ стоимости API вызовов, оценили по скорости различные типы инстансинга геометрии. В целом, чем меньше переключений стейтов тем лучше. Следует использовать новые фичи последних версий API по максимуму: текстурные массивы, SSBO, Draw Indirect, мапинг буферов с флагами GL_MAP_PERSISTENT_BIT и GL_MAP_COHERENT_BIT для быстрой передачи данных и др фичи.

Рекомендации:
    - Чем меньше переключений стейтов, тем лучше. Стоит группировать объекты по материалам.
    - Можно сделать обертку на смену состояний (текстур, буферов, шейдеров, других стейтов). Проверять, действительно ли менялся ресурс прежде чем вызывать его смену на стороне gl, API вызов которого гораздо дороже чем проверка индекса.
    - Объединять геометрию в 1 буфер.
    - Использовать текстурные массивы.
    - Хранить данные в буферах и текстурах.
    - Использовать как можно меньше шейдеров. Но слишком сложный, универсальный шейдер с большим количеством ветвлений очевидно станет проблемой. Тем более на старых видео картах, где ветвления обходятся дороже.
    - Использовать инстансинг.
    - Если возможно, использовать Draw Indirect и формировать информацию о дипах на стороне gpu.

Несколько общих советов:
    - Нужно вычислять узкие места и в первую очередь оптимизировать их.
    - Нужно знать во что упирается производительность – CPU или GPU.
    - Не делать работу дважды, переиспользывать результаты работы алгоритма с предыдущего кадра.
    - Сложные вычисления можно предрассчитывать.
    - Лучшая оптимизация производительности - вообще не делать работу.
    - Использовать параллельные вычисления: разбивать работу на части и выполнять в параллельных потоках.

Исходный код всех примеров

Ссылки:

  1. Beyond Porting
  2. OpenGL documentation
  3. Instancing in OpenGL
  4. OpenGL Pixel Buffer Object (PBO)
  5. Buffer Object
  6. Drawing with OpenGL
  7. Shader Storage Buffer Object
  8. Shader Storage Buffers Objects
  9. hardware caps, stats (for GL_MAX_VERTEX_UNIFORM_COMPONENTS)
  10. Array Texture
  11. Textures Array example
  12. MultiDrawIndirect example
  13. The Road to One Million Draws
  14. MultiDrawIndirect, Surviving without gl_DrawID
  15. Humus demo about Instancing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment