Оптимизация OpenGL приложений

Автор: | 22.12.2018

OpenGL конвейер
Модель  клиент-сервер
Пошаговый и пакетный режимы
Массивы вершин
Vertex Buffer Objects
Полезные ссылки

OpenGL конвейер

OpenGL – библиотека (API — Application Programming Interface) для обработки графической информации и прямого доступа к железу («software interface to graphics hardware», как обозначаются они в спецификации). Библиотека содержит набор уже однажды написанных функций, от самых простых (вывод точки на экран) до довольно сложных (построение готовых примитивов, например, трехмерной пирамидки), которые применяются практически в каждой программе. Базовые функции реализованы аппаратно, в виде части GPU, более сложные функции представляют собой программные модули, построенные на базовых командах. Видеокарта не всегда аппаратно поддерживает нужные для работы приложения функции, и в этом случае библиотека использует программные модули, эмулирующие требующиеся возможности. Сама же библиотека реализована в виде информации о структуре, определяющей, как объекты будут нарисованы во framebuffer, причем некоторые из структур доступны пользователю, который может создавать различные вызовы процедур для изменения параметров.

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

 

Display list – группа OpenGL команд, которые сохраняются на стороне сервера для последующего выполнения. Улучшается производительность, поскольку для запуска списка команд всего лишь одна команда glCallList передается по медленному каналу между клиентом и сервером.

Vertex Operation. Каждая вершина преобразуются матрицей GL_MODELVIEW (от СК объекта в СК наблюдателя). Кроме того, если включено освещение, то вместе с преобразованием вершин преобразуются параметры нормальных векторов.

Primitive Assembly. Примитивы (точки, линии и полигоны) преобразуются матрицей GL_PROJECTION и обрезаются объемом просмотра, который определяется в СК наблюдателя. После этого, определяется перспектива делением на коэффициент W и создается 2D сцена в СК  окна.

Pixel Transfer Operation. После того, как пиксели прочитаны из памяти клиента,  выполняется масштабирование, смещение и отображение.  Обработанные данные сохраняются в текстурной памяти или непосредственно подготавливаются для фрагментных операций.

Texture Memory. Текстура изображения загружается в текстурную память для нанесения на геометрических объектов.

Raterization. Растеризация — преобразование геометрических и пиксельных данных в фрагмент. Фрагмент – прямоугольный массив, содержащий цвет, глубину, ширину линии, размер точки и данные для расчетов сглаживания (GL_POINT_SMOOTH, GL_LINE_SMOOTH, GL_POLYGON_SMOOTH). Если включен режим заполнения GL_FILL, то пространство полигона будет заполнено. Каждый фрагмент содержит информацию о пикселах в буфере кадра.

Fragment Operation. Это последний процесс преобразования фрагментов в пиксели на буфере кадра. Первый процесс на этой стадии – генерация  текселей; элемент текстуры генерируется из памяти текстур и применяется к каждому фрагменту. Затем вычисления  тумана применяются. После этого, следует смешивание, сглаживание, логические операции и битовая маска выполняются. Фактические данные пикселя сохраняются в буфере кадра.

FrameBuffer — область памяти для временного хранения информации о точках, составляющих один кадр изображения на мониторе. Открыть окно во FB – это значит распределить видеопамять под изображение.

Feedback. OpenGL может возвратить большинство из текущих состояний и данных через команды glGet() and glIsEnabled().  Более того, вы можете прочитать в прямоугольную область пиксельных данных из буфера кадра использованием glReadPixels () и получить полностью преобразованные данные про  вершины используя glRenderMode (GL_FEEDBACK). glCopyPixels () не возвращает пиксельные  данные пикселя, в системную память, но их можно скопировать обратно в другой буфер кадра, например, от переднего (front) буфера в резервный (back) буфер.

Модель  клиент-сервер

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

Т.о., одним из самых «узких» мест в работе с современным GPU является передача ему данных – текстур, вершин, нормалей и т.п. Очевидный путь оптимизации OpenGL приложения – минимизация передачи информации между клиентом и сервером. Для повышения быстродействия следует уменьшить количество запросов на передачу данных и передавать их как можно большими частями.  Если с текстурами все достаточно просто — они один раз загружаются в память GPU и больше не изменяются, то с геометрическими данными (координаты вершин, нормали, текстурные координаты) дело обстоит значительно хуже. Стандартный способ передачи данных через команды glVertex, glNormal и т.п. является крайне неэффективным, поскольку передача данных осуществляется очень маленькими частями и через очень большое количество вызовов. Большие объемы (больше нескольких десятков тысяч примитивов) лучше рисовать с помощью массивов вершин.

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

Пошаговый и пакетный режимы

Ключевым моментом с точки зрения производительности, особенно в сетевом окружении, является наличие двух режимов: пошагового и пакетного.

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

Несмотря на свою гибкость, режим непосредственного вывода может оказаться неэффективным, если требуется переопределить неизменяемые интерактивно объекты или параметры. В этом случае используется пакетный режим  вывода через дисплейный список (display list), содержащий последовательность команд OpenGL.  Смысл ясен – инкапсуляция функциональности (в данном случае – построения объектов для последующего многократного использования).

Дисплейный список – это удобный и эффективный путь именования и организации набора команд OpenGL. Данный механизм удобен в случае необходимости многократного выполнения группы команд, заключенных между скобками glBeginList/glEndList. Поименованный буфер памяти с дисплейным списком хранится на сервере, а его содержимое выводится на экран при поступлении команды glCallList, содержащей приказ от программы клиента.   Предположим, например, что вы хотите нарисовать торус и смотреть на него под разными углами. Наиболее эффективный способ сделать это заключается в том, чтобы сохранить торус в дисплейном списке. После этого, если вы хотите изменить угол обзора, все что вам нужно сделать, это изменить видовую матрицу и вызвать список отображения к исполнению.

for(...)
{
glPushMatrix
glTranslate //в новое место для примитива,
glCallList  // вызвали дисплейный список из кеша видеоадаптра
glPopMatrix
}

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

Недостаток дисплейных списков проявляется при частом внесении в него изменений – при этом, понятно, ни один метод оптимизации не будет работать. Для работы с такими «изменчивыми» объектами и сценами в OpenGL предпочтительнее режим прямого отображения. В практике оба метода нашли широкое применение.

Массивы вершин

Можно заметно повысить эффективность передачи данных используя  так называемые вершинные массивы (vertex arrays).   Рассмотрим особенности их использования на примере прорисовки куба

Куб состоит из 6 граней и 8 вершин. Предположим, что OpenGL умеет работать только с треугольниками (например, OpenGL ES  не поддерживает примитив GL_QUADS). Таким образом, нам нужно преобразовать каждую грань в 2 треугольники (используется примитив GL_TRIANGLES).  Треугольники в OpenGL представляют собой комбинацию из 3х вершин. Таким образом, что бы “объяснить” OpenGL, что мы хотим изобразить грань куба, нам потребуется нарисовать 2 треугольника со следующими вершинами: {vertex0,vertex1,vertex2}, {vertex2,vertex3,vertex0}.  Здесь видим проблему – это избыточность обработки смежных вершин. Так, например, вершина  v0 вызывается в программе 6 раз

glBegin(GL_TRIANGLES);  // draw a cube with 12 triangles
    // front face =================
    glVertex3fv(v0);    // v0-v1-v2
    glVertex3fv(v1);
    glVertex3fv(v2);
    glVertex3fv(v2);    // v2-v3-v0
    glVertex3fv(v3);
    glVertex3fv(v0);
    // right face =================
    glVertex3fv(v0);    // v0-v3-v4
    glVertex3fv(v3);
    glVertex3fv(v4);
    glVertex3fv(v4);    // v4-v5-v0
    glVertex3fv(v5);
    glVertex3fv(v0);
    // top face ===================
    glVertex3fv(v0);    // v0-v5-v6
    glVertex3fv(v5);
    glVertex3fv(v6);
    glVertex3fv(v6);    // v6-v1-v0
    glVertex3fv(v1);
    glVertex3fv(v0);
    ...                 // draw other 3 faces
glEnd();

С точки зрения OpenGL-оптимизации – это бездарный код. Из общих принципов программирования напрашивается  8 вершин положить в массив и сделать один вызов функции.  Это намного быстрее – передать все сразу, чем по одной вершине.

Для оптимального построения куба в OpenGL необходимо создать массив с информацией о 8 вершинах {vertex 0, vertex 1, vertex 2, vertex 3, …} и массив индексов вида {0,1,2,0,2,3,2,6,3,2,5,6…}, где каждая комбинация из 3х элементов (0,1,2 2,3,0 0,3,4 …) представляет собой конкретный треугольник. Эта возможность позволяет один раз (без дублирования) записать информацию о вершинах и многократно использовать ее,  связав с массивом индексов. Ниже показано, как это может быть реализовано программно.

static GLfloat vertices[8][3] = {
{1.0, 1.0, 1.0},{0.0, 1.0, 1.0}, {0.0, 0.0, 1.0},{1.0, 0.0, 1.0},
{1.0, 0.0, 0.0},{1.0, 1.0, 0.0}, {0.0, 1.0, 0.0},{0.0, 0.0, 0.0}
};
static GLuint indices[12][3] = {
   {0,1,2}, {2,3,0}, {0,3,4}, {4,5,0},
   {0,5,6}, {6,1,0}, {1,6,7}, {7,2,1}, 
   {7,4,3}, {3,2,7}, {4,7,6}, {6,5,4} };
int i;
glBegin(GL_TRIANGLES);
for (i = 0; i < 12; i++) {
   glVertex3fv(&vertices[indices[i][0]][0]);
   glVertex3fv(&vertices[indices[i][1]][0]);
   glVertex3fv(&vertices[indices[i][2]][0]);
}
glEnd();

Этот вариант программы выглядит более профессиональным, однако количество вызовов   glVertex() не уменьшилось (12*3=36).  Можно заменить 36 вызовов glVertex() единственным вызовом функции glDrawArrays().

GLfloat vertices[] = {...}; // 36 of vertex coords
...
// activate and specify pointer to vertex array
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, vertices);
// draw a cube
glDrawArrays(GL_TRIANGLES, 0, 36);
// deactivate vertex arrays after drawing
glDisableClientState(GL_VERTEX_ARRAY);

Однако, в этом случае необходимо дублировать смежные вершины в массиве, поэтому число вершин будет 36 вместо 8.  Функция glDrawElements() позволяет уменьшить число вершин в массиве за счет использования массива индексов. При этом, поскольку массив вершин содержит 8 вершин, то тип данных GLubyte массива индексов может быть достаточным.

GLfloat vertices[] = {...};          // 8 of vertex coords
GLubyte indices[] = {0,1,2, 2,3,0,   // 36 of indices
                     0,3,4, 4,5,0,
                     0,5,6, 6,1,0,
                     1,6,7, 7,2,1,
                     7,4,3, 3,2,7,
                     4,7,6, 6,5,4};
...
// activate and specify pointer to vertex array
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, vertices);
// draw a cube
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_BYTE, indices);
// deactivate vertex arrays after drawing
glDisableClientState(GL_VERTEX_ARRAY);

Достоинства. При использовании массива вершин передача осуществляется сразу большими блоками, количество обращений к GPU заметно сокращается. Это приводит к гораздо более эффективному использованию GPU и общему повышению быстродействия программы. Указание OpenGL на массивы происходит отдельными функциями, сам вывод тоже происходит отдельной функцией. С массивами вершин вместо тысяч вызовов glVertex3f() мы можем сделать только ОДИН вызов, что-нибудь вроде glDrawArrays(…). Использование массивов вершин ускоряет работу программы в 10, а то и в 20 раз, в зависимости от того, сколько вершин вы отрисовываете. Конечно, в этом примере мы просто отрисовали несколько треугольников, так что большой разницы вы не заметите, но, если добавить ещё 5000 треугольников, то результат будет ощутимым.  Если вы посмотрите исходники любой большой игры, например quake, вы увидите, что все они используют массивы вершин. Играм необходимо каждое минимальное увеличение производительности, так как игровые данные очень объемны, а использование массивов вершин даёт отнюдь не маленький выигрыш.

Недостатки. Этот способ требует постоянной передачи массивов данных от CPU к GPU – каждый вызов, в котором используется указатель на массив приводит к передаче его графическому ускорителю и именно эта постоянная необходимость передачи большого объема данных через шину PCI Express ограничивает эффективность приложения. Использование vertex array может уменьшить число вызовов функций. Тем не менее, недостаток vertex array в том, что функции работы с массивами вершин находятся на стороне клиента и массивы должны быть повторно отправлены на сервер каждый раз, когда к ним обращаются.

Vertex Buffer Objects

Vertex Buffer Objects (VBO) – технология, позволяющая хранить координаты вершин совместно с их атрибутами в видеопамяти. При использовании VBO все геометрия загружается в видеопамять только один раз, на этапе инициализации, после чего мы просто ссылаемся на эти данные. Это, во-первых, позволят существенно разгрузить шину для более важных задач, во-вторых – приводит к существенному повышению производительности, так как GPU может незамедлительно приступать к рендерингу, не дожидаясь пока будут получены данные от CPU.

Для создания  VBO требуется выполнить 3 шага:

  1. Генерировать новый буферный объект (функция glGenBuffersARB()).
  2. Связать буферный объект (функция glBindBufferARB()).
  3. Копировать данные вершин в буферный объект (функция glBufferDataARB()).

Следующий код является примером создания VBO для прорисовки куба:

GLuint vboId;                              // идентификатор VBO
GLfloat* vertices = new GLfloat[vCount*3]; // создать vertex array
...
// генерация  VBO и установление связи с  идентификатором
glGenBuffersARB(1, &vboId);
// будет VBO хранить данные массива вершин или данные массива индексов
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vboId);
// загрузить данные в VBO
glBufferDataARB(GL_ARRAY_BUFFER_ARB,dataSize, vertices, GL_STATIC_DRAW_ARB);
// можно удалить данные после их копирования в VBO
delete [] vertices;
...
// удалить VBO перед окончанием программы
glDeleteBuffersARB(1, &vboId);

Рендеринг с помощью VBO реализуется почти так же, как при использовании vertex array. Разница только в том, что указатель на массив вершин, в данном случае, связан с VBO.

// связывание VBO  массива вершин и данные массива индексов
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vboId1);  // координаты
glBindBufferARB(GL_ELEMENT_ARRAY_BUFFER_ARB, vboId2);//индексы
// делается то же, что и для массива вершин
glEnableClientState(GL_VERTEX_ARRAY);// активиз. массив вершин
glVertexPointer(3, GL_FLOAT, 0, 0);// парам. =0, нет указ_ля
// рисует  6 quads, используя массив индексов
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_BYTE, 0);
glDisableClientState(GL_VERTEX_ARRAY);// деактив. мас. вершин
// связь с 0, поэтому, возврат к нормальным операциям с указателем
glBindBufferARB(GL_ARRAY_BUFFER_ARB, 0);
glBindBufferARB(GL_ELEMENT_ARRAY_BUFFER_ARB, 0);

Ниже приводится код, где модифицируется  VBO

// bind then map the VBO
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vboId);
float* ptr = (float*)glMapBufferARB(GL_ARRAY_BUFFER_ARB, GL_WRITE_ONLY_ARB);
// if the pointer is valid(mapped), update VBO
if(ptr)
{
    updateMyVBO(ptr, ...);         // модификация данных буфера
    glUnmapBufferARB(GL_ARRAY_BUFFER_ARB);
}
// теперь можно нарисовать обновленный VBO
...

VBO предназначен для повышения производительности OpenGL, предоставляя преимущества vertex array и display list , избегая при этом недостатков их реализации.  VBO создает «буфер объектов» для атрибутов вершин в высокопроизводительной памяти на стороне сервера и обеспечивает те же функции доступа к  массивам, которые используются в Vertex arrays. В отличие от display list, данные в VBO могут быть прочитаны и обновлены в памяти клиента.  Другое важное преимущество VBO –деление буфера объектов между большим количеством клиентов наподобие, как и display list и текстуры. Так как VBO на стороне сервера, несколько клиентов будут иметь доступ к тому же буферу с соответствующим идентификатором. Использовать VBO рекомендуется практически всегда, проще назвать причины, когда не следует его использовать: это либо очень маленькие массивы данных (неэффективно), либо очень большие (когда они не помещаются в свободную видеопамять).

Pixel_buffer object (PBO) тесно связан с VBO. Он расширяет возможности  VBO, позволяя сохранить не только данные о вершинах в буфере, но и данные о пикселах.

 

Полезные ссылки:

OpenGL

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *