Создание шейдера дыма на GLSL

в 9:32, , рубрики: glsl, OpenGL, WebGL, обработка изображений, разработка игр, шейдеры

image
[Дым на КДПВ несколько сложнее получаемого в туториале.]

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

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

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

Чему мы научимся

Вот конечный результат, к которому мы будем стремиться:

Мы реализуем алгоритм, описанный в работе Джоса Стэма о динамике жидкостей в играх в реальном времени. Кроме того, мы узнаем, как рендерить в текстуру, эта техника также называется буферы кадров. Она очень полезна в программировании шейдеров, потому что позволяет создать множество эффектов.

Подготовка

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

Все примеры кода хранятся на CodePen, но их можно также найти в связанном со статьёй репозитории GitHub (там код может быть удобнее читать).

Теория и основы

Алгоритм из работы Джоса Стэма отдаёт приоритет скорости и визуальному качеству в ущерб физической точности, именно это и нужно нам в играх.

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

Создание шейдера дыма на GLSL - 2
Благодаря рассеиванию каждая ячейка обменивается своей плотностью с соседями.

Это всё, что потребуется для создания реалистично выглядящего эффекта дыма: значение в каждой ячейке при каждой итерации «рассеивается» во все её соседние ячейки. Этот принцип не всегда понятен сразу, если вы хотите поэкспериментировать с примером, то можно изучить интерактивное демо:

Создание шейдера дыма на GLSL - 3
Просмотреть интерактивное демо на CodePen.

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

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

//W = количество столбцов в сетке
//H = количество строк
//f = коэффициент распределения/рассеивания
//Сначала мы копируем сетку в newGrid, чтобы не изменять сетку, потому что выполняем считывание из неё
for(var r=1; r<W-1; r++){
    for(var c=1; c<H-1; c++){
		newGrid[r][c] += 
			f * 
			(
				gridData[r-1][c] + 
				gridData[r+1][c] + 
				gridData[r][c-1] + 
				gridData[r][c+1] - 
				4 * gridData[r][c] 
			);
	}
}

Этот фрагмент кода является основой алгоритма. Каждая ячейка получает часть значений четырёх соседних ячеек минус её собственное значение, где f — коэффициент меньше 1. Мы умножаем текущее значение ячейки на 4, чтобы оно рассеивалось от высоких к низким значениям.

Чтобы это стало понятно, рассмотрим следующую ситуацию:

Создание шейдера дыма на GLSL - 4

Возьмём ячейку посередине (в позиции [1,1] в сетке) и применим указанное выше уравнение рассеивания. Допустим, что f равен 0.1:

0.1 * (100+100+100+100-4*100) = 0.1 * (400-400) = 0

Никакого рассеивания не происходит, потому что все ячейки имеют одинаковые значения!

Рассмотрим тогда ячейку в верхнем левом углу (считаем, что значения всех ячеек за пределами показанной сетки равны 0):

0.1 * (100+100+0+0-4*0) = 0.1 * (200) = 20

Итак, теперь у нас получился чистый прирост 20! Давайте рассмотрим последний случай. После одного временного шага (после применения этой формулы ко всем ячейкам) наша сетка будет выглядеть вот так:

Создание шейдера дыма на GLSL - 5

Давайте снова посмотрим на рассеивание в ячейке посередине:

0.1 * (70+70+70+70-4*100) = 0.1 * (280 - 400) = -12

Мы получили чистое уменьшение в 12! Поэтому значения всегда изменяются от больших к меньшим.

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

Итак, подведём итог: наша общая техника заключается в том, что каждый пиксель каждый кадр теряет часть своего значения цвета и передаёт его соседним пикселям. Звучит очень просто, не так ли? Давайте реализуем эту систему и посмотрим, что получится!

Реализация

Мы начнём с базового шейдера, который выполняет отрисовку всего экрана. Чтобы убедиться, что он работает, попробуем закрасить экран сплошным чёрным цветом (или любым другим). Вот как выглядит схема, которую я использую в Javascript.

Нажимайте на кнопки вверху, чтобы увидеть код HTML, CSS и JS.

Наш шейдер прост:

uniform vec2 res;
void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;
    gl_FragColor = vec4(0.0,0.0,0.0,1.0);
 }

res и pixel сообщают нам координаты текущего пикселя. Мы передаём размеры экрана в res как uniform-переменную. (Пока мы их не используем, но скоро они пригодятся.)

Шаг 1: перемещаем значения между пикселями

Повторю ещё раз то, что мы хотим реализовать:

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

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

Каждый пиксель должен получить немного цвета своих соседей и потерять немного своего.

Теперь это решение можно реализовать. Однако если попробовать сделать это, то мы наткнёмся на фундаментальную проблему…

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

uniform vec2 res;
uniform sampler2D texture;
void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;

    gl_FragColor = texture2D( tex, pixel );// Это цвет текущего пикселя
    gl_FragColor.r += 0.01;// Инкремент красного компонента 	
 }

Можно ожидать, что каждый кадр красный компонент каждого пикселя будет увеличиваться на 0.01. Вместо этого мы получим статичное изображение, в котором все пиксели стали всего немного краснее, чем в начале. Красный компонент каждого пикселя увеличится только один раз, несмотря на то, что шейдер выполняется каждый кадр.

Понимаете, почему так происходит?

Проблема

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

Создание шейдера дыма на GLSL - 6

Мы передаём uniform-переменные и текстуру шейдеру, он делает пиксели немного краснее, отрисовывает их на экране, а затем начинает всё заново. Всё, что мы отрисовываем в шейдере, очищается при следующем шаге отрисовки.

Нам же нужно что-то вроде этого:

Создание шейдера дыма на GLSL - 7

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

Фокус с буфером кадра

Общая техника будет одинакова для всех платформ. Загуглите «рендеринг в текстуру» для любого используемого вами языка или инструмента и изучите подробности реализации. Также можно посмотреть, как использовать объекты буфера кадра, которые являются просто ещё одним названием для функции рендеринга в какой-нибудь буфер вместо экрана.

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

Пусть A и B — это две созданные нами текстуры. Тогда способ будет следующим:

  1. Передаём A через шейдер, рендерим в B.
  2. Рендерим B на экран.
  3. Передаём B через шейдер, рендерим в A.
  4. Рендерим A на экран.
  5. Повторяем 1.

Более краткий код будет следующим:

  1. Передаём A через шейдер, рендерим в B.
  2. Рендерим B на экран.
  3. Меняем A и B (то есть переменная A теперь содержит текстуру, находившуюся в B, и наоборот).
  4. Повторяем 1.

На этом всё. Вот реализация этого алгоритма в ThreeJS:

Новый код шейдера находится во вкладке HTML.

Мы по-прежнему видим чёрный экран, с которого и начинали. Шейдер тоже не слишком отличается:

uniform vec2 res; // Ширина и высота экрана
uniform sampler2D bufferTexture; // Входная текстура
void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;
    gl_FragColor = texture2D( bufferTexture, pixel );
}

Кроме того, что теперь мы добавили эту строку (протестируйте!):

gl_FragColor.r += 0.01;

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

Задача: Что произойдёт, если мы вставим gl_FragColor.r += pixel.x; в примере с буфером кадра, в отличие от первоначального примера? Подумайте немного, почему отличаются результаты, и почему они именно такие.

Шаг 2: получаем источник дыма

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

// Получаем расстояние от этого пикселя до центра экрана
float dist = distance(gl_FragCoord.xy, res.xy/2.0);
if(dist < 15.0){ // Создаём круг радиусом 15 пикселей
	gl_FragColor.rgb = vec3(1.0);
}

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

// Получаем расстояние от этого пикселя до центра экрана
float dist = distance(gl_FragCoord.xy, res.xy/2.0);
if(dist < 15.0){ // Создаём круг радиусом 15 пикселей
	gl_FragColor.rgb += 0.01;
}

Ещё один способ — заменить эту фиксированную точку положением мыши. Мы можем передавать третье значение, сообщающее о том, нажата ли клавиша мыши. Таким образом мы можем создавать дым нажатием левой клавиши. Вот реализация этой возможности:

Нажмите, чтобы создать дым.

Вот как выглядит наш шейдер:

// Ширина и высота экрана
uniform vec2 res; 
// Входная текстура
uniform sampler2D bufferTexture; 
// x,y - это положение. z - это сила/плотность
uniform vec3 smokeSource;

void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;
    gl_FragColor = texture2D( bufferTexture, pixel );

    // Получаем расстояние от текущего пикселя до источника дыма
    float dist = distance(smokeSource.xy,gl_FragCoord.xy);
    // Создаём дым, когда нажата клавиша мыши
    if(smokeSource.z > 0.0 && dist < 15.0){
    	gl_FragColor.rgb += smokeSource.z;
    }
}

Задача: не забывайте, что ветвление (условные переходы) обычно в шейдерах затратны. Можете переписать шейдер без использования конструкции if? (Решение есть в CodePen.)

Если вы не понимаете, то в предыдущем туториале есть подробное объяснение использования мыши в шейдерах (в части об освещении).

Шаг 3: рассеиваем дым

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

Это выражается примерно так:

// Рассеивание дыма
float xPixel = 1.0/res.x; // Размер единичного пикселя
float yPixel = 1.0/res.y;

vec4 rightColor = texture2D(bufferTexture,vec2(pixel.x+xPixel,pixel.y));
vec4 leftColor = texture2D(bufferTexture,vec2(pixel.x-xPixel,pixel.y));
vec4 upColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y+yPixel));
vec4 downColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y-yPixel));

// Уравнение рассеивания
gl_FragColor.rgb += 
    14.0 * 0.016 * 
	(
		leftColor.rgb + 
		rightColor.rgb + 
		downColor.rgb + 
		upColor.rgb - 
		4.0 * gl_FragColor.rgb
	);

Коэффициент f остаётся прежним. В этом случае у нас есть временной шаг (0.016, то есть 1/60, потому что программа выполняется с частотой 60 fps), и я подбирал разные числа, пока не остановился на значении 14, которое хорошо выглядит. Вот результат:

Ой-ёй, всё зависло!

Это то же самое уравнение рассеивания, которое мы использовали в демо для центрального процессора, но наша симуляция останавливается! В чём причина?

Оказывается, что текстуры (как и все числа в компьютере) имеют ограниченную точность. В какой-то момент коэффициент, который мы вычитаем, становится слишком маленьким и округляется до 0, поэтому симуляция останавливается. Чтобы исправить это, нам нужно проверять, что он не опускается ниже какого-нибудь минимального значения:

float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r);
// Нам нужно учитывать низкую точность текселов
float minimum = 0.003;
if (factor >= -minimum && factor < 0.0) factor = -minimum;

gl_FragColor.rgb += factor;

Я использую для получения коэффициента компонент r вместо rgb, потому что легче работать с отдельными числами и потому, что все компоненты всё равно имеют одинаковые значения (так как дым белый).

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

Шаг 4: рассеивание дыма вверх

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

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

// Уравнение рассеивания
float factor = 8.0 * 0.016 * 
    (
		leftColor.r + 
		rightColor.r + 
		downColor.r * 3.0 + 
		upColor.r - 
		6.0 * gl_FragColor.r
	);

И вот как выглядит шейдер:

Примечание об уравнении рассеивания

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

Важно добавить, что очень просто «взорвать» симуляцию. (Попробуйте изменить значение 6.0 на 5.0 и посмотрите, что получится). Очевидно, что это происходит из-за того, что ячейки получают больше, чем теряют.

Это уравнение на самом деле упоминается в процитированной мной работе как модель «плохого рассеивания». В работе есть другое уравнение, которое более стабильно, но оно не очень удобно для нас, в основном потому, что ему требуется запись в сетку, из которой мы считываем. Другими словами, нам потребуется считывать и записывать одну текстуру одновременно.

Того, что у нас есть, достаточно для наших целей, но если интересно, можете изучить объяснение в работе. Кроме того, другое уравнение реализовано в интерактивном демо для центрального процессора, см. функцию diffuse_advanced().

Небольшое исправление

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

Чтобы исправить это, мы дадим нижним пикселям находить под собой 0:

// Обрабатываем нижнюю границу
// Эти строки нужно выполнять до функции рассеивания
 if(pixel.y <= yPixel){
 	downColor.rgb = vec3(0.0);
 }

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

Сетка скоростей

Поздравляю! Теперь у вас есть готовый шейдер дыма! В конце я хотел бы кратко рассказать об упоминаемом в работе поле скоростей.

Создание шейдера дыма на GLSL - 8
Этап переноса (адвекции) перемещает плотность по статичному полю скоростей.

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

На самом деле текстура скоростей тоже не обязана быть статичной! Можно использовать фокус с буфером кадра для изменения скоростей в реальном времени. Я не буду рассказывать об этом в туториале, но эта функция имеет большой потенциал для исследований.

Заключение

Самое важное, что можно почерпнуть из этого туториала: возможность рендеринга в текстуру вместо экрана — это очень полезная техника.

Для чего могут пригодиться буферы кадров?

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

Ещё один пример использования — реализация шейдеров, требующих нескольких проходов. например blur. Обычно изображение пропускается через шейдер, размывается по оси x, а затем пропускается снова для размывания по оси y.

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

Не бойтесь технических статей

Намного больше подробностей можно найти в процитированной мной работе. Оно требует знакомства с линейной алгеброй, но пусть это не помешает вам проанализировать и попробовать реализовать систему. Суть её довольно просто реализовать (после некоторой подстройки коэффициентов).

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

Автор: PatientZero

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js