Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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() ;
} 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), как мы и говорили. Для начала вершинный шейдер. Перемещать в пространстве он ничего не будет, так что по оси z просто все обнуляем и ставим значение w в стандартную единицу. Шейдер должен вернуть вектор из четырех значений (x,y,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>

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

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

Наш квадрат будет занимать весь 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.

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

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

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

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

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

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

Это имеет мало практического смысла, но будет полезно отметить пару моментов. Обычно в уроках по 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);
}

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

В шейдерах доступны все основные математические функции — 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';
}

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

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);

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

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

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

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;

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

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

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

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

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

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));

Что в итоге?

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть