D:\sideБлогGameMaker Studio: шейдеры, varying

Этот пост больше теоретический. И расскажет о проблеме, с которой я внезапно столкнулся при реализации размытия. Суть проблемы проста - каждый пиксель размытой картинки зависит от всех окружающих, и потому шейдер в каждой точке должен обращаться к целому участку текстуры сразу. Это дело «дорогостоящее».

Те, кто следит за группой в VK, могли видеть PDF›ку с рекомендациями по производительности на видеочипах PowerVR SGX. У меня в смартфоне такой. Там написано, почему шейдеры, основанные на отдельной точке текстуры, быстрее тех, которые изменяют поданные им текстурные координаты. Эту особенность мы будем обходить одним очень элегантным костылём, который заодно покажет суть работы наших видеокарт между двумя нашими шейдерами. Пост скучноватый, но важный. Куда уж без этого.

С доступом к целому квадрату из точек надо бороться.

Приём первый - разбить размытие на две стадии таким образом, чтобы разделить горизонтальное и вертикальное размытие. Потому что в случае с размытием процедура «размыть точку на основе квадрата NxN» и «размыть N точек на основе линий, в которых они лежат» занимает одно и то же количество ресурсов. А если нет разницы, зачем платить больше? Но есть и подводный камень. Размытие займёт не один шейдер, а сразу два, по горизонтали и вертикали. То есть, размытие сначала придётся писать первым шейдером на сюрфейс, затем вторым шейдером на экран.

Выигрышность такого подхода неочевидна. Приведу цифры. За один проход «наивному методу» размытия по блокам 5х5 точек понадобится в каждой дёргать из текстуры 25 различных текселей (это как пиксели, но в текстуре). За два прохода - в каждом по 5, итого 10. Причём поскольку в каждом шейдере их очень мало, сейчас я скажу, как ускорить работу с ними.

Приём второй - воспользоваться особенностью многих видеокарт (не только от PowerVR) предсказывать запрашиваемые точки текстур на основе переменных varying (которые можно заполнить в вершинном шейдере), и загружать их в шейдер заранее, устраняя необходимость тащить их из текстуры. Это забота оборудования. Наша задача - дать ему нужные координаты в varying. Но сначала нужно разобраться, что вообще означает varying.

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

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

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

Но нас интересует другое. Если мы возьмём в каждой вершине, составляющей наш спрайт, переменные с координатами окружающих точек - они тоже усреднятся, и каждый фрагмент получит окружающие его точки. Что это означает? Что видеокарта сможет по ним предсказать, какие точки понадобятся каждому фрагменту, и подгрузит их заранее! И расходы ресурсов видеокарты будут минимальными. Другой подводный камень - количество таких переменных сильно ограничено, поэтому количество участвующих точек невелико. Я взял всего 5 - одна основная и 4 размывающих. Строго говоря, количество размывающих можно удвоить засчёт использования vec4, и упаковывания текстурных координат парами. Возможно, позже я это и сделаю. А пока пусть будет более понятный код с более слабым размытием.

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

//Вершинный: горизонтальный блюр
attribute vec3 in_Position;                  // (x,y,z)
attribute vec4 in_Colour;                    // (r,g,b,a)
attribute vec2 in_TextureCoord;              // (u,v)
//attribute vec3 in_Normal;                  // (x,y,z)     unused in this shader.

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec2 v_blurTexCoords[4];

void main()
{
    vec4 object_space_pos = vec4( in_Position.x, in_Position.y, in_Position.z, 1.0);
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
    
    v_vColour = in_Colour;
    v_vTexcoord = in_TextureCoord;
    
    v_blurTexCoords[ 0] = v_vTexcoord + vec2(-0.008, 0.0);
    v_blurTexCoords[ 1] = v_vTexcoord + vec2(-0.004, 0.0);
    v_blurTexCoords[ 2] = v_vTexcoord + vec2( 0.004, 0.0);
    v_blurTexCoords[ 3] = v_vTexcoord + vec2( 0.008, 0.0);
}
Тоже вершинный, но вертикальный
attribute vec3 in_Position;                  // (x,y,z)
attribute vec4 in_Colour;                    // (r,g,b,a)
attribute vec2 in_TextureCoord;              // (u,v)
//attribute vec3 in_Normal;                  // (x,y,z)     unused in this shader.

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec2 v_blurTexCoords[4];

void main()
{
    vec4 object_space_pos = vec4( in_Position.x, in_Position.y, in_Position.z, 1.0);
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
    
    v_vColour = in_Colour;
    v_vTexcoord = in_TextureCoord;
    
    v_blurTexCoords[ 0] = v_vTexcoord + vec2(0.0, -0.008);
    v_blurTexCoords[ 1] = v_vTexcoord + vec2(0.0, -0.004);
    v_blurTexCoords[ 2] = v_vTexcoord + vec2(0.0, 0.004);
    v_blurTexCoords[ 3] = v_vTexcoord + vec2(0.0, 0.008);
}
// Фрагментный, он одинаков
// В исходнике я комментарии поменять забыл.
// Но они в чём-то верны и там.
varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec2 v_blurTexCoords[4];

void main()
{
    gl_FragColor = vec4(0.0);
    gl_FragColor += texture2D(gm_BaseTexture, v_blurTexCoords[ 0])*0.0097577;
    gl_FragColor += texture2D(gm_BaseTexture, v_blurTexCoords[ 1])*0.2058489;
    gl_FragColor += texture2D(gm_BaseTexture, v_vTexcoord        )*0.5687868;
    gl_FragColor += texture2D(gm_BaseTexture, v_blurTexCoords[ 2])*0.2058489;
    gl_FragColor += texture2D(gm_BaseTexture, v_blurTexCoords[ 3])*0.0097577;
}