D:\sideБлогGameMaker Studio: шейдеры, операции

⚠️ Обращайте внимание на даты.
Этот блог больше не ведётся с 17 января 2023, и на тот момент с написания этой страницы (13.07.2013) прошло 9 лет.

На сегодня удвоенная доза GLSL! Разберём сразу два шейдера. Новые приёмы, которые мы сегодня рассмотрим, будут достаточно типичными даже для программирования не на видеокарточках, поэтому с их пониманием проблем возникнуть не должно.

На сегодня единственное правило, которое вам надо запомнить: если операция обычно действует с отдельными числами, то на вектор она действует так, как если бы её применили к каждой его компоненте по порядку. А если векторов несколько, то ещё и к одним и тем же компонентам.

Элементарный пример, с которым мы уже сталкивались ранее - умножение. Пусть у нас есть жёлтый цвет, и он смешивается с тёмно-серым (цвета мы мешаем при помощи умножения):

\((1, 1, 0, 1) \cdot (0.5, 0.5, 0.5, 1) = (0.5, 0.5, 0, 1)\)

Первое умножается на первое, второе на второе, и так далее.

Поехали.

Шейдер, который мы сейчас рассмотрим, реализован отвратительно. Он не плох с точки зрения кода, но эффект, который он обеспечивает, сложно реализовать хуже. Идея проста - сделать рисуемое более “запикселенным”, чтобы каждая точка нарисованного была крупной, и представляла много точек исходного изображения. Проблема в этом эффекте всего одна - выбор цвета, которым будет закрашена каждая точка. И в зависимости от того, как будет выбран этот цвет, эффект будет очень разным.

Наш шейдер с этим не парится - он берёт цвет из левого верхнего угла каждой крупной точки. Но припоминаем - каждая точка обрабатывается шейдером отдельно, без взаимодействия с другими. Как она узнаёт, где границы точки? Ответ прост - текстурные координаты. Взгляните на сам код:

//Размер крупной точки мы подаём снаружи
uniform vec3 f_SpritePixelSize;
//То есть да, вектор, точка может быть прямоугольной, не квадратом
//Алгоритм выстроен так, что в третьей координате указывается
//масштаб по обеим осям, а в первых двух - по отдельным.
//Эффект от подачи туда (0.5, 0.5, 1) и (1, 1, 0.5) один и тот же
//Это не я писал, мне это кажется лишним расходом ресурсов.

varying vec2 v_vTexcoord;
varying vec4 v_vColour;

void main()
{
	//Смотрим внимательно:
	//Берутся исходные текстурные координаты (vec2)
	//Делятся по очереди на размер точки по отдельным осям (f_SpritePixelSize.xy)
	//Делятся оба на одно число, "общее увеличение" (f_SpritePixelSize.z)
	vec2 uv = (v_vTexcoord/f_SpritePixelSize.xy) / f_SpritePixelSize.z;
	//Магия выбора цвета - полученные координаты округляются вниз
	//То есть, достигается выбор цвета только из тех точек, для которых
	//координаты в текстуре нацело делятся на размеры клеток
	uv = floor(uv);
	//Обратная операция - полученные координаты множатся на масштаб
	//по осям, затем на общий масштаб.
	vec2 uv2 = (uv*f_SpritePixelSize.xy)*f_SpritePixelSize.z;

	//Почти всё то же, что в пропускающем шейдере!
	//Но мы изменили то, из какой точки текстуры берётся цвет.
	gl_FragColor = (v_vColour * texture2D( gm_BaseTexture, uv2 ));
}

Этот шейдер очень быстр, и только поэтому хоть сколько-нибудь полезен. Качество можно повысить так: брать из текстуры несколько точек неподалёку и брать средний цвет. Но имейте в виду, что каждый вызов texture2D - удовольствие достаточно дорогое.

Последний шейдер предельно прост. Он берёт один цвет, и заменяет другим. И сделано это вполне очевидным способом, через if:

//1 - что заменяем, 2 - чем заменяем
uniform vec3 f_Colour1;
uniform vec3 f_Colour2;

varying vec2 v_vTexcoord;
varying vec4 v_vColour;

void main()
{
    //Берём цвет, который должен быть (vec4)
    vec4 col = texture2D( gm_BaseTexture, v_vTexcoord );
    //Если он равен первому (не считая прозрачности, 4-ой компоненты)
    if( col.rgb == f_Colour1.rgb )
    {
        //Перезаписать новыми значениями, также не считая прозрачности
        col.rgb = f_Colour2.rgb;
    }
    //То же, что в пропускающем шейдере, но с пересчитанным цветом текстуры
    gl_FragColor = v_vColour * col;
}

Последнее замечание - у видеокарт большие проблемы со скачками в другие места выполняемого кода. А на низком уровне, после компиляции многие конструкции выражаются через скачки. На центральном процессоре, по крайней мере. if - это скачок за следующее дейстие, если условие под ним ложно.  Цикл - это как if, только в конце скачок к началу if. Вот любые такие конструкции для видеокарт - высокоэффективные генераторы мощных тормозов. Компиляторы при первой возможности пытаются развернуть такие циклы, и если это не получается - выдают предупреждение или даже ошибку.

nVidia уже работает над исправлением этого неприятного эффекта, и какие-то подвижки в эту сторону делались в их архитектуре Fermi (если я верно помню). Но пока она не получила широкого распространения (пост уже несколько неактуален, примечание от 13.02.2014, Tegra K1 построена аж на Kepler), нам придётся несколько ограничить использование циклов.

Люди не сидят на месте и придумывают разные весёлые костыли, чтобы покрыть обработку больших объёмов данных. К примеру, я видел следующий приём - чтобы усреднить цвет с картинки (512х512) в две строчки (2х512), делается несколько прогонов шейдера, который уменьшает количество строк вдвое, при этом каждый пиксель берёт цвет, средний между своим текселем и стоящим под ним.

По количеству строк: 512 -> 256 -> 128 -> 64 -> 32 -> 16 -> 8 -> 4 -> 2

Но это даёт уж очень много (8) прогонов шейдеров. Впрочем, каждый из них довольно лёгкий. В каких-то случаях это работает, в GM я это не проверял.

Я всю эту войну с шейдерами в GM веду с одной целью - сделать быструю систему освещения. Я прочитал занятную реализацию на HLSL (язык шейдеров в DirectX), но она использует штуки, недоступные в нашем GLSL ES. Но мне кажется, я знаю, как этот момент преодолеть. Несколько потеряв в производительности, но получив взамен потрясный эффект.