Давайте напишем ... MMO! Часть 17: Меньше веселья с шейдерами

Опубликовано NowhereMan -

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 - все они были объединены в один персонаж в моем воображении.

Выглядит так:

Поначалу он выглядел таким надежным.

 

Надеюсь, на следующей неделе у меня будут новости получше.