четверг, 3 ноября 2011 г.

Что внутри. VBO, часть третья, оптимизация

При работе с VBO нужно учитывать массу моментов, часть из них мы с вами уже разобрали, мы уже определились что нужен индексный буфер, что нужно уменьшать количество пакетов, что лучше рисовать все за один вызов и т.д. Сейчас мы разберем почему так, как это влияет на производительность, что можно сделать для повышения производительности и как это представлено в VBOMesh.

Для начала рассмотрим "классический" цикл рисования VBO:

      glEnableClientState(GL_NORMAL_ARRAY);
      glBindBuffer(GL_ARRAY_BUFFER, nId);
      glNormalPointer(GL_FLOAT, 0, nil);

      glClientActiveTexture(GL_TEXTURE0);
      glEnableClientState(GL_TEXTURE_COORD_ARRAY);
      glBindBuffer(GL_ARRAY_BUFFER, tId);
      glTexCoordPointer(2, GL_FLOAT, SizeOf(TAffineVector), nil);

      glEnableClientState(GL_COLOR_ARRAY);
      glBindBuffer(GL_ARRAY_BUFFER, cId);
      glColorPointer(4,GL_FLOAT, 0, nil);
      glEnable(GL_COLOR_MATERIAL);

      glEnableClientState(GL_VERTEX_ARRAY);
      glBindBuffer(GL_ARRAY_BUFFER, vId);
      glVertexPointer(3, GL_FLOAT, 0, nil);

      glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iId);

      glDrawElements(GL_TRIANGLES, Count, GL_UNSIGNED_INT, nil);

      glDisableClientState(GL_VERTEX_ARRAY);
      glDisableClientState(GL_NORMAL_ARRAY);
      glDisableClientState(GL_TEXTURE_COORD_ARRAY);
      glDisableClientState(GL_COLOR_ARRAY);
      glBindBuffer(GL_ARRAY_BUFFER, 0);
      glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
Из примера видно что у нас есть 4 вершинных атрибута (координата, нормаль, цвет и текстурная координата), каждый из этих атрибутов хранится в отдельном буфере (vId, nId, cId, tId), перед началом вывода мы разрешаем использование соответствующего атрибута (glEnableClientState), после чего активируем буфер нужного атрибута (glBindBuffer) и передаем видеокарте указатель на этот буфер (gl*Pointer). После чего выводим всю геометрию (glDrawElements) и отключаем использование всех вершинных атрибутов. Отдельно стоит отметить использование индексного буфера, для него вызывается только команда glBindBuffer без каких-либо активаций клиентских состояний или указателей на этот буфер. До недавнего времени это была распространенная ошибка, копировавшаяся из статьи в статью, где люди для индексного буфера использовали состояние GL_INDEX_ARRAY и glIndexPointer. Не смотря на созвучность это относилось к индексированному цвету (индексу в палитре цветов, помните разрешение экрана с 16 и 256 цветами?).

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

Что же тут можно сделать чтоб ускорить этот процесс? Много-чего, но обо всем по-порядку.
Итак, для начала, представленный выше пример является одним из трех способов задания буфера VBO, так называемые "раздельные буферы" (Separate Buffers). Это подразумевает что каждый вершинный атрибут находится в своем буфере, который можно удалить, пересоздать, поменять (вспоминаем предыдущую статью). Это дает более гибкие возможности, но требует биндить каждый буфер отдельно. Объединенный/Множественный буфер (Non-interleaved/Multiple Buffer) - все вершинные атрибуты склеиваются в один большой буфер, первая часть буфера хранит все координаты вершин, за ними идут все нормали и т.д. Такой подход позволяет уменьшит количество биндов буфера до одного и обеспечивается локальность данных, что немного ускоряет вывод геометрии. Минусы - если понадобится воспользоваться частью одного буфера в другой модельке, то придется биндить оба буфера, как следствие - потеряем все преимущества такого подхода, необходимо кроме идентификатора буфера хранить еще и смещение в этом буфере, а так же усложняется код, так как приходится учитывать возможность объединения буфера. Ну и третий способ, так называемые чередующийся буфер (Interleaved Buffer). Принцип тот же что и у предыдущего способа, но если в предыдущем способе массив с вершинным атрибутом добавлялся в конец буфера, то теперь данные идут вперемешку для каждой вершины, тоесть вначале идет координата первой вершины, потом нормаль первой вершины, потом цвет первой вершины, затем координата второй вершины, нормаль второй вершины и т.д. При таком подходе достигается наибольшая производительность, но этот буфер становится абсолютно статичным, тоесть нельзя использовать его части, нельзя обновить только один вершинный атрибут и т.д.

Выбирая между гибкостью и производительностью я остановился на раздельных буферах, так как разница в производительности не столь существенна, зато открываются новые возможности и вся работа с буферами VBO становится "однородной", что существенно упрощает код движка.

Но на этом оптимизации не заканчиваются. Как мы помним - у нас могут быть десятки тысяч объектов, и если прикинуть, что современная видеокарта способно вывести до 5 миллионов полигонов, выходит что у одной модельки будет до 500 полигонов, а то и вовсе 12, как у кубиков из Maincraft. При таком раскладе мы больше времени будем тратить на переключения буферов и активацию состояний чем на рисование. Потому самым разумным способом является объединение нескольких буферов VBO разных моделей в один большой. Это позволит уменьшить количество биндов буфера с 10000 до 1! (один). Не сложно догадаться что прирост производительности будет фантастическим. Сюда так же можно добавить тот факт, что мы можем за один вызов glDrawElements выводить сразу несколько объектов (если для них не нужно указывать разные матрицы трансформации и не нужно применять разные материалы, но и это обходится).

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

Но что делать если объединение в один буфер не вариант? Такая ситуация возникает в первую очередь для подгружаемых объектов, такая ситуация может возникнуть при необходимости сортировки объектов, при окклюжн куллинге, при смене уровней детализации и т.д. Что же в таком случае делать? Мы обречены? К счастью нет, в OpenGL 2.1 появилась новая технология, называемая VAO (Vertex Array Object), это своеобразный аналог старых дисплейных списков, позволяющий объединить всю активацию буферов VBO под одной командой, таким образом предыдущий пример из 21 строки превращается в 3:
      glBindVertexArray(vao);
      glDrawElements(GL_TRIANGLES, Count, GL_UNSIGNED_INT, nil);
      glBindVertexArray(0);

Таким образом мы во-первых существенно ускоряем вывод множества объектов, во-вторых - компенсируем потери на использовании раздельных буферов VBO.

Это все позволяет существенно увеличить количество выводимых объектов, но, тем не менее, с ростом количества объектов производительность неизменно падает и с этим ничего нельзя поделать, или вернее сказать - нельзя было ничего поделать. Не так давно NVidia представила два новых расширения: GL_NV_shader_buffer_load и GL_NV_vertex_buffer_unified_memory и назвала это "Bindless graphics". О деталях можно почитать здесь: http://origin-developer.nvidia.com/object/bindless_graphics.html
Идея заключается в прямом доступе к видеопамяти, за счет этого мы избавляемся от дорогого бинда буферов, что позволяет выводить десятки тысяч объектов без потери производительности. К сожалению эта технология (как и многие другие, не менее интересные) пока поддерживается только NVidia и требует поддержки OGL 3.x, потому, в угоду совместимости, пришлось отказаться от этой возможности, хотя в будущем есть возможность подключить эту технологию к рендеру VBOMesh.


Отдельно стоило бы упомянуть об оптимизации под Pre-/Post-T&L cache, но это на столько тонкая материя что вместо прироста производительности можно запросто получить потерю производительности. Проблема в том, что нет способа узнать точно размер вершинного кэша, возьмешь меньше - будет не эффективно, возьмешь хоть на одну вершину больше - данные рассинхронизируются и получишь катастрофическое падение фпс. Да и сама процедура оптимизации геометрии достаточно долгая и для больших моделей (более 64к вершин) ее желательно производить оффлайн, иначе загрузка игры превратится в недельное ожидание.
После множества тестов все же решил отказаться от этого, выигрыш от правильно подобранных индексов не покрывает всех сложностей с их работой.
Что касается предварительного кэша - его размер задается драйвером и с недавнего времени он у всех видеокарт равен миллиону вершин, что существенно больше большинства моделей, потому какая либо предварительная оптимизация теряет смысл. При этом эти же видеокарты отлично справлялись и при существенно большем количестве вершин, в этом случае из-за существенного падения производительности (все-таки 4 миллиона полигонов нужно вывести) эта разница попросту незаметна. Потому так же было решено отказаться от этой оптимизации (в модуле лишь присутствует возможность вывода геометрии через glDrawElementsRange, что теоретически позволяет драйверу лучше оптимизировать индексы).

На этом наше знакомство с VBO подошло к концу. Осталось лишь перечислить базовые объекты, выводимые моим движком средствами VBO:
Plane - плоскость с возможностью указания разбивки и функции высоты
Box - коробка/куб, с указанием разбивки
Sphere - сфера, тайлинг текстуры, инверсия нормалей
AABB - окаймляющий бокс (линии)
Grid - цветная сетка
Points - точки/частицы
VolumetricLines - объемные линии
SkeletalAnimation - скелетная анимация на GPU, стандартная (через юниформы) и через r2vb
Sprite - цилиндрический, сферический, худ-спрайт а так же анимированный спрайт
ScreenQuad - скринквад (передний/задний план)
Terrain - ландшафт по карте высот
FreeForm - загрузка моделей из 3ds/obj/smd
GUI - графический пользовательский интерфейс
Пользовательские объекты.

В следующих статьях более детально рассмотрю особенности реализации каждого из объектов.

3 комментария:

  1. Это ты за ночь написал? Похвально.
    Всё прочёл , узнал много нового.

    ОтветитьУдалить
  2. Fantom, без обид, но тег "more" (он же "под катом") - самое лучшее изобретение для блогосферы :)

    Спрячь под кат оснонвые части статей, а то скроллить страницу неудобно :)

    "Зафрендил" твою уютную жжшечку :)

    ОтветитьУдалить
  3. Ого, я думал народ это будет неделю читать :)

    Если верить времени создания сообщений то ушло 5 часов, естественно с перерывами на ужин, кино и домашние дела. Реально пару часов пришлось поработать.

    Спасибо за напоминание про тэг, я как-то забыл о его существовании :)

    ОтветитьУдалить