javascript

Введение в программирование шейдеров для верстальщиков

  • четверг, 23 августа 2018 г. в 00:19:25
https://habr.com/post/420847/
  • Разработка веб-сайтов
  • WebGL
  • JavaScript



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


Все это побудило создать введение в те аспекты работы с шейдерами, которые наиболее вероятно пригодятся в работе именно верстальщику для создания различных 2d-эффектов с картинками на сайте. Конечно с поправкой на то, что сами по себе в дизайне интерфейсов они у нас применяются относительно редко. Мы сделаем стартовый шаблон на чистом JS без сторонних библиотек и рассмотрим идеи создания некоторых популярных эффектов, основанных на сдвиге пикселей, которые сложно сделать на SVG, но при этом они легко реализуются с помощью шейдеров.


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

Сразу стоит отметить, что интегрированные в статью редакторы от CodePen имеют свойство влиять на производительность того, что в них выполняется. Так что прежде, чем писать комментарий, что у вас на макбуке что-то тормозит — убедитесь, что проблема исходит не от них.


Основные идеи


Что такое шейдер?


Что такое фрагментный шейдер? По сути, это — маленькая программа. Она выполняется для каждого пикселя на сanvas. Если у нас есть canvas размером 1000x500px, то эта программа выполнится 500000 раз, каждый раз получая в качестве своих входных параметров координаты пикселя, для которого она выполняется в данный момент. Это все происходит на GPU во множестве параллельных потоков. На центральном процессоре подобные вычисления занимали бы гораздо больше времени.


Вершинный шейдер — это тоже программа, но он выполняется не для каждого пикселя на canvas, а для каждой вершины в фигурах, из которых у нас все строится в трехмерном пространстве. Также параллельно для всех вершин. Соответственно получает на вход координаты вершины, а не пикселя.


Дальше в контексте нашей задачи происходит следующее:


  • Мы берем набор координат вершин прямоугольника, на котором потом будет "нарисована" фотография.
  • Вершинный шейдер для каждой вершины считает ее расположение в пространстве. У нас это будет сводиться к частному случаю — плоскости, параллельной экрану. Фотографии в 3d нам не нужны. Последующая проекция на плоскость экрана можно сказать ничего не делает.
  • Дальше для каждого видимого фрагмента, а в нашем контексте для всех фрагментов-пикселей, выполняется фрагментный шейдер, он берет фотографию и текущие координаты, что-то считает и выдает цвет для этого конкретного пикселя.
  • Если во фрагментном шейдере не было никакой логики, то поведение всего этого будет напоминать метод drawImage() у canvas. Но потом мы добавим эту самую логику и получим много всего интересного.

Это сильно упрощенное описание, но должно быть понятно, кто что делает.


Немного про синтаксис


Шейдеры пишутся на языке GLSL — OpenGL Shading Language. Этот язык очень похож на Си. Описывать здесь весь синтасис и стандартные методы не имеет смысла, но вы всегда можете воспользоваться шпаргалкой:


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





Каждый шейдер имеет функцию main, с которой начинается его выполнение. Стандартные входные параметры для шейдеров и вывод результатов их работы реализуются через специальные переменные с приставкой gl_. Они зарезервированы заранее и доступны внутри этих самых шейдеров. Так координаты вершины для вершинного шейдера лежат в переменной gl_Position, координаты фрагмента (пикселя) для фрагментного шейдера лежат в gl_FragCoord и.т.д. Полный список доступных специальных переменных вы всегда найдете в той же шпаргалке.


Основные типы переменных в GLSL достаточно незатейливы — void, bool, int, float… Если вы работали с каким-нибудь Си-подобным языком, вы их уже видели. Есть и другие типы, в частности векторы разных размерностей — vec2, vec3, vec4. Мы будем постоянно использовать их для координат и цветов. Сами переменные, которые мы можем создавать, бывают трех важных модификаций:


  • Uniform — Глобальные во всех смыслах данные. Передаются извне, одинаковы для всех вызовов вершинных и фрагментных шейдеров.
  • Attribute — Эти данные передаются уже более точечно и для каждого вызова шейдера могут быть разными.
  • Varying — Нужны для передачи данных от вершинных шейдеров во фрагментные.

Полезно делать префиксы u/a/v ко всем переменным в шейдерах, чтобы упростить понимание того, какие данные откуда взялись.

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


Готовим стартовый шаблон


Начнем с JS. Как это обычно и бывает при работе с canvas, нам нужен он сам и контекст. Чтобы не загружать код примеров, сделаем глобальные переменные:


const CANVAS = document.getElementById(IDs.canvas);
const GL = canvas.getContext('webgl');

Пропустим момент, связанный с размером canvas и его перерасчетом при изменении размера окна браузера. Этот код включен в примеры и обычно зависит от остальной верстки. Нет смысла акцентировать на нем внимание. Перейдем сразу к действиям с WebGL.


function createProgram() {
    const shaders = getShaders();

    PROGRAM = GL.createProgram();

    GL.attachShader(PROGRAM, shaders.vertex);
    GL.attachShader(PROGRAM, shaders.fragment);
    GL.linkProgram(PROGRAM);

    GL.useProgram(PROGRAM);
}

Сначала мы компилируем шейдеры (будет немного ниже), создаем программу, добавляем в нее оба наших шейдера и производим линковку. На этом этапе проверяется совместимость шейдеров. Помните про varying-переменные, которые передаются от вершинного во фрагментный? — Вот в частности их наборы здесь проверяются, чтобы потом в процессе не оказалось, что что-то не передали или передали, но совсем не то. Конечно, логические ошибки данная проверка не выявит, думаю это понятно.


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


const vertexPositionAttribute = GL.getAttribLocation(PROGRAM, 'a_position');

Далее мы указываем, что через эту переменную будет передаваться массив с координатами (в самом шейдере мы его будем воспринимать уже как вектор). WebGL самостоятельно разберется с тем, какие именно координаты каких точек в наших фигурах передавать в какой вызов шейдера. Мы только задаем параметры для массива-вектора, который будет передаваться: размерность — 2 (будем передавать координаты (x,y)), он состоит из чисел и не нормализован. Последние параметры нам не интересны, оставляем нули по умолчанию.


GL.enableVertexAttribArray(vertexPositionAttribute);
GL.vertexAttribPointer(vertexPositionAttribute, 2, GL.FLOAT, false, 0, 0);

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


function createPlane() {
    GL.bindBuffer(GL.ARRAY_BUFFER, GL.createBuffer());
    GL.bufferData(
        GL.ARRAY_BUFFER,
        new Float32Array([
            -1, -1,
            -1,  1,
             1, -1,
             1,  1
        ]),
        GL.STATIC_DRAW
    );
}

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


Перед тем, как перейти к самим шейдерам, посмотрим на их компиляцию:


function getShaders() {
    return {
        vertex: compileShader(
            GL.VERTEX_SHADER,
            document.getElementById(IDs.shaders.vertex).textContent
        ),
        fragment: compileShader(
            GL.FRAGMENT_SHADER,
            document.getElementById(IDs.shaders.fragment).textContent
        )
    };
}

function compileShader(type, source) {
    const shader = GL.createShader(type);

    GL.shaderSource(shader, source);
    GL.compileShader(shader);

    return shader;
}

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


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


console.log(GL.getShaderInfoLog(shader));

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

Переходим к самим шейдерам


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


Старайтесь писать код шейдеров с более-менее осмысленными названиями переменных. В сети вы встретите примеры, где из однобуквенных переменных будут собираться функции с ядреной математикой на 200 строк сплошного текста, но то, что кто-то так делает, еще не значит, что это стоит повторять. Подобный подход — это не "специфика работы с GL", это — банальная копипаста исходников еще из прошлого века, написанных людьми, которые в молодости имели ограничения на длину имен переменных.

Для начала вершинный шейдер. В переменную-атрибут a_position будет передаваться 2d-вектор с координатами (x,y), как мы и говорили. Шейдер должен вернуть вектор из четырех значений (x,y,z,w). Перемещать в пространстве он ничего не будет, так что по оси z просто все обнуляем и ставим значение w в стандартную единицу. Если вам интересно, почему координат четыре, а не три, то вы можете воспользоваться поиском в сети по запросу "однородные координаты".


<script id='vertex-shader' type='x-shader/x-vertex'>
    precision mediump float;

    attribute vec2 a_position;

    void main() {
        gl_Position = vec4(position, 0, 1);
    }
</script>

Результат работы записывается в специальную переменную gl_Position. У шейдеров нет оператора return в полном смысле этого слова, все результаты своей работы они записывают в специально зарезервированные для этих целей переменные.


Обратите внимание на задание точности для типа данных float. Чтобы избежать некоторой части проблем на мобильных устройствах, точность должна быть хуже, чем highp и должна быть одинаковой в обоих шейдерах. Здесь это показано для примера, но хорошей практикой будет на телефонах подобную красоту с шейдерами совсем отключать.

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


<script id='fragment-shader' type='x-shader/x-fragment'>
    precision mediump float;

    #define GOLD vec4(1.0, 0.86, 0.6, 1.0)

    void main() {
        gl_FragColor = GOLD;
    } 
</script>

Вы можете обратить внимание на числа, описывающие цвет. Это знакомый всем верстальщикам RGBA, только нормализованный. Значения не целые от 0 до 255, а дробные от 0 до 1. Порядок тот же.


Не забывайте использовать препроцессор для всех магических констант в реальных проектах — это делает код более понятным не оказывая влияния на производительность (подстановка, также как и в Си, происходит при компиляции).

Стоит отметить еще один момент о препроцессоре:


Использование постоянных проверок #ifdef GL_ES в различных уроках лишено практического смысла, т.к. у нас в браузере на сегодняшний день никаких других вариантов GL просто не существует.

Но пора бы уже посмотреть на результат:


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


Градиент и преобразования векторов


Обычно в уроках по WebGL начинают с рисования градиентов. Это имеет мало практического смысла, но будет полезно отметить пару моментов.


void main() {
    gl_FragColor = vec4(gl_FragCoord.zxy / 500.0, 1.0);
}

В этом примере мы используем координаты текущего пикселя в качестве цвета. Вы часто будете такое встречать в примерах в сети. И то и другое — векторы. Так что никто не мешает все смешать в кучу. У евангелистов TypeScript здесь должен случиться приступ. Важным моментом является то, как мы достаем из вектора только часть координат. Свойства .x, .y, .z, .xy, .zy, .xyz, .zyx, .xyzw и.т.д. в разных последовательностях позволяют вытащить элементы вектора в определенном порядке в виде другого вектора. Очень удобно реализовано. Также вектор большей размерности можно сделать из вектора меньшей размерности, добавив недостающие значения, как мы и поступили.


Всегда явно указывайте дробную часть чисел. Автоматического преобразования int -> float здесь нет.

Uniforms и ход времени


Следующий полезный пример — использование uniforms. Это те самые общие для всех вызовов шейдеров данные. Мы получаем их расположение почти тем же образом, что и для переменных-атрибутов, например:


GL.getUniformLocation(PROGRAM, 'u_time')

Потом мы можем устанавливать им значения перед каждым кадром. Также, как и с векторами, здесь есть много похожих методов, начинающихся со слова uniform, далее идет размерность переменной (1 для чисел, 2, 3 или 4 для векторов) и тип (f — float, i — int, v — вектор).


function draw(timeStamp) {
    GL.uniform1f(GL.getUniformLocation(PROGRAM, 'u_time'), timeStamp / 1000.0);

    GL.drawArrays(GL.TRIANGLE_STRIP, 0, 4);

    window.requestAnimationFrame(draw);
}

На самом деле нам не всегда нужны 60fps в интерфейсах. Вполне можно добавить тормозилку к requestAnimationFrame и снизить частоту перерисовки кадров.

Для примера будем изменять цвет заливки. В шейдерах доступны все основные математические функции — sin, cos, tan, asin, acos, atan, pow, exp, log, sqrt, abs и другие. Воспользуемся двумя из них.


uniform float u_time;

void main() {
    gl_FragColor = vec4(
            abs(sin(u_time)),
            abs(sin(u_time * 3.0)),
            abs(sin(u_time * 5.0)), 1.0);
} 

Время в таких анимациях — понятие относительное. Здесь мы используем те значения, которые предоставляет нам requestAnimationFrame, но можем сделать свое "время". Идея в том, что если какие-то параметры описываются функцией от времени, то мы можем повернуть время в обратную сторону, замедлить, ускорить его или вернуться в исходное состояние. Это бывает очень полезно.


Но хватит абстрактных примеров, перейдем к использованию картинок.


Загружаем картинку в текстуру


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


function createTexture() {
    const image = new Image();

    image.crossOrigin = 'anonymous';

    image.onload = () => {
        // ....
    };

    image.src = 'example.jpg';
}

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


const texture = GL.createTexture();

GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D, texture);

Остается добавить картинку. Также мы сразу говорим, что ее нужно перевернуть по оси Y, т.к. в WebGL ось перевернута:


GL.pixelStorei(GL.UNPACK_FLIP_Y_WEBGL, true);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGB, GL.RGB, GL.UNSIGNED_BYTE, image);

По идее текстуры должны быть квадратными. Точнее даже должны иметь размер, равный степени двойки — 32px, 64px, 128px и.т.д. Но все мы понимаем, что фотографии никто обрабатывать не станет и они будут каждый раз разных пропорций. Это будет вызывать ошибки даже если canvas по размеру идеально подходит к текстуре. Поэтому мы заполняем все пространство до краев плоскости крайними пикселями изображения. Это стандартная практика, хотя и кажется немного костыльной.


GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_S, GL.CLAMP_TO_EDGE);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_T, GL.CLAMP_TO_EDGE);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.LINEAR);

Остается передать текстуру в шейдеры. Это общие для всех данные, так что используем модификатор uniform.


GL.uniform1i(GL.getUniformLocation(PROGRAM, 'u_texture'), 0);

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


GL.uniform1f(GL.getUniformLocation(PROGRAM, 'u_canvas_size'),
    Math.max(CANVAS.height, CANVAS.width));

И делим на него координаты:


uniform sampler2D u_texture;
uniform float u_canvas_size;

void main() {
    gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size);
} 

На этом моменте можно сделать небольшую паузу и заварить чаю. Мы проделали всю подготовительную работу и переходим к созданию различных эффектов.


Эффекты


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


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

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


gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size
    + sin(u_time + gl_FragCoord.y));

Результат странный. Очевидно, что все движется со слишком большой амплитудой. Поделим все на какое-нибудь число:


gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size
    + sin(u_time + gl_FragCoord.y) / 250.0);

Уже лучше. Теперь понятно, что получилось небольшое волнение. По идее, чтобы увеличить каждую волну нам нужно поделить аргумент синуса — координату. Сделаем это:


gl_FragColor = texture2D(u_texture, gl_FragCoord.xy / u_canvas_size
    + sin(u_time + gl_FragCoord.y / 30.0) / 250.0);

Подобные эффекты часто сопровождаются подбиранием коэффициентов. Это делается на глазок. Как и с кулинарией, поначалу будет сложно угадывать, но потом это будет происходить само собой. Главное — хотя бы примерно понимать, на что влияет тот или иной коэффициент в получившейся формуле. После того, как коэффициенты подобраны, имеет смысл выносить их в макросы (как это было в первом примере) и давать осмысленные имена.


Кривое зеркало, велосипеды и эксперименты


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


Возможно мы не хотим делать такие равномерные горизонтальные волны, а хотим "кривое зеркало", в котором все будет искажаться случайным образом. Что же делать?


Кажется, что нам будет нужна генерация случайных чисел, не так ли? Раз уж все должно быть случайным. Но вот незадача, у нас здесь нет стандартной функции rand() или чего-то похожего. Все дело в том, что она реализуется так, что следующий результат, следующее число, зависит от предыдущего. Получается последовательность случайных чисел. Но шейдеры выполняются параллельно и мы не можем передавать последовательность от одного к другому без вреда для производительности. Но если подумать, то нам это и не нужно. Нам нужно генерировать случайное число в зависимости от координат. То есть нам нужна урезанная реализация. Это даже скорее хеш-функция, чем генератор случайных чисел. Алгоритмов хеширования придумали уже достаточно много. Нам желательно иметь такой, чтобы принимал на вход координаты, чтобы самим не делать обертку над ним, и выдавал число. Есть готовый вариант этой функции, который уже очень давно кочует по интернету и многими называется "каноническим":


float rand(vec2 seed) {
    return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123);
}

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


Если мы просто так добавим случайный компонент, то получится нечто странное, поэтому сразу поделим на коэффициент для уменьшения амплитуды нашего рандома:


gl_FragColor = texture2D(u_texture,
    gl_FragCoord.xy / u_canvas_size
    + rand(gl_FragCoord.xy) / 100.0);

И вернуть сюда функцию от времени тоже будет не лишним:


gl_FragColor = texture2D(u_texture,
    gl_FragCoord.xy / u_canvas_size
    + rand(gl_FragCoord.xy + vec2(sin(u_time))) / 250.0);

Получилось не совсем то, что нам нужно, но тоже занятно:


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


Поскольку у нас используются координаты от 0 до 1, нужно умножить их на какой-нибудь коэффициент. В данном случае 5 — это и будет количеством квадратов в одной линии в анимации. Также вынесем в отдельную переменную наши координаты, чтобы они не рассчитывались несколько раз.


vec2 texture_coord = gl_FragCoord.xy / u_canvas_size;

gl_FragColor = texture2D(u_texture,
    texture_coord
    + rand(floor(texture_coord * 5.0) + vec2(sin(u_time))) / 100.0);

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


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


Добавим также отдельно sin и cos для разных координат, чтобы немного рассинхронизировать анимацию. Такое часто делают. Будет полезно поиграть и с другими функциями и коэффициентами.


gl_FragColor = texture2D(u_texture,
    texture_coord
    + vec2(
        noise(texture_coord * 10.0 + sin(u_time + texture_coord.x * 5.0)) / 10.0,
        noise(texture_coord * 10.0 + cos(u_time + texture_coord.y * 5.0)) / 10.0));

Для начала пусть все будет линейным. Используем функцию fract как есть. Блоки будут размера 1 на 1 — мы округляем до целых чисел:


float noise(vec2 position) {
    vec2 block_position = floor(position);

    float top_left_value     = rand(block_position);
    float top_right_value    = rand(block_position + vec2(1.0, 0.0));
    float bottom_left_value  = rand(block_position + vec2(0.0, 1.0));
    float bottom_right_value = rand(block_position + vec2(1.0, 1.0));

    vec2 computed_value = fract(position);

    // ...
}

Мы можем попробовать использовать другую функцию для интерполяции. WebGL в частности предоставляет нам функцию smoothstep, она даст более плавные движения:


vec2 computed_value = smoothstep(0.0, 1.0, fract(position));

Возникает вопрос о том, какое значение вернуть из этой функции. Алгоритм интерполяции известен, но давайте в качестве эксперимента попробуем вернуть координату по X у полученного значения:


return computed_value.x;

Ооо… Ни разу не то, что нужно, но зато как красиво...


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

Если возвращать значение по y — будет то же самое, но по горизонтали. А что если вернуть длину вектора?


return length(computed_value);

Тоже очень даже интересная штука.


Но вернемся к известному алгоритму. Для удобства сразу вычитаем 0.5 — пусть будут и отрицательные значения тоже.


return mix(top_left_value, top_right_value, computed_value.x)
    + (bottom_left_value  - top_left_value)  * computed_value.y * (1.0 - computed_value.x)
    + (bottom_right_value - top_right_value) * computed_value.x * computed_value.y
    - 0.5;

На этот результат точно стоит посмотреть:


Тут можно сделать еще одну паузу, поиграть с коэффициентами, посмотреть, что будет.


Отменяем эффект


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


Проще всего добавить uniform-переменную, которая будет отвечать за силу применения трансформаций. Скажем от 0 до 1, где 0 — ничего не происходит, а 1 — трансформируется все.


uniform float u_intensity;

Мы можем умножать на нее вектор трансформации:


gl_FragColor = texture2D(u_texture,
    texture_coord
    + vec2(noise(texture_coord * 10.0 + sin(u_time + texture_coord.x * 5.0)) / 10.0,
           noise(texture_coord * 10.0 + cos(u_time + texture_coord.y * 5.0)) / 10.0)
        * u_intensity);

Для примера будем плавно менять интенсивность при наведении мыши, отключая тем самым эффект с искажениями.


Если совместить этот процесс с изменением прозрачности цвета (также от 0 до 1), то можно делать красивые растворения изображений при переходе к новому слайду в галерее.


После того, как эффект полностью отменен, имеет смысл прекращать перерисовку каждого кадра до тех пор, пока не будет нужно активировать эффект снова. Если у вас на странице одновременно перерисовываются несколько элементов — старайтесь все их собрать в один цикл с requestAnimationFrame. Чем больше этих циклов будет на странице, тем сильнее будет проседать FPS.

Увеличительное стекло и мышка


И раз уж заговорили про мышку, будет не лишним передать в шейдер ее положение. Также через uniform-переменную.


document.addEventListener('mousemove', (e) => {
    let rect = CANVAS.getBoundingClientRect();

    MOUSE_POSITION = [
        e.clientX - rect.left,
        rect.height - (e.clientY - rect.top)
    ];

    GL.uniform2fv(GL.getUniformLocation(PROGRAM, 'u_mouse_position'), MOUSE_POSITION);
});

И попробуем использовать сдвиг, основываясь на направлении и длине вектора из текущего положения в положение мыши. Если расстояние не очень большое — применяем трансформацию, а все остальное пусть остается как есть.


void main() {
    vec2 texture_coord = gl_FragCoord.xy / u_canvas_size;
    vec2 direction = u_mouse_position / u_canvas_size - texture_coord;
    float dist = distance(gl_FragCoord.xy, u_mouse_position) / u_canvas_size;

    if (dist < 0.4) {
        gl_FragColor = texture2D(u_texture,
            texture_coord
            + u_intensity * direction * dist * 1.2
        );
    } else {
        gl_FragColor = texture2D(u_texture, texture_coord);
    }
} 

Читателю будет полезно поиграть с коэффициентами и использовать какую-нибудь нелинейную функцию от расстояния. Таким образом можно будет получить вогнутую или выпуклую линзу.


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

Помехи


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


float random_value = rand(vec2(texture_coord.y, u_time));

if (random_value < 0.05) {
    gl_FragColor = texture2D(u_texture,
        vec2(texture_coord.x + random_value / 5.0,
             texture_coord.y));
} else {
    gl_FragColor = texture2D(u_texture, texture_coord);
}

"Что из себя представляет данный эффект?" — Это важный вопрос, который начинающие часто забывают себе задать. И это приводит к ступору даже с такими простыми задачами.

Можно также увеличить ширину полос. Действуем по уже знакомому сценарию — умножаем координаты, округляем и производим одни и те же вычисления для множества точек.


float random_value = rand(vec2(floor(texture_coord.y * 20.0), u_time));

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


gl_FragColor = texture2D(u_texture,
    vec2(texture_coord.x + random_value / 4.0,
         texture_coord.y))
    + vec4(vec3(random_value), 1.0);

На фоне предыдущих примеров этот уже не должен казаться сложным. Игры с отдельными компонентами цвета оставим в качестве упражнения — здесь можно долго экспериментировать. Удобно, что из вектора отдельные цветовые каналы можно доставать аналогично координатам — есть свойства .r, .g, .b, .rg, .rb, .rgb, .bgr, и.т.д. Использование разных обозначений для цветов и для координат помогает не смешивать все в одну кучу.


Возвращаем параметр интенсивности и смотрим на результат:


float random_value = u_intensity * rand(vec2(floor(texture_coord.y * 20.0), u_time));

Что в итоге?


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