Разработчик сделал красивую анимацию на странице и заметил, что она тормозит на ноутбуках. Десяток элементов, плавно меняющих прозрачность, съедает половину кадров. Между тем подобную задачу видеокарта решает незаметно, и доступ к ней из браузера открыт уже полтора десятилетия. Разбираемся, что такое шейдеры, зачем они нужны и когда их применение оправдано.
Язык для видеокарты появился в 2002 году и вырос из языка общего назначения
История началась со сложности. Раньше эффекты для графики писали на ассемблере конкретной видеокарты, и перенос кода с одной модели на другую превращался в переписывание заново.
Двадцать четвёртого июля 2002 года появился язык описания шейдеров, синтаксис которого опирался на распространённый язык общего назначения. Он давал разработчику прямой контроль над графическим конвейером без привязки к ассемблеру.
Через два года возможность вошла в основную версию графической библиотеки. А в 2011 году появилась веб-версия, доступная из браузера без установки дополнений.
Ключевое достоинство подхода состоит в переносимости. Один и тот же код работает на разном оборудовании, потому что каждая видеокарта содержит собственный компилятор, поставляемый вместе с драйвером. Производитель оптимизирует его под свою архитектуру, а разработчик пишет один раз.
Девятнадцатого января 2017 года вышла вторая версия веб-стандарта, основанная на более современной версии графической библиотеки. А с 2024 года разрабатывается новый интерфейс, который должен постепенно заменить текущий.
Шейдер выполняется на видеокарте и работает параллельно для тысяч элементов
Главное отличие шейдера от обычного кода состоит в модели выполнения. Программа на языке сценариев выполняется последовательно, одна операция за другой. Шейдер выполняется параллельно для множества элементов одновременно.
Вершинный шейдер вызывается один раз на каждую вершину. Его задача преобразовать координаты вершины из пространства модели в координаты экрана. Внутри шейдера заполняется встроенная переменная, хранящая положение точки.
Фрагментный шейдер вызывается один раз на каждый пиксель. Его задача определить цвет пикселя. Внутри заполняется другая встроенная переменная, хранящая четыре составляющие цвета: красную, зелёную, синюю и прозрачность.
Посчитаем масштаб. Экран размером тысяча девятьсот двадцать на тысячу восемьдесят содержит чуть больше двух миллионов пикселей. Фрагментный шейдер выполняется для каждого из них, и на частоте шестьдесят кадров в секунду это более ста двадцати миллионов запусков ежесекундно. Обычный процессор на такое не способен, а видеокарта делает это буднично.
Отсюда вытекает ограничение, о котором забывают. Шейдер не может вывести результат в консоль, не может записать данные на диск, не может обратиться к сети. Он умеет только вычислять и возвращать значения. Вся связь с внешним миром идёт через данные, которые приложение передало заранее.
Первый шейдер пишется в несколько строк и сразу виден на экране
Проще всего начать с рисования простого цвета и постепенно усложнять.
// вершинный шейдер
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
// фрагментный шейдер
precision mediump float;
void main() {
gl_FragColor = vec4(0.2, 0.6, 0.9, 1.0);
}
Первая строка второго шейдера задаёт точность вычислений с плавающей точкой. Без неё часть устройств откажется компилировать программу или выдаст ошибку.
Дальше шейдеры передаются в графический контекст как обычные строки:
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error("Ошибка компиляции: " + log);
}
return shader;
}
function createProgram(gl, vertexSource, fragmentSource) {
const program = gl.createProgram();
gl.attachShader(program, createShader(gl, gl.VERTEX_SHADER, vertexSource));
gl.attachShader(program, createShader(gl, gl.FRAGMENT_SHADER, fragmentSource));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error("Ошибка сборки: " + gl.getProgramInfoLog(program));
}
return program;
}
Проверка статуса компиляции обязательна. Без неё ошибка в коде шейдера проявится пустым экраном, и поиск причины займёт часы. Сообщение компилятора указывает номер строки и суть проблемы.
Данные передаются из приложения в шейдер через переменные
Шейдер сам по себе статичен. Чтобы он реагировал на время, движение мыши или размер экрана, в него передают значения.
Первый способ это атрибуты. Они описывают отдельные вершины: координаты, нормали, координаты текстуры. Значения меняются от вершины к вершине.
Второй способ это униформы, то есть общие переменные. Их значение одинаково для всех вершин и всех пикселей в одном вызове отрисовки. Именно так передают время, разрешение экрана и параметры эффекта.
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
float wave = sin(uv.x * 10.0 + uTime * 2.0) * 0.5 + 0.5;
gl_FragColor = vec4(uv.x, wave, uv.y, 1.0);
}
Здесь видно, как из координат пикселя получается относительная координата от нуля до единицы. Такая нормировка делает эффект независимым от размера окна.
Передача значений из приложения выглядит так:
const timeLocation = gl.getUniformLocation(program, "uTime");
const resolutionLocation = gl.getUniformLocation(program, "uResolution");
function render(now) {
gl.uniform1f(timeLocation, now * 0.001);
gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
Третий способ это текстурные сэмплеры. В шейдер передаётся изображение, и он читает из него значения по координатам. Так работают эффекты искажения, размытия и цветокоррекции.
Практические эффекты строятся на простых математических приёмах
Разберём несколько приёмов, из которых складывается большинство визуальных эффектов.
Первый приём это интерполяция. Функция смешивает два значения по заданному весу. Если вес равен нулю, результат равен первому значению, если единице, второму.
precision mediump float;
uniform float uProgress;
uniform vec3 uColorA;
uniform vec3 uColorB;
void main() {
vec3 color = mix(uColorA, uColorB, uProgress);
gl_FragColor = vec4(color, 1.0);
}
Второй приём это плавный переход. Функция возвращает ноль ниже нижней границы, единицу выше верхней и плавно меняется между ними. Она незаменима для мягких краёв.
precision mediump float;
uniform vec2 uResolution;
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
float d = distance(uv, vec2(0.5));
float alpha = 1.0 - smoothstep(0.3, 0.32, d);
gl_FragColor = vec4(1.0, 0.4, 0.2, alpha);
}
Третий приём это искажение по текстуре. Координаты выборки смещаются на небольшую величину, зависящую от другой текстуры или от времени. Так получают эффект ряби на воде и волнения.
Четвёртый приём это шум. Простая функция, дающая псевдослучайное значение по координатам, используется для зернистости, дыма и облаков.
precision highp float;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
Пятый приём это преобразование цвета. Насыщенность, контраст, оттенок меняются умножением и смешиванием составляющих. Так делают постобработку изображения.
Ветвления и циклы в шейдере обходятся дорого
Отдельная тема это управляющие конструкции. Формально язык поддерживает ветвления и циклы, но их использование отличается от привычного.
Причина в параллельности. Все запущенные экземпляры шейдера выполняются группами, и если внутри группы одна ветка идёт по одному пути, а другая по второму, оба пути выполняются последовательно для всей группы. Выигрыша от ветвления не получается.
Отсюда практические правила.
Первое: число итераций цикла делают постоянным. Если граница задаётся переменной, компилятор не может развернуть цикл.
Второе: тяжёлые вычисления выносят наружу. Всё, что не зависит от конкретного пикселя, считается один раз и передаётся как униформа.
Третье: избегают условий внутри цикла. Разбор случаев лучше заменить арифметикой.
Четвёртое: рекурсия запрещена вовсе и проверяется при компиляции. Это следствие той же модели выполнения.
Пятое: обращение к текстуре дороже арифметики. Лишние выборки из изображения заметны сильнее, чем десяток умножений.
Производительность зависит от разрешения и числа проходов
Третий источник проблем это объём работы.
Первое: стоимость пропорциональна числу пикселей. Эффект, работающий на весь экран, в четыре раза дороже того же эффекта на четверти экрана. На мобильных устройствах полный экран при высокой плотности пикселей может оказаться неподъёмным.
Второе: несколько проходов складываются. Каждый дополнительный проход это ещё одна отрисовка. Размытие в двадцать проходов съест ресурсы даже на мощной видеокарте.
Третье: точность вычислений влияет на скорость. Высокая точность нужна не везде. Если эффект работает с цветом от нуля до единицы, средней точности достаточно.
Четвёртое: текстуры занимают память. Крупные изображения на слабом устройстве приводят к сбросу контекста, и приложение теряет графику.
Пятое: измерение обязательно. Оценка на глаз обманывает. Счётчик кадров и замер времени на видеокарте показывают реальную картину.
let frames = 0;
let last = performance.now();
function measure(now) {
frames += 1;
if (now - last >= 1000) {
console.log("Кадров в секунду:", frames);
frames = 0;
last = now;
}
requestAnimationFrame(measure);
}
Когда шейдер оправдан, а когда это лишняя сложность
Полезно очертить границы применимости.
Шейдер оправдан для эффектов, работающих со всей областью изображения: размытие, искажение, цветокоррекция, генерация фонов. Обычными средствами такое делается медленно или не делается вовсе.
Он оправдан для обработки большого числа однотипных элементов. Частицы, трава, толпа. Видеокарта справляется с миллионами объектов там, где процессор захлёбывается.
Он оправдан для трёхмерной графики. Освещение, материалы, тени считаются именно там.
Он не нужен для простых переходов и анимаций интерфейса. Прозрачность, перемещение, изменение размера прекрасно выполняются средствами таблиц стилей, причём с аппаратным ускорением.
Он не нужен для десятка объектов. Затраты на подготовку программы и передачу данных превысят выигрыш.
Он опасен там, где требуется точность вычислений. Различия между видеокартами в округлении приводят к тому, что результат может отличаться на разных устройствах. Для научных расчётов это неприемлемо.
Отладка шейдеров сложнее обычной и требует приёмов
Четвёртая причина, по которой шейдеры считаются сложными, это отсутствие привычных инструментов.
Первое: вывода в консоль нет. Единственный способ увидеть значение это покрасить пиксель в зависимости от него. Приём называется визуализацией: вычисленное число превращается в оттенок серого.
precision highp float;
uniform vec2 uResolution;
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
float value = distance(uv, vec2(0.5));
// значение показывается как оттенок серого
gl_FragColor = vec4(vec3(value), 1.0);
}
Второе: сообщения компилятора различаются между устройствами. Одна видеокарта принимает код, другая отклоняет. Общие правила: указывать точность, не полагаться на неявные преобразования типов, явно приводить числа с плавающей точкой.
Третье: некоторые ошибки не видны до запуска на реальном устройстве. Проверка на нескольких моделях обязательна, особенно на мобильных.
Четвёртое: сообщение об ошибке содержит номер строки, но при сборке из нескольких файлов нумерация сбивается. Полезно выводить исходный код с номерами строк в журнал при сбое.
Пятое: полезно иметь набор простых проверочных шейдеров. Если на экране пусто, стоит подставить заведомо рабочий шейдер и убедиться, что дело не в настройке контекста.
Пошаговое освоение экономит недели
Начинать стоит с малого, добавляя по одной возможности.
Первый шаг: однотонный прямоугольник. Проверяется, что контекст создан и программа собрана.
Второй шаг: градиент. В шейдер передаётся разрешение экрана, и цвет зависит от координат.
Третий шаг: передача времени. Появляется движение, и видно, что цикл отрисовки работает.
Четвёртый шаг: простая фигура, нарисованная математикой. Круг через расстояние от центра.
Пятый шаг: плавные границы через функцию перехода. Появляется мягкость.
Шестой шаг: чтение из текстуры. Осваивается работа с изображением.
Седьмой шаг: искажение координат. Осваивается эффект ряби.
Каждый шаг добавляет одну небольшую возможность и заметен на экране. Такой порядок занимает несколько вечеров и даёт понимание всех основ без мучительных поисков.
Взаимодействие с обычной разметкой требует аккуратности
Шейдер живёт в отдельном элементе страницы, и его соседство с обычным интерфейсом имеет особенности.
Первое: элемент с графикой имеет собственный размер в пикселях, который не совпадает с размером на экране при высокой плотности. Соотношение нужно учитывать, иначе изображение окажется размытым.
function resize(canvas, gl) {
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.floor(canvas.clientWidth * ratio);
const height = Math.floor(canvas.clientHeight * ratio);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
}
Второе: при прокрутке и смене размера окна нужно пересчитывать размеры и перерисовывать. Событие изменения размера срабатывает часто, поэтому обработку стоит ограничивать по времени.
Третье: при уходе со страницы цикл отрисовки нужно останавливать. Иначе вкладка продолжит нагружать видеокарту в фоне, а на ноутбуке это сажает батарею.
Четвёртое: контекст может быть потерян. Видеокарта перезагружается, драйвер обновляется, система экономит энергию. Приложение должно уметь восстановить ресурсы и продолжить работу.
Пятое: доступность. Анимированный фон не должен мешать чтению текста. Уважение к системной настройке уменьшения движения обязательно.
Шейдеры получились инструментом, который не заменяет обычную вёрстку, а дополняет её там, где нужна вычислительная мощь. Знание языка само по себе не делает интерфейс лучше: важно понимать, какие задачи действительно требуют видеокарты, а какие решаются десятком строк стилей. Этот выбор и есть основное умение, а синтаксис придёт по ходу дела.
Программирование шейдеров получилось инструментом с высоким порогом входа и ясной отдачей. Язык выглядит непривычно, математика требует подготовки, отладка затруднена. Зато там, где задача сводится к однотипным вычислениям над миллионами значений, обычные средства бессильны, а видеокарта справляется играючи. Начинать стоит с одного эффекта и счётчика кадров, а дальше станет видно, где именно эта сила пригодится.