15 апреля 2011
После хороших результатов, которые я получил на прошлой неделе, я ожидал получить рабочее демо. На самом деле, я даже придумал лучший способ сделать то, что я делал с шейдерами, так что я собирался назвать эту часть "Больше веселья с шейдерами". К сожалению, так не получилось.
В части 16 я описал вырезание памяти дисплея, необходимой для кусков пейзажа, путем их кодирования в моей игре и декодирования в шейдере. Это уменьшило использование памяти с 36 байт на вершину до 8 байт. С немного другим расположением битов я мог бы сделать это за 4 байта. Я также не заметил падения производительности. Шейдерный код казался таким же быстрым, как и раньше.
Единственная оставшаяся проблема с таким подходом заключается в том, что мой формат будет работать только с кубами. В настоящее время в игре четыре формы - кубы, ступени, столбы и сферы (которые я использовал вместо факелов Minecraft). В будущих версиях я хотел больше форм - возможно, даже определяемых пользователем. Для этого мне нужно было решение. И я придумал идеальный способ сделать это!
Я посылал поля, связанные с кубом, такие как сторона куба (6 возможных значений, 0-5) и координата текстуры (4 возможных угла, 0-3), а затем возвращал их обратно в "нормальный x, y, z" и "текстура u, v". Вместо этого я понял, что могу просто послать число вершин. На кубе 24 возможные вершины (6 граней на 4 угла) и я мог просто посмотреть каждую вершину в таблице.
Запись в таблице для вершины будет иметь полную нормаль и текстурные координаты. Она также может иметь дельту положения от начала объекта, что позволит мне сделать поверхность сфер, или любой другой формы, которую я хочу. Я мог бы поместить все различные формы в большую таблицу. Вершины для кубов могли бы быть от 0 до 23, а вершины для ступеней от 24 до 48 и так далее. Тогда один шейдер мог бы визуализировать все различные формы.
Я сделал это. Мне придётся показать вам код, так что потерпите, непрограммисты. Сначала мы определим структуру, в которой будет храниться вся информация:
struct VertexInfo { vec3 position; vec3 normal; vec2 texture; };
Затем мы определяем массив этих структур и инициализируем его для всех типов вершин. Для моей первой попытки было всего 24 записи для куба:
const VertexInfo SHAPES[24] = VertexInfo[]( // position normal texture u,v // cube xmin face VertexInfo(vec3(0.0, 1.0, 1.0), vec3(-1.0, 0.0, 0.0), vec2(0.0, 0.0)), VertexInfo(vec3(0.0, 1.0, 0.0), vec3(-1.0, 0.0, 0.0), vec2(1.0, 0.0)), VertexInfo(vec3(0.0, 0.0, 1.0), vec3(-1.0, 0.0, 0.0), vec2(0.0, 1.0)), VertexInfo(vec3(0.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0), vec2(1.0, 1.0)), ...
Для непрограммистов это просто определение строк таблицы. В каждой строке есть три записи: "позиция", "нормаль" и "текстура". У записей три значения, три значения и два значения соответственно. Так что первая строка - для вершины на XMin стороне куба (думайте об этом как о левой стороне.) Позиция равна (0, 1, 1) - это верхний задний угол. Нормаль направлена влево (-1,0,0) и этот угол находится в (0,0) в текстуре рисунке для стороны.
И так далее для каждой из 24 вершин.
Чтобы использовать это в шейдере, мы пишем строчку типа:
vec3 normal = SHAPES[vertex].normal;
Получается строка "вершина" таблицы SHAPES, и вытягивается запись "нормаль". Я получаю также записи о позиции и текстуре, использую все это, чтобы указать вершину для рендеринга, и мы готовы к работе.
И это сработало! Он отрисовал все мои кубики без какого-либо специфического кода кубиков в шейдере. Я скомпилировал шейдер с очень большой таблицей (1024 записи) и он отлично скомпилировался. Так что я знал, что он будет обрабатывать все формы, которые мне нужны. Была только одна маленькая проблема....
Не так быстро
Мой тест на скорость для шейдеров - модифицированная версия игры с выключенным курсором и текстовыми оверлеями, без неба и прозрачных кубов. На этой неделе я также вырезал все некубические формы, чтобы убедиться, что все сравнимо с новыми шейдерами. То, что осталось, это всего лишь серия вызовов, по одному на чанк, чтобы отрисовать все непрозрачные данные в сцене. Это настолько маленькие накладные расходы, насколько я могу сделать. Я рендерю около 300 чанков, просто чтобы получить время, достаточное для точного измерения.
За 15-ю часть демо, использующую 36 байт на вершину, я получаю пересмотренное время 11,79 мс. Для шейдера только для куба, о котором я упоминал на прошлой неделе, оно было еще быстрее - 10,40 мс.
Для нового кода, с моей причудливой таблицей вершин, у меня получилось... 301.26 мс.
Грандиозные три кадра в секунду. Почти в 30 раз медленнее. Бесполезно.
Очевидный вопрос - почему? Код для индексации массива не медленный. Использование данных такое же, как и в шейдере только для куба. Должно быть, это проблема доступа к данным массива констант. Он как-то не помещается в шейдер и находится в более медленной памяти.
Я попытался сделать массив SHAPES локальной переменной и инициализировать его. Это не помогло. Я подумал, что, возможно, компилятор просто несчастен в индексации массивов структур, поэтому я сделал из него массив значений с плавающей точкой и выполнил индексацию самостоятельно. Это тоже не помогло.
Я знал, что константы в коде (когда пишешь что-то вроде "pi = 3.14") не могут быть такими медленными. Шейдеры никогда не получат приличной производительности в этом случае. Поэтому я перекодировал таблицу в виде большого выражения switch:
switch (vertex) { case 0: position = vec3(0.0, 1.0, 1.0); normal = vec3(-1.0, 0.0, 0.0); texture = vec2(0.0, 0.0); break; case 1: ... }
И это сделало фокус. Время упало до 11 мс. Ура! Большой оператор "switch" был довольно шатким, но это сработало. Я увеличил его до 48 записей и добавил форму ступеньки. Я снова увеличил его до 72 записей, чтобы сделать форму столбца. И... это перестало работать. Компилятор не выдает ошибку, но оно просто не работает.
Все это было с OpenGL 3.3, и шейдерным языком 3.3. Я просмотрел документацию по шейдерному языку 1.2, который я должен использовать на Mac, и на нем даже не было выражений переключения. Поэтому мне пришлось переписать все на операторы if. простой способ сделать это - сравнить вертекс с каждым из 24 значений и выполнять задания, когда вы попадаете в нужное. Для таблицы из 1000 записей это будет медленно. Поэтому я написал ее как бинарное дерево. Для 8 записей это выглядело так:
if (vertex < 4) { if (vertex < 2) { if (vertex < 1) { // vertex 0 position = vec3(0.0, 1.0, 1.0); normal = vec3(-1.0, 0.0, 0.0); texture = vec2(0.0, 0.0); } else { // vertex 1 position = vec3(0.0, 1.0, 0.0); normal = vec3(-1.0, 0.0, 0.0); texture = vec2(1.0, 0.0); } } else { if (vertex < 3) // vertex 2 ... else // vertex 3 ... } } else { if (vertex < 6) { if (vertex < 5) // vertex 4 ... else // vertex 5 ... } else { if (vertex < 7) // vertex 6 ... else // vertex 7 ... } }
Как видите, это утомительно, поэтому я написал программу для генерации выражений if. Для таблицы из 8 записей это означает три сравнения, чтобы найти значение, вместо среднего из 4. Ничего особенного. Но для таблицы из 1024 записей это означает 10 сравнений, вместо среднего значения в 512. Определенно стоит того!
Это сработало, и время было идентично версии switch, и могло справиться с 72 случаями, и работало бы на Mac. Дальше больше.
Время для другой видеокарты
Я решил протестировать мои различные сжатые вершинные шейдеры на моей Linux машине, которая имеет встроенный графический чип ATI Radeon 4200.
Я запустил тот же самый тест на машине с Linux, но только с 88 тестовыми блоками (расстояние просмотра = 150) вместо 321 (расстояние просмотра = 300). Вот таблица времен для различных версий:
Version | NVidia 250 (321 chunks) | ATI 4200 (88 chunks) | Ratio (ATI/NVidia time) |
---|---|---|---|
large vertexes | 11.79 | 18.64 | 158% |
cube-only vertexes | 10.40 | 22.27 | 214% |
shape table | 301.26 | 135.33 | 45% |
24-case switch | 10.80 | 102.98 | 954% |
72-case if tree | 10.80 | 45.45 | 421% |
Тут я вижу, что карта NVidia обрабатывает операторы switch, и if хорошо - почти так же, как и более простой cube-only вариант. Но она плохо обрабатывает массив таблицу форм. Я все это знал.
Что интересно, так это сравнение двух видеокарт. Хотя ATI также плохо обрабатывает таблицу форм, по сравнению с базовым временем, она не так уж и плоха, как NVidia. С другой стороны, она гораздо хуже справляется с оператором switch.
На самом деле, ATI запускает 24-уровневый switch много медленнее, чем дерево из 72-х операторов if. Если мы считаем, что дерево if выполняет 6 тестов на вершину, а 24-кейсный переключатель реализован в виде серии if-then-else операторов и в среднем 12 тестов на вершину, то это означает, что переключатель должен быть в два раза медленнее, чем if-дерево, и так оно и есть.
Это создает еще одну проблему для переносимости. Я не только не могу написать одинаковый шейдерный код для всех видеокарт, я даже не могу быть уверен, что шейдерные алгоритмы будут работать так же хорошо на одной видеокарте, как и на другой.
Что разочаровывает, так это то, что даже шейдер только для куба на ATI-дисплее работает медленнее, чем при использовании больших вершин. Время, сэкономленное благодаря меньшему объему данных для отправки на дисплей, используется дополнительным кодом в шейдере. Ни один из других методов не приближается ко времени простого использования больших вершин. На интегрированном дисплее ATI я мог бы также использовать большие вершины и тупой шейдер для всего.
Еще одна попытка
Всё ещё есть надежда. Шейдер только для кубиков мог быть медленнее, потому что в нем есть маленькие операторы switch. Если я перепишу шейдер, чтобы избежать их полностью, возможно, я все еще смогу посылать короткие вершины, что позволит мне загрузить больше кусочков пейзажей в видеокарту. Вот как:
В шейдере только для кубов, я делал такие вещи:
vec3 normal; switch (nCode & 0x7) { case 0: normal = vec3(1.0, 0.0, 0.0); break; case 1: normal = vec3(-1.0, 0.0, 0.0); break; etc ... }
Здесь я беру 3 бита (значения 0 - 5) и, используя оператор switch, создаю правильную нормаль, основанную на стороне. Вместо этого я могу сделать что-то подобное:
vec3 normal; normal.x = float((nCode & 0x3)-1); normal.y = float(((nCode >> 2) & 0x3)-1); normal.z = float(((nCode >> 4) & 0x3)-1);
Здесь я беру 2 бита для нормального x (используя значения 0, 1, 2) и вычитаю 1, чтобы получить -1, 0 или 1, что является диапазоном возможных значений, которые я хочу. Я делаю это и на y и на z, беру по 2 бита в кодированном целочисленном nCode. Вместо того, чтобы использовать одно поле из 3 бит, я использую 3 поля из 2 бит, или 6 в целом. Это использует больше места в сжатой вершине, но, как видите, операторов if или switch нет. Я могу сделать то же самое с координатами текстуры.
Когда я делаю это, я получаю те же 10,40 мс в случае NVidia, но теперь я получаю время 17,49 мс в случае ATI. Это немного быстрее, чем с большими вершинами, и дает нужное мне уменьшение памяти. Ура!
Итог
Есть шейдерные программы, которые я могу написать и которые будут хорошо работать на всех устройствах. Простые шейдеры, которые я использую для версии с большими вершинами (часть 15), работают нормально, так же как и шейдеры, которые я использую в последний раз без операторов if. Похоже, что шейдеры do-many-shapes невозможно писать переносимо. В конце концов, для каждой фигуры у меня должен быть отдельный шейдер.
Я спросил Флориана Бёша (у которого есть новый пост в его собственном блоге, здесь), что мне делать с шейдерами. Его (отредактированный) ответ был:
- Массивы Const очень медленные. Это страшно.
- Структуры, как правило, немного медленнее, чем массивы.
- С массивами однородных элементов ты должен получить большую скорость. К сожалению, большинство аппаратных средств ограничивает их до 128-256 элементов.
- Следует избегать действий, которые могли бы помешать компилятору встраивать (inline) доступ к индексу массива.
- Не следует делать слишком много битовых операций (сдвигов и AND) на GPU. Они не очень хороши в них, а аппаратная поддержка для них очень плохая.
- Вам следует избегать условных операторов. Большую часть времени они становятся встроенными (inlined), за исключением тех случаев, когда это не так, и в этом случае они становятся очень медленными.
- Шейдерные ядра имеют очень, очень маленький стек (что-то около 2 КБ на ядро или около того). Если вы загружаете много данных в этот стек, они постоянно получают данные из основного VRAM, это шинный доступ, блокирующий все остальные ядра от выполнения того же самого.
Мое состояние души
По мере того, как я работал над низкоуровневым графическим кодом для этого проекта, я постепенно развивал ментальный образ индустрии компьютерной графики. Дизайнеры OpenGL и DirectX, производители 3D-оборудования и авторы справочников и OpenGL SuperBible - все они были объединены в один персонаж в моем воображении.
Выглядит так:
|
Надеюсь, на следующей неделе у меня будут новости получше.
- Войдите или зарегистрируйтесь, чтобы оставлять комментарии