вторник, 21 февраля 2012 г.

Основы VBO в OpenGL. Часть третья, индексный буфер.



  1. Часть первая, первый квадрат
  2. Часть вторая, добавляем атрибуты
  3. Часть третья, индексный буфер
  4. Часть четвертая, динамика
  5. Часть пятая, дополнительные возможности
  6. Часть шестая, VAO
  7. Ссылки по теме

В предыдущих уроках мы для рисования одного четырехугольника использовали 6 вершин, 2 из которых дублировались. Причиной этого было использования для рисования команды glDrawArrays, которая в качестве параметра может принимать только массив вершин, и при выводе считает что идущие друг за другом три вершины формируют треугольник, и изменить этот порядок никак нельзя. Естественно, если вы будете использовать в качестве типа грани GL_QUADS, то один квадрат будет формироваться из 4-х вершин, а в случае GL_TRIANGLE_STRIP – каждая вершина, после первых двух, будет добавлять новый треугольник.
Но данный подход не выгоден по двум причинам – во-первых это перерасход памяти на дублирующиеся вершины, во-вторых – все трансформации в GPU рассчитываются для каждой вершины, в том числе и дублирующейся. В третьих в современных видеокартах есть так называемый Pre-/Post- TnL Cache, суть которого в экономии GPU времени на расчет дублирующихся вершин. Эти проблемы решаются за счет использования так называемого индексного буфера, а технологию иногда называют IBO.

Суть индексного буфера – хранить номера вершин. Рассмотрим предыдущий пример:
Листинг 17:
1
2
3
4
5
6
7
   Setlength(VertexBuffer,6);
   VertexBuffer[0].X:=-1; VertexBuffer[0].Y:=1; VertexBuffer[0].Z:=0;
   VertexBuffer[1].X:=-1; VertexBuffer[1].Y:=-1;VertexBuffer[1].Z:=0;
   VertexBuffer[2].X:= 1; VertexBuffer[2].Y:=1; VertexBuffer[2].Z:=0;
   VertexBuffer[3].X:= 1; VertexBuffer[3].Y:=1; VertexBuffer[3].Z:=0;
   VertexBuffer[4].X:=-1; VertexBuffer[4].Y:=-1;VertexBuffer[4].Z:=0;
   VertexBuffer[5].X:= 1; VertexBuffer[5].Y:=-1;VertexBuffer[5].Z:=0;

Мы задали 6 вершин - 2 треугольника по 3 вершины в каждом, при этом вершины 1,4 и 2,3 у нас дублируются. Точно так же у нас дублируются вершины в массиве цвета, нормалей и текстурных координат, другими словами 1/3 данных у нас дублируются.
Посчитаем сколько занимает памяти наша структура и сколько из этого приходится на дублирующиеся вершины:
Массив цветов – 3 компоненты по 1 байту каждая, в сумме 3 байта * 6 вершин = 18 байт.
Массив нормалей – 3 компоненты по 4 байта каждая, в сумме 12 байт * 6 вершин = 72 байта
Массив вершин идентичен массиву нормалей и занимает тоже 72 байта
Массив текстурных координат имеет только две компоненты, и занимает 2*4*6=48 байт
Итого – вся наша структура занимает в сумме 18+72+72+48=210 байт, из которых 1/3 (70байт) дублирующихся, в результате мы имеем 30% перерасход памяти и 30% падение производительности.

Что избавиться от этого в OpenGL было решено воспользоваться проверенным подходом - вместо самих данных передавать указатели на эти данные, в виде номеров/индексов вершин в едином вершинном буфере. Сами же индексы точно так же помещаются в буфер VBO.

Давайте теперь создадим этот массив, который будет хранить эти номера каждой из вершин:
Var Indices: array of byte;
……..
SetLength(Indices,6);
Тоесть это простой одномерный массив, размер которого равен количеству вершин во всех треугольниках, в нашем случае у двух треугольников 6 вершин.

Заполним данный массив номерами вершин:
Indices[0]:=0; Indices[1]:=1; Indices[2]:=2;
Indices[3]:=3; Indices[4]:=4; Indices[5]:=5;
Тоесть каждый элемент этого массива указывает на номер соответствующей вершины. В результате, когда видеокарте потребуется узнать какую вершину нужно отрисовать следующей, она обращается не к массиву VertexBuffer а к массиву Indices и берет вершину как VertexBuffer[Indices[i]]. Что нам дает такой подход? А дает нам такой подход то, что теперь вместо хранения в памяти дублирующихся вершин, мы просто будем дублировать их номера в индексном массиве, тогда предыдущий пример можно переписать в таком виде:
Листинг 18:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
   Setlength(VertexBuffer,4);
   VertexBuffer[0].X:=-1; VertexBuffer[0].Y:=1; VertexBuffer[0].Z:=0;
   VertexBuffer[1].X:=-1; VertexBuffer[1].Y:=-1;VertexBuffer[1].Z:=0;
   VertexBuffer[2].X:= 1; VertexBuffer[2].Y:=1; VertexBuffer[2].Z:=0;
   VertexBuffer[3].X:= 1; VertexBuffer[5].Y:=-1;VertexBuffer[5].Z:=0;
   SetLength(Indices,6);
   Indices[0]:=0;
   Indices[1]:=1;
   Indices[2]:=2;
   Indices[3]:=2;
   Indices[4]:=1;
   Indices[5]:=3;

Вместо 6 вершин мы в массиве VertexBuffer храним только 4 разных, а дублируем их уже в массиве Indices (совпадающие выделены одинаковым цветом). Использование данного массива добавило нам лишних 6 байт, но за счет того, что мы перестали хранить дублирующиеся вершины, это сэкономило нам 70 байт, тоесть выигрыш у нас получился 64 байта на 6 вершинах и чем больше у нас вершин и чем больше из них повторяющихся тем существеннее будет экономия памяти. Но здесь более важен факт кеширования повторяющихся вершин (особенно их трансформаций) в быстрой памяти, за счет чего можно получить 4-х кратный прирост производительности, но об этом дальше.

Как же теперь сказать OpenGL чтоб он использовал созданный нами индексный массив? Давайте разберем этот процесс поэтапно, как мы делали это для вершинного буфера.

Этап первый – создание индексного буфера, данный этап мало чем отличается от создания вершинного буфера, за исключением константы:
Листинг 18:
1
2
3
4
5
6
7
8
var iId: Cardinal;
begin
  Count := high(Indices)+1;
  glGenBuffers( 1, @iId );
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iId);
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLUByte) * Count, @Indices[0], GL_STATIC_DRAW);
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
end;

В отличии от вершинного буфера нам необходимо использовать константу GL_ELEMENT_ARRAY_BUFFER, в остальном отличий на этом этапе нет.

Теперь переходим ко второму этапу – рисование. Вот тут уже есть некоторые отличия, первое отличие – не существует специального состояния или указателя на данный тип буфера. Это очень распространенная ошибка, вызванная созвучностью команд:
glEnableClientState(GL_INDEX_ARRAY)
glIndexPointer(GL_UNSIGNED_SHORT, 0, 0);  
glDisableClientState(GL_INDEX_ARRAY)
Не используйте эти команды!!! Они предназначены лишь для работы с индексированной палитрой цветов, которая уже более 10-ти лет нигде не применяется.

Так как же все-таки активировать индексный буфер? Очень просто – дописать перед процедурой рисования вот такую строку:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iId);
Таким образом, мы просто в нужный момент времени биндим данный буфер. Особенность данного буфера заключается в том, что он кэшируется драйвером видеокарты, о самой оптимизации под Pre-TnL мы поговорим ниже. 

Как активировать наш индексный буфер мы разобрались, как же теперь заставить видеокарту использовать его? Ведь нет ни специальных состояний, ни специальных указателей? Зато есть специальная команда для рисования с использованием индексного буфера:
glDrawElements(GL_TRIANGLES, IndicesCount, GL_UNSIGNED_BYTE, nil);
Как видите – внешне она мало чем отличается от использовавшейся ранее команды glDrawArrays, но основное ее отличие в том, что она при рисовании использует наш индексный буфер. Последний параметр позволяет задавать смешение начала буфера, чтоб рисовать не с первого индекса.

Ну и теперь совместим все написанное в нашу новую процедуру VBODraw:
Листинг 19:
1
2
3
4
5
6
7
8
9
begin
  glEnableClientState( GL_VERTEX_ARRAY );
  glBindBuffer( GL_ARRAY_BUFFER, vId );
  glVertexPointer( 3, GL_FLOAT, 0, nil );
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iId);
  glDrawElements(GL_TRIANGLES, IndicesCount, GL_UNSIGNED_BYTE, nil);
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
  glDisableClientState(GL_VERTEX_ARRAY);
end;
Я взял простейший случай, дабы не загромождать статью кодом, но вы легко сможете изменить последний пример, ну или посмотреть как это сделано в исходниках Demo4.

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

Так же рекомендую ознакомиться с этой статьей: VBO Indexing
В ней на картинках показана индексация, а так же рассмотрен процесс сглаживания нормалей.

Теперь поговорим немного об оптимизации. Как я уже говорил ранее – оптимизация заключается в подготовке данных для дальнейшего кэширования видеокартой. Таких кэша у нас два – Pre и Post TnL. Первый – кэширует индексы, его размер обычно или 4096 или 65535 или 1024к индекса. Узнать сколько именно рекомендуется использовать индексов на конкретной видеокарте можно воспользовавшись такой вот функцией:
Листинг 20:
1
2
3
4
function GetMaxIndicesCount: GLUInt;
begin
  glGetintegerv(GL_MAX_ELEMENTS_INDICES, @result);
end;

Тем самым, задача программиста по оптимизации под Pre-TnL кэш сводится лишь к тому, чтобы следить чтоб количество индексов по возможности не превышало максимально рекомендуемое количество, все остальное сделает драйвер видеокарты. Так же рекомендуется правильно выбирать тип индексов. Если вам нужно вывести квад из 6 вершин, то нет смысла использовать тип GL_UNSIGNED_INT, позволяющий работать с 4 миллиардами вершин. В нашем случае для индексов я использовал тип GL_UNSIGNED_BYTE, что позволяет работать с 256 вершинами.

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

К сожалению размер данного кэша очень маленький и составляет от 16 до 32 вершин. Задача так же усложняется тем, что нет возможности узнать точный размер Post-TnL кэша, а ошибка всего на одну вершину может нарушить когерентность данных, что приведет к значительному падению производительности. Иногда рекомендуется брать минимальный размер, к примеру 12 вершин, но и это плохо, так как при обычном, ленточном, задании геометрии у нас будут дублироваться 2 вершины из 3-х для любого треугольника, в то время как после оптимизации этот коэффициент может оказаться намного ниже. Проведя десятки экспериментов на разных видеокартах и объектах разной сложности я пришел к выводу что дешевле вообще не производить оптимизацию, так как кроме очень негативных последствий при выборе неоптимального размера кэша (падение производительности может доходить до 20-30%), эта процедура еще и очень длительная, так при использовании утилиты NVTriStrip от NVidia на оптимизацию сетки из 200-300к полигонов уходило до 5 минут, а в результате выигрыш мог составлять менее 5% по сравнению с неоптимизированной сеткой.

Более подробно об оптимизации под Post-TnL кэш можно почитать к примеру тут:

Бинарник и исходные коды примера можно взять тут: Demo4

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

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