понедельник, 3 июля 2017 г.

Shader Subroutines. Часть 1. Основы.

В этот раз я бы хотел поговорить об относительно старой штуке, появившейся в далеком 2010 году, шейдерных подпрограммах (Shader Subroutines). Не смотря на то, что они в виде расширения были добавлены еще в OpenGL 3.2, о них в сети не так уж и много информации. Фактически я сумел найти всего 6 статей на эту тему и всего пару обсуждений использования этой "фичи". То ли все на столько ясно что не требуется пояснений, то ли люди не понимают/недооценивают возможности этого расширения. К слову, я относился ко второй группе людей. Не смотря на то, что я познакомился с этим расширением еще в момент его выхода, но до сегодняшнего дня я не понимал его места в проекте движка.

Давайте же вместе попробуем разобраться с этим.



Что такое подпрограмма - это функция, которую можно вызывать из шейдера. Закономерный вопрос - а чем она отличается от обычных функций? Ведь к шейдеру можно подлинковывать отдельные шейдерные объекты с нужными нам функциями, в зависимости от задачи, что полезного нам дают эти подпрограммы? Ответ простой - в отличии от шейдерных объектов, которые прикрепляются к программе на этапе линковки, шейдерные подпрограммы можно менять на лету, в рантайм, через юниформы.  За этим следует логичный вопрос - а в чем смысл? Вот и я долгое время не понимал в чем же тут сакральный смысл, но не прошло и 7 лет как на меня снизошло озарение, чем и хочу поделиться с вами.

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

С появлением отложенного освещения задача еще больше усугубилась, сцена разделилась на геометрию с альбедо и освещение, это позволило отделить геометрическую сложность сцены от расчета освещения. Не смотря на кучу плюсов такого подхода, одним из недостатков отложенного освещения было то, что ко всем объектам сцены применялся один и тот же шейдер, что фактически означало одна модель освещения на всю сцену. Таким образом, особой необходимости в динамической смене шейдеров для сцены не было. Разбирать этот вопрос можно долго, но в этом мало смысла, важно то, что тогда не было особой необходимости в динамической смене подпрограммы.

Что же изменилось? Изменилось все, без преувеличения, но основную роль таки сыграли следующие изменения:

  1. Bindless textures
  2. UBO/SSBO
  3. Instancing
Биндлес текстуры - открыли нам доступ к неограниченному количеству текстур на пиксель, буферы UBO/SSBO позволили прикреплять к шейдерам огромные массивы ванных, фактически - описать всю сцену (объекты, текстуры, материалы, источники света), ну и расширенные возможности инстансинга позволили  выводить огромное количество объектов за один раз, без лишних биндов буферов или вызовов glDraw, прерывать это чудо ради бинда текстуры или смены шейдера - совсем не хотелось бы.

Собственно мы подошли к основной идее - если у нас есть доступ к свойствам любого материала на сцене, ко всем текстурам на сцене и ко всем MVP матрицам всех объектов на сцене, то мы можем "запечь" всю сцену в один буфер VBO и отрендерить ее целиком за один вызов glDraw, или разложив этот буфер на отдельные сабмеши и опять же отрисовав их все за один вызов glMultiDraw*Instanced как инстансы, возложив все функции текстурирования/освещения на единственный шейдер.

Это замечательно, но в теории, как только дело доходит до реализации, сразу всплывает множество нюансов. Так как введение уже и так сильно затянулось, то я рассмотрю только одну группу "нюансов", связанную с материалами: 

Рис1. Схема материала

На Рис1. представлен один из вариантов иерархии материалов на сцене, но реальные материалы могут быть куда сложнее (и не только материалы), к примеру у нас будут:

1. Разные модели освещения (Lambert, Blinn, Phong, Cook-Torrance, Rim, No Light и т.д.)
2. Разные модели освещения тянут за собой разный набор свойств
3. Свойства могут быть как простым цветом, так и текстурой картой
4. Текстура может быть как однослойной так и многослойной (мультиматериалы)
5. Каждая отдельная текстура может быть сэмплером а может быть процедурной
6. Может быть несколько алгоритмов процедурной текстуры

Пример на картинке выше разворачивается в 30 отдельных шейдеров, для более сложных случаев это количество растет в геометрической прогрессии, но так раньше делали, и в некоторых статьях можно было найти заявления типа - "у нас в такой-то игре более 20000 шейдеров", теперь вы понимаете почему :)

Во всех найденных по теме статьях (список в конце статьи) используется референсный вариант с передачей указателя на подпрограмму через юниформу, примерно так:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#version 430
layout(location = 0) in vec3 in_Position;

subroutine vec4 colorRedBlue ();

subroutine (colorRedBlue )
 vec4 redColor() { return vec4(1.0, 0.0, 0.0, 1.0); }; 

subroutine (colorRedBlue ) 
 vec4 blueColor() {return vec4(0.0, 0.0, 1.0, 1.0); };

out vec4 color;

subroutine uniform colorRedBlue myRedBlueSelection;

void main(){
    color = myRedBlueSelection();
    gl_Position = in_Position.xyzz;
};

В 4-й строке мы объявляем сигнатуру нашей подпрограммы, далее, в строках 6 и 9 мы имплементим разные варианты этой функции, приводя их к указанной сигнатуре, в строке 14 - объявляется юниформа, через которую мы и укажем какой из описанных в строках 6 и 9 вариантов будет использоваться. Ну и непосредственно в строке 14 мы вызываем выбранный вариант функции.

Со стороны OpenGL нам необходимо передать индекс функции в юниформу myRedBlueSelection. Узнать биндинг этой юниформы в шейдере можно через функцию GetSubroutineUniformLocation:

GLuint funcLoc = glGetSubroutineUniformLocation(shader, GL_VERTEX_SHADER, "myRedBlueSelection");

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

Второй важный момент, который может ввести в заблуждение - этот funcLoc явно не используется :) Фактически это как индикатор того, что в таком то шейдере есть юниформа с таким-то именем. Возникает резонный вопрос - а как же нам передавать индекс подпрограммы в шейдер? А передается он через специальную функцию:

glUniformSubroutinesuiv(GLenum shadertype, GLsizei count, const GLuint *indices)

С этой функцией тоже не все так просто, как вы уже заметили - здесь мы так же должны указать тип шейдера, куда мы будем сетить эту юниформу, но пропал ID шейдерной программы, потому делать это можно только при активной шейдерной программе. Дале, вы уже наверное заметили что функция имеет спецификацию -uiv, тоесть принимает на вход вектор беззнаковых целых, других вариантов функции нет. Собственно следующий нюанс - все индексы подпрограмм должны передаваться в шейдер за один раз, тоесть если у вас в шейдере есть несколько юниформ подпрограмм, то нужно проинициализовать их все за один вызов, для чего последним параметром в этой функции идет указатель на массив индексов подпрограмм.

Для того чтоб понимать какой элемент массива соответствует какой юниформе и используется функция GetSubroutineUniformLocation. Фактически алгоритм установки таких юниформ можно записать в виде такого вот псевдокода:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int sub_count;
glGetProgramStageiv(shader, GL_VERTEX_SHADER, GL_ACTIVE_SUBROUTINES, &sub_count);
GLuint *subs = new GLuint[sub_count];

struct Subroutines {
  GLuint sub_index;
  string unif_name;
}

Subroutines *sub_unif = new Subroutines[sub_count];
sub_unif[0].sub_name = "myRedBlueSelection";
sub_unif[0].sub_index = glGetSubroutineIndex(shader, GL_VERTEX_SHADER, "redColor");


for (int i = 0; i < sub_count; ++i) {
  subs[glGetSubroutineUniformLocation(shader,GL_VERTEX_SHADER, 
    sub_unif[i].unif_name)] = sub_unif[i].sub_index);
}
glUseProgram(shader);
glUniformSubroutinesuiv(GL_VERTEX_SHADER, 1, &subs[0]);

Тоесть первым делом мы должны получить количество активных подпрограмм в шейдере (sub_count, 2 строка). Далее, чтоб связать значения юниформ с именами подпрограмм я объявил структуру Subroutines, в которой sub_index это индекс нужной нам подпрограммы, а unif_name - имя юниформы подпрограммы, к какой мы будем биндить нужную нам подпрограмму. Индекс подпрограммы мы получаем через функцию glGetSubroutineIndex (строка 12):

GLuint glGetSubroutineIndex( GLuint program, GLenum shadertype, const GLchar *name)

Здесь так же указывается шейдерная программа, тип шейдера и имя подпрограммы в шейдере.

В нашем примере мы хотим чтоб объект рисовался красным цветом, потому получаем индекс подпрограммы "redColor" (строка 12) и говорим что его нужно установить для юниформы "myRedBlueSelection" (строка 11). Заполнение этого массива так же задача нетривиальная, так как нужно убедиться что все юниформы активны, что устанавливаемые индексы подпрограмм совместимы с указанной юниформой и прочее. Я упустил этот кусок ради экономии места в статье, к тому же у вас всего одна юниформа, то это и не обязательно.

Для описанных целей в OpenGL имеется несколько функций:

    void GetActiveSubroutineUniformiv(uint program, enum shadertype,
                                      uint index, enum pname, int *values);
    void GetActiveSubroutineUniformName(uint program, enum shadertype,
                                        uint index, sizei bufsize,
                                        sizei *length, char *name);
    void GetActiveSubroutineName(uint program, enum shadertype, uint index,  
sizei bufsize, sizei *length, char *name);

Основная "логика" заключена в строках 16-17, здесь мы связываем юниформы с индексами, формируя массив индексов для целого шейдерного объекта. И последний шаг, строка 20, мы передаем весь этот массив в шейдер, с указанием в какой тип шейдера это пойдет (шейдерная программа должна быть активна). В случае любой ошибки на этом этапе вы получите ошибку с исчерпывающим описанием "Invalid operation".

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

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

Недостаток такого подхода очевиден - перед рисованием каждого объекта/материала нужно передать юниформу с указателем на нужную функцию, таким образом, нарисовать всю сцену за один glDraw* не получится, как впрочем и сделать lightpass в отложенном освещении с использованием разных материалов. Тоесть суть та же, что и использование нагенерированных шейдеров, разве что не нужно делать препроцессинг мега шейдера.

На этом собственно все в статьях и останавливаются, но это лишь вершина айсберга. В статьях главная идея обычно остается за кадром или упоминается вскользь, молв - данный указатель может быть помещен в массив, а индексы на него должен быть "Dynamically Uniform Expression". Что же это нам дает? Это дает ВСЕ!!! Без преувеличения. Это дает нам возможность вычислять этот индекс или получать его напрямую из свойств материала! Таким образом, наша фантазия теперь ограничена лишь максимальным количеством подпрограмм в шейдере (не менее 256 штук). К примеру, рассмотренная выше структура материала требует всего 8 подпрограмм, тоесть линейный рост количества подпрограмм в зависимости от сложности материала.

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

Предположим нам нужно отобразить несколько инстансов, у каждого из которых свой процедурный материал, при этом мы хотим воспользоваться функцией glDrawElementsInstanced, чтоб нарисовать все инстансы со всеми материалами за один раз. Что нам для этого понадобится, во-первых - буфер, в котором будут храниться свойства инстансов - модельная матрица объекта и Id материала, к примеру так:
struct InstanceInfo {
  mat4 ModelMatrix;
  uint MatId[4];
}
Эта информация должна быть помещена в буфер SSBO (можно и в юниформы, но это ограничит количество наших объектов). Здесь я указал MatId как массив из 4-х uint, это сделано исключительно для выравнивания данных в памяти, чтоб оно соответствовало стандарту std430, вы же там можете передавать сразу 4 материала для смешивания.

SSBOPtr<InstanceInfo> objects = 
  SSBOPtr<InstanceInfo>(new SSBO<InstanceInfo>(3, GL_SHADER_STORAGE_BUFFER));
Note: Код, не относящийся к теме статьи, вынесен в отдельный фреймворк
Здесь я создаю персистентный буфер SSBO типа InstanceInfo на 3 элемента (3 инстанса).

Далее мы пишем наш шейдер, в котором будем получать информацию об объектах из этого буфера. Код шейдера приведу целиком и сразу:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const char vShader[] = SHADER_SOURCE(
    layout(location = 0) in vec3 in_Position;
    layout(location = 1) in vec3 in_Normal;

    struct InstanceInfo {
      mat4 Model;
      uint MatId[4];
    };

    layout(std430, binding=1) buffer Instances { InstanceInfo objects[]; };

    subroutine vec4 colorMaterial();

    subroutine (colorMaterial) vec4 redColor()   {return vec4(1.0, 0.0, 0.0, 1.0);};
    subroutine (colorMaterial) vec4 greenColor() {return vec4(0.0, 1.0, 0.0, 1.0);};
    subroutine (colorMaterial) vec4 blueColor()  {return vec4(0.0, 0.0, 1.0, 1.0);};

    out vec4 color;

    uniform mat4 Proj;
    uniform mat4 View;
    subroutine uniform colorMaterial mySelections[3];

    void main(){
        float l = dot(mat3(View)*in_Normal.xyz, vec3(1.0, 1.0, 1.0));
        uint func = objects[gl_InstanceID].MatId[0];
        color = mySelections[func]()*max(l,0.0);
        gl_Position = Proj*View*objects[gl_InstanceID].Model*vec4(in_Position.xyz,1.0);
    };
);

В строки 5-8 мы перенесли структуру нашего объекта из основной программы, в строке 10 - мы объявили буфер SSBO с нашими инстансами. В строке 12 - объявили сигнатуру наших функций, а в строках 14-16 - непосредственно объявили 3 функции, имитирующие разные процедурные материалы. В строке 22 я объявил массив юниформ подпрограмм mySelections, за каждой из них впоследствии будет закреплена своя подпрограмма. Номер инстанса я получаю через встроенную константу gl_InstanceID, используя эту константу я получаю информацию об объекте - идентификатор материала (строка 26) и его модельную матрицу (строка 28). Далее, используя этот идентификатор материала (по сути - это даже не материал а процедура, которая формирует цвет на основе полученных данных, в нашем примере это константа цвета), мы вычисляем цвет вершины (строка 27). Чтоб выглядело не так скучно я в строке 25 добавил постоянное освещение. 

Теперь нам нужно получить индексы соответствующих подпрограмм (строки 1-3):



1
2
3
4
5
  routineC1 = glGetSubroutineIndex(shader, GL_VERTEX_SHADER, "redColor");
  routineC2 = glGetSubroutineIndex(shader, GL_VERTEX_SHADER, "greenColor");
  routineC3 = glGetSubroutineIndex(shader, GL_VERTEX_SHADER, "blueColor");

  GLuint funcLoc = glGetSubroutineUniformLocation(shader, GL_VERTEX_SHADER, "mySelections");

Ну и наконец нам получить указатель на массив юниформ подпрограмм (строка 5). Но так как у нас в шейдере всего одна юниформа такого типа, то ее индекс (funcLoc) будет равен 0.

Теперь заполним наш будем инстансов данными, я взял простой пример, три объекта располагаются в один ряд и имеют разные материалы:


1
2
3
4
5
  for (int i = -1; i < 2; ++i) {
    instancePtr[i+1].Model = glm::translate(glm::mat4(1.0f),
      glm::vec3(i*2.1f, 0.0f, 0.0f));
    instancePtr[i+1].MatId[0] = i+1;
  }

instancePtr это указатель на память буфера SSBO.

Далее, в обязательном порядке, мы биндим шейдер:
glUseProgram(shader);
Формируем массив индексов для юниформы с массивом подпрограмм и передаем этот массив индексов в шейдер::
GLuint func[3] = {routineC1, routineC2, routineC3};
glUniformSubroutinesuiv(GL_VERTEX_SHADER, 3, &func[0]);
Как я уже говорил ранее - у нас в программе только одна юниформа подпрограммы, потому ее индекс в массиве равен 0. В массиве юниформы следуют одна за другой, поэтому я вместо поиска где какая юниформа находится в списке передаю сразу массив с последовательными значениями.

Все что нам останется сделать - нарисоваться наши инстансы через glDrawElementsInstanced:


1
2
3
4
  sphere->Bind(true);
    glDrawElementsInstanced(sphere->getFaceType(), sphere->getElementsCount(),
      GL_UNSIGNED_INT, 0, 3);
  sphere->Bind(false);

Здесь мы биндим VAO (строка 1) и рисуем 3 инстанса. В результате получим что-то похожее на Рис.2.:

Рис.2. Subroutine Demo 

Полный код примера можно скачать здесь.

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


Ссылки на материалы по теме:

https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_shader_subroutine.txt
https://www.khronos.org/opengl/wiki/Shader_Subroutine
https://www.khronos.org/opengl/wiki/Shader_Subroutine/Layout_Qualifier
http://www.lighthouse3d.com/tutorials/glsl-tutorial/subroutines/
http://www.geeks3d.com/20140701/opengl-4-shader-subroutines-introduction-3d-programming-tutorial/
https://www.packtpub.com/books/content/glsl-40-using-subroutines-select-shader-functionality
http://www.sunandblackcat.com/tipFullView.php?l=eng&topicid=20
http://www.informit.com/articles/article.aspx?p=2731929&seqNum=6

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

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