На главную Назад Вперёд

Программирование шейдеров

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

Как и в прошлой главе, примеры приведены на C++ и Javascript. При этом для Javascript-примеров считается, что OpenGL-контекст хранится в глобальной переменной GL.

Язык GLSL

Для программирования шейдеров в основном используется язык GLSL – диалект языка C с некоторыми дополнительными особенностями. Многие аспекты языка сильно зависят от используемой версии OpenGL, поэтому сперва договоримся о том, что далее мы будем рассматривать только OpenGL версии GLES 2.0.

Поскольку при использовании OpenGL из SDL2 эта версия не является версией по-умолчанию, её нужно явно указать перед вызовом функции SDL_GL_CreateContext.

SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);

В браузере ничего выставлять не нужно, WebGL приблизительно соответствует стандарту GLES 2.0.

Передача данных между шейдерами

Предположим, что в вершинном шейдере объявлены две входные переменные:

attribute vec4  pos;
attribute float foo;

Рассмотрим следующий массив данных:

-0.5,    0, 0, 1, 0,                              
 0.5,  0.5, 0, 1, 1,                             
 0.5, -0.5, 0, 1, 1 

Его можно разбить на три блока по пять чисел. Для того, чтобы интерпретировать первую четвёрку чисел каждого блока как значение pos, а последнее число каждого блока как foo, можно сделать два вызова vertexAttribPointer.

В C++ это выглядит так (по какой-то причине последний аргумент сделан не числовым, а типа «указатель»):

glVertexAttribPointer(pos, 4, GL_FLOAT, GL_FALSE, 20, nullptr); 
glVertexAttribPointer(foo, 1, GL_FLOAT, GL_FALSE, 20, (void*)((char*)nullptr+16)); 

В Javascript (в предположении того, что WebGL-контекст находится в глобальной переменной GL) это выглядит так:

GL.vertexAttribPointer(pos, 4, GL.FLOAT, false, 20, 0)
GL.vertexAttribPointer(foo, 1, GL.FLOAT, false, 20, 16)

Напомним ещё раз, что 20 – это размер блока данных, а последний агрумент задаёт смещение значения переменной относительно начала блока.

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

varying float fs_foo;

В вершинном шейдере следует выставить её значение:

void main() {
    ...
    fs_foo = foo;
    ...
}

А во фрагментном шейдере нужно этим значением обязательно воспользоваться (иначе эта переменная будет полностью вырезана на этапе сборки программы).

Например, подойдёт такой способ:

void main() {
    gl_FragColor = vec4(fs_foo,1,1,1);
}

Также в начале фрагментного шейдера нужно обязательно (это касается именно версии GLES 2.0) указать желаемую точность вычислений с плавающей точкой. Для совместимости с большинством устройств рекомендуется указывать

precision mediump float;

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

Интерполяция значений

Выходными данными вершинного шейдера являются четырёхмерный вектор gl_Position, а также – все varying-переменные. Во фрагментном шейдере нет прямого доступа к gl_Position, зато есть доступ ко всем varying-переменным. Возникает естественный вопрос: «чему равны значения этих переменных во фрагментном шейдере?»

Для ответа на этот вопрос нужно подробно описать процедуру проекции треугольника на изображение. Временно предположим, что изображение не дискретно, а непрерывно (т.е. состоит не из отдельных пикселей, а представляет собой декартов квадрат отрезка \([-1,1]\)). Введём обозначения \(v_1=(x_1,y_1,w_1)\), \(v_2=(x_2,y_2,w_2)\), \(v_3=(x_3,y_3,w_3)\), в которых \(x_i\), \(y_i\), \(w_i\) – первая, вторая и четвёртая компоненты вектора gl_Position для i-й вершины. Как известно из курса школьной геометрии, любая точка треугольника p может быть однозначно представлена в виде:

\[p = a_1 v_1 + a_2 v_2 + a_3 v_3\]

где \(a_i\) – неотрицательные числа с суммой 1, называемые барицентрическими координатами точки p.

У каждого пикселя изображения есть т.н. экранные координаты – пара чисел в пределах от -1 до 1. Далее мы их будем обозначать \(s_x\) и \(s_y\). Середина изображения имеет координаты \(s_x=0\) и \(s_y=0\). Положительное направление \(s_x\) – вправо, положительное направление \(s_y\) – вверх.

Точка p с координатами (обычными) \((x,y,w)\) проецируется в точку \(s_x=x/w\), \(s_y=y/w\). Все varying-переменные ведут себя так: если \(f_1\), \(f_2\) и \(f_3\) – значения переменной foo в вершинах треугольника, то значение foo в точке \((s_x,s_y)\) равно

\[f = a_1 f_1 + a_2 f_2 + a_3 f_3\]

Такая формула называется линейной интерполяцией значения из опорных значений \(f_1\), \(f_2\), \(f_3\). Ещё говорят, что foo линейно распределено по треугольнику.

Ещё в точке \((s_x,s_y)\) вычисляется экранная глубина \(s_z\) по формуле

\[s_z = b_1 \frac{z_1}{w_1} + b_2 \frac{z_2}{w_2} + b_3 \frac{z_3}{w_3}\]

в которой \(b_i\) – барицентрические координаты точки \((s_x,s_y)\) в спроецированном треугольнике, а \(z_i\)третья компонента вектора gl_Position в i-й вершине. Если экранная глубина оказывается меньше -1 или больше 1, точка «обрезается» (не переносится на изображение). Также обрезаются точки с отрицательным значением пространственной глубины

\[w = a_1 w_1 + a_2 w_2 + a_3 w_3\]

Основное назначение экранной глубины мы обсудим несколько позднее, а сейчас сформулируем важную теорему классической геометрии.

Теорема о линейном распределении проекции. Пусть \(a_i\) – барицентрические кординаты точки p треугольника, а \(b_i\) – барицентрические координаты её проекции (относительно проекции треугольника), то

\[\frac{a_1 f_1 + a_2 f_2 + a_3 f_3}{a_1 w_1 + a_2 w_2 + a_3 w_3} = b_1 \frac{f_1}{w_1} + b_2 \frac{f_2}{w_2} + b_3 \frac{f_3}{w_3}\]

Говоря словами, отношение линейно распределённой по треугольнику величины к пространственной глубине точки, в которой величина вычисляется, линейно распределено по проекции треугольника.

При помощи этой теоремы, например, можно выключить у varying-переменной коррекцию перспективы. Иллюстрацию можно посмотреть по ссылке.

Входные константы

Иногда требуется задать из управляющей программы какой-то набор констант для шейдеров. Константы шейдеров, заданные извне, называются в OpenGL словом uniform. Общаться с ними ещё проще, чем с аттрибутами. Функцией getUniformLocation можно получить идентификатор переменной, а функциями семейства uniform можно задавать значения этих констант (функции семейства uniform можно использовать только после вызова функции useProgram). Демонстрация использования констант доступна по ссылке.

Полезные функции GLSL

В процессе написания…

Тест экранной глубины

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

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

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

Самая простая схема вычисления глубины пикселя – использовать интерполированную w-координату. Эта схема называется W-буферизацией. В настоящее время она очень редко используется по следующей причине. Все вычисления производятся в арифметике с плавающей точкой. Напомним, что числа в ней представляются в виде

\[2^e (1+m)\]

где \(e\) – целое число, а \(m\) – сумма целых отрицательных степеней двойки. Например, для стандартного типа float величина \(e\) может меняться в пределах от -126 до 127 (значения -127 и 128 зарезервированы), а \(m\) складывается из любой комбинации степеней двойки от \(2^{-23}\) до \(2^{-1}\).

Особенность чисел с плавающей точкой – неравномерное распределение точно представимых чисел по числовой прямой. На промежутке \([1,2)\) представимо столько же чисел, сколько и, например, на промежутке \([1024,2048)\). То есть с увеличением расстояния до нуля плотность представимых чисел падает экспоненциально. Соответственно, и точность W-буферизации (что абсолютная, что относительно экранных координат) падает экспоненциально с увеличением расстояния до треугольника.

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

\[d_i = \alpha w_i + \beta\]

Для использования Z-буферизации глубину нужно записать в z-координату. Соответственно, в глубинном буфере в итоге оказывается значение экранной глубины \(s_z\) пикселя, определённой в разделе про интерполяцию значений. Напомним, что, согласно теореме о линейном распределении проекции , для пикселя с реальной глубиной w его экранная глубина равна

\[d = \alpha + \frac{\beta}{w}\]

При помощи правильного выбора констант \(\alpha\) и \(\beta\) можно сдвинуть наиболее частовстречающиеся значения глубины в окрестность нуля, где аппроксимация реального значения числом с плавающей точкой происходит наиболее точно. Но не нужно забывать и про то, что пиксели, для которых \(d\lt -1\) или \(d\gt 1\), не рисуются. Довольно популярный выбор: \(\alpha=0\) и \(\beta=-1\) (минус нужен, так как обращение числа антитонно, т.е. инвертирует порядок на числовой прямой). При таком выборе конестант рисуется всё пространство, расположенное за «экраном» \(w=1\).

Важно! Чтобы включить буферизацию, нужно выполнить функцию enable с константой DEPTH_TEST. На C++ это будет выглядеть так:

glEnable(GL_DEPTH_TEST);

На Javascript это будет выглядеть так:

GL.enable(GL.DEPTH_TEST)

Неквадратные изображения

В процессе написания

Смешение цветов

В процессе написания…

Простое попиксельное освещение

В процессе написания…

Пока можно посмотреть демонстрацию по ссылке.

@ 2016 arbrk1, all rights reversed