Программирование игр, создание игрового движка, OpenGL, DirectX, физика, форум
GameDev.ru / Программирование / Статьи / Стоимость OpenGL команд.

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

Автор:

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

Рассмотрим стоимость API вызовов:
- смену различных стейтов по отдельности (фрейм буферов, вершинных буферов, шейдеров, констант, текстур);
- различные типы инстансинга геометрии, сравним по скорости;
- несколько практических примеров, как стоит оптимизировать рендер (отрисовку) геометрии в проекте.

В данной статье буду рассматривать только OpenGL. Возможно в следующих статьях рассмотрю и другие API. Не буду детально рассказывать про параметры и вариации каждого API вызова. Для этого есть справочники.

Конфигурация компьютера на котором производились расчеты: Intel Core i5-4460 3.2GHz., Radeon R9 380. Операционная система - Windows 10.

Немного об измерениях производительности:
- наиболее правильный подход к измерению производительности является замер времени всего кадра.  Даже если в замеры попадают несколько 'лишних' вещей вроде очистки буфера, установка шейдера и fbo;
- полное уравнение выглядит так: СРЕДНЕЕ_ВРЕМЯ_ТЕСТА = (ВРЕМЯ_ПОСЛЕ_N_ИТЕРАЦИЙ - ВРЕМЯ_КОГДА_НАЧАЛИ_ПЕРВУЮ_ИТЕРАЦИЮ) / КОЛИЧЕСТВО_ИТЕРАЦИЙ_ТЕСТА;
- во всех вычислениях время в милисекундах (ms.);
- выполняем тест несколько раз и вычисляем среднее время. Количество итераций должно быть достаточно большим: 500-1000. Иначе получаем большой разброс в замерах от запуска к запуску;
- измеряя производительность инстансинга мы должны убедиться что не упираемся в GPU (поэтому в отчет также пишется время потраченное на GPU). Нас интересует только время потраченное на CPU.
- как правило, время теста масштабируется линейно в зависимости от количества смен стейтов.
- разумеется, производительность и стоимость API вызовов зависит от железа (производителя, модели), драйвера, операционной системы.
Но, мы можем оценить относительную стоимость API вызовов, выработать направления в котором стоит оптимизировать программу и оценить потенциальный выигрыш в производительности от различных оптимизаций.

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

Смена стейтов
  Дипы
  Смена FBO
  Смена шейдера
  Смена параметров шейдера
  Смена вершинных буферов
  Смена текстур
Сравнительная оценка стоимости стейтов
Инстансинг
  Текстурный инстансинг
  Инстансинг через вершинный буфер
  Uniform buffer instancing, Texture buffer instancing, SSBO buffer instancing
  Uniform buffer
  TBO
  SSBO
  Uniforms instancing
  Multi draw indirect
  Сравнение типов инстансинга о скорости
Рекомендации по оптимизации и выводы

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

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

Разберемся со стоимостью различных OpenGL вызовов: дип (DIP — Draw Indexed Primitive), смена FBO (Frame Buffer Object), шейдеров, вершинных буферов, текстур, констант (параметров передаваемых в шейдер).

Дипы

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
}

Для 1000 дипов получаем  0.41 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); //восстанавливаем рендер 'на экран'
}

Для 200 смен fbo получаем 1.97 ms.

Менять 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))); //дип, рендерим объект
  }
}

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

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

Часто материалы делают универсальными, со множеством опций. Чтобы получать разновидности материала. Это легкий способ сделать картинку разнообразной, каждого персонажа/объект уникальным. Соответственно, нужно как-то передать шейдеру эти параметры. Делается это специальными командами: 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 буфер, пересылают на GPU первой командой весь набор данных. Остается только для каждого объекта указать по какому смещению его данные находятся в этом большом массиве.

//установим буфер, в который будем записывать данные объектов
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))); //дип, рендерим объект
}

Тест на смену параметров шейдера через glUniform4fv занимает  1.27 ms. для 1000 инстансов. Тот же тест с использованием SSBO - 0.8 ms.

При записи данных инстансов на GPU, использование 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++)
  {
     //меняем вершинный буфер, vbo
    glBindVertexArray(separate_geometry_vao_id[i % NUM_SIMPLE_VERTEX_BUFFERS]);
    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))); //дип, рендерим объект
  }
}

Для 1000 смен VBO получаем 0.95 ms.

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

Текстуры придают поверхностям детальный вид. Можно добиться очень большого разнообразия в картинке просто меняя текстуры, смешивая между собой разные в одном и том же материале. Текстуры приходится менять часто, но можно сложить их в так называемый текстурный массив, установить его только 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))); //дип, рендерим объект
    }
  }
}

Смена текстур для 1000 объектов занимает 3.27 ms. С учетом того что для каждого объекта мы устанавливаем NUM_TEXTURES_IN_COMPLEX_MATERIAL (в нашем случае 6) текстур. Нужно будет это учесть при вычислении стоимости glBindTexture.
Использование массива текстур, также для рендера 1000 объектов, занимает 0.87 ms.



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

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

Таблица 1. Время выполнения тестов на смену различных стейтов

Тип теста 1000 дипов
SIMPLE_DIPS_TEST 0.41
FBO_CHANGE_TEST 1.97
SHADERS_CHANGE_TEST 2.9
UNIFORMS_SIMPLE_CHANGE_TEST 1.27
UNIFORMS_SSBO_CHANGE_TEST 0.8
VBO_CHANGE_TEST 0.95
ARRAY_OF_TEXTURES_TEST 3.27
TEXTURES_ARRAY_TEST 0.87

Исходя из результатов тестов, можно примерно оценить стоимость изменения каждого стейта. Абсолютная стоимость указана на 1000 вызовов API функции. Относительную стоимость считаем по отношению к стоимости дипа (glDrawRangeElements).

Таблица 2. Стоимость API вызова (на 1000 вызовов).

API вызов абсолютная стоимость относительная стоимость %
glBindFramebuffer 9.44 2314%
glUseProgram 2.49 610%
glBindVertexArray 0.54 132%
glBindTexture 0.48 116%
glDrawRangeElements 0.41 100%
glUniform4fv 0.09 21%

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

Страницы: 1 2 Следующая »

8 февраля 2017

#instancing, #OpenGL


Обновление: 4 мая 2017

2001—2017 © GameDev.ru — Разработка игр