[Перевод] Создание мультяшного шейдера воды для веба. Часть 2

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

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

  • Добавил модели маяка и осьминога.
  • Добавил модель земли с цветом #FFA457.
  • Добавил камере цвет неба #6CC8FF.
  • Добавил в сцену цвет подсветки #FFC480 (эти параметры можно найти в настройках сцены).


Моя исходная сцена теперь выглядит так.

bc22fa0d57b5120e75cced6f105bebd2.png


Плавучесть


Самый простейший способ создания плавучести — скрипт, толкающий объекты вверх и вниз. Создайте новый скрипт Buoyancy.js и задайте в его initialize следующее:

Buoyancy.prototype.initialize = function() {
    this.initialPosition = this.entity.getPosition().clone();
    this.initialRotation = this.entity.getEulerAngles().clone();
    // Исходному времени присваивается случайное значение, чтобы
    // после прикрепления скрипта к разным объектам они 
    // не двигались все одинаково
    this.time = Math.random() * 2 * Math.PI;
};


Теперь в update мы выполняем инкремент времени и поворачиваем объект:

Buoyancy.prototype.update = function(dt) {
    this.time += 0.1;
    
    // Перемещаем объект вверх и вниз 
    var pos = this.entity.getPosition().clone();
    pos.y = this.initialPosition.y + Math.cos(this.time) * 0.07;
    this.entity.setPosition(pos.x,pos.y,pos.z);
    
    // Слегка поворачиваем объект
    var rot = this.entity.getEulerAngles().clone();
    rot.x = this.initialRotation.x + Math.cos(this.time * 0.25) * 1;
    rot.z = this.initialRotation.z + Math.sin(this.time * 0.5) * 2;
    this.entity.setLocalEulerAngles(rot.x,rot.y,rot.z);
};


Примените этот скрипт к лодке и посмотрите, как она скачет по воде вверх и вниз! Можно применить этот скрипт к нескольким объектам (в том числе и к камере — попробуйте)!

Текстурирование поверхности


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

Можете попробовать найти какие-нибудь текстуры каустики или создать её самостоятельно. Я нарисовал в Gimp такую текстуру, которую вы можете свободно использовать. Подойдёт любая текстура, при условии что её можно замостить без заметных стыков.

Подобрав понравившуюся текстуру, перетащите её в окно Assets своего проекта. Нам нужно ссылаться на эту текстуру из скрипта Water.js, поэтому создадим для неё атрибут:

Water.attributes.add('surfaceTexture', {
    type: 'asset',
    assetType: 'texture',
    title: 'Surface Texture'
});


А затем назначим её в редакторе:

67261e32f145eda9dab99d1866181ced.png


Теперь нам нужно передать её шейдеру. Зайдите в Water.js и задайте в функции CreateWaterMaterial новый параметр:

material.setParameter('uSurfaceTexture',this.surfaceTexture.resource);


Теперь вернитесь в Water.frag и объявите новую uniform:

uniform sampler2D uSurfaceTexture;


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

Varying-переменные


Varying-переменные позволяют передавать данные из вершинного шейдера во фрагментный. Это третий тип специальных переменных, которые можно использовать в шейдере (первые два — это uniform и attribute). Переменная задаётся для каждой вершины и к ней может получать доступ каждый пиксель. Поскольку пикселей гораздо больше, чем вершин, значение интерполируется между вершинами (отсюда и появилось название «varying» — оно отклоняется от передаваемых ей значений).

Чтобы проверить её в работе, объявим в Water.vert новую переменную как varying:

varying vec2 ScreenPosition;


А затем присвоим ей значение gl_Position после её вычисления:

ScreenPosition = gl_Position.xyz;


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

uniform sampler2D uSurfaceTexture;
varying vec3 ScreenPosition;

void main(void)
{
    vec4 color = vec4(0.0,0.7,1.0,0.5);
    
    // Проверяем нашу новую varying-переменную
    color = vec4(vec3(ScreenPosition.x),1.0);
    
    gl_FragColor = color;
}


Плоскость теперь должна выглядеть чёрно-белой, а разделяющая цвета линия будет проходить там, где ScreenPosition.x = 0. Значения цветов изменяются только от 0 до 1, но значения в ScreenPosition могут находиться за пределами этого интервала. Они автоматически ограничиваются, поэтому поэтому когда вы видите чёрный цвет, он может быть 0 или отрицательным числом.

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

Задача 1: создайте новую varying-переменную для передачи вместо экранной позиции позицию в мире. Визуализируйте её таким же способом. Если цвет не меняется вместе с движением камеры, то всё сделано верно.


Использование UV


UV — это 2D-координаты каждой вершины на меше, нормализованные от 0 до 1. Именно они необходимы для правильного сэмплирования текстуры на плоскость, и мы уже настроили их в предыдущей части.

Объявим в Water.vert новый атрибут (это название взято из определения шейдера в Water.js):

attribute vec2 aUv0;


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

// В Water.vert
// Мы объявляем переменную с другими переменными сверху
varying vec2 vUv0;

// ..
// Под основной функцией мы сохраняем значение атрибута
// в varying, чтобы к нему имел доступ фрагментный шейдер
vUv0 = aUv0;


Теперь мы объявим ту же самую varying-переменную во фрагментном шейдере. Чтобы убедиться, что всё работает, мы можем как и раньше визуализировать отладку, и тогда Water.frag будет выглядеть так:

uniform sampler2D uSurfaceTexture;
varying vec2 vUv0;

void main(void)
{
    vec4 color = vec4(0.0,0.7,1.0,0.5);
    
    // Подтверждаем UV 
    color = vec4(vec3(vUv0.x),1.0);
    
    gl_FragColor = color;
}


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

color = texture2D(uSurfaceTexture,vUv0);


После этого мы увидим на поверхности текстуру:

f499bfae266b0aa16709bbea0280df6f.png


Стилизация текстуры


Вместо того, чтобы просто задать текстуру в качестве нового цвета, давайте скомбинируем её с уже имеющимся синим:

uniform sampler2D uSurfaceTexture;
varying vec2 vUv0;

void main(void)
{
    vec4 color = vec4(0.0,0.7,1.0,0.5);
    
    vec4 WaterLines = texture2D(uSurfaceTexture,vUv0);
    color.rgba += WaterLines.r;
    
    gl_FragColor = color;
}


Это работает, потому что цвет текстуры чёрный (0) везде, кроме линий воды. Прибавляя его, мы не меняем исходный синий цвет, за исключением мест с линиями, где он становится светлее.

Однако это не единственный способ комбинирования цветов.

Задача 2: сможете ли вы скомбинировать цвета так, чтобы получить более слабый эффект, показанный ниже?


a3de1822a348656f47e5bd6cfa6cbed5.png


Перемещение текстуры


В качестве финального эффекта мы хотим, чтобы линии двигались по поверхности и она не выглядела такой статичной. Для этого мы воспользуемся тем фактом, что любое значение за пределами интервала от 0 до 1, передаваемое функции texture2D, будет переноситься (например, и 1.5, и 2.5 становятся равными 0.5). Поэтому мы можем увеличивать нашу позицию на уже заданную нами uniform-переменную времени, чтобы увеличивать или уменьшать плотность линий на поверхности, что придаст финальному фрагментному шейдеру такой вид:

uniform sampler2D uSurfaceTexture;
uniform float uTime;
varying vec2 vUv0;

void main(void)
{
    vec4 color = vec4(0.0,0.7,1.0,0.5);
    
    vec2 pos = vUv0;
    // При умножении на число больше 1
    // текстура начинает повторяться чаще
    pos *= 2.0;
    // Смещаем всю текстуру, чтобы она двигалась по поверхности
    pos.y += uTime * 0.02;
    
    vec4 WaterLines = texture2D(uSurfaceTexture,pos);
    color.rgba += WaterLines.r;
    
    gl_FragColor = color;
}


Линии пены и буфер глубин


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

Хитрость


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

Посмотрите на изображение ниже.

4d00e7eec8714a365c110aea67df9fab.png


Какие пиксели должны быть частью пены? Мы знаем, что это должно выглядеть примерно так:

1a8541c4f8fdd65879966b909805c484.png


Поэтому давайте рассмотрим два конкретных пикселя. Ниже я пометил их звёздочками. Чёрный будет находится на пене, а красный — нет. Как нам различить их в шейдере?

012d35fb25d42c19a86adaa081048237.png


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

cd35a031bbd18805128ebd44ed1c1279.png


Заметьте, что красная звёздочка не находится на корпусе маяка, как нам казалось, а чёрная и на самом деле там. Мы можем различить из с помощью расстояния до камеры, которое обычно называют «глубиной». Глубина 1 означает, что точка находится очень близко к камере, глубина 0 — что она очень далеко. Но это не только вопрос абсолютных расстояний в мире, глубины или камеры. Важна глубина относительно пикселя за ним.

Посмотрите снова на первый вид. Допустим, корпус маяка имеет значение глубины 0.5. Глубина чёрной звёздочки будет очень близка к 0.5. То есть она и пиксель под ней имеют очень близкие значения глубины. С другой стороны, красная звёздочка будет иметь гораздо большую глубину, потому что она ближе к камере, допустим 0.7. И хотя пиксель за ней всё равно находится на маяке, он имеет значение глубины 0.5, то есть здесь разница больше.

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

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

Буфер глубин


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

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

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

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

material.depthTest = false;


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

Визуализация буфера глубин


Давайте в целях отладки добавим способ визуализации буфера глубин. Создайте новый скрипт DepthVisualize.js. Прикрепите его к камере.

Чтобы получить доступ к буферу глубин в PlayCanvas, достаточно написать следующее:

this.entity.camera.camera.requestDepthMap();


Так мы автоматически выполняем инъекцию uniform-переменной во все наши шейдеры, которую мы можем использовать, объявив её следующим образом:

uniform sampler2D uDepthMap;


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

var DepthVisualize = pc.createScript('depthVisualize');

// код initialize, вызываемый один раз для каждой сущности
DepthVisualize.prototype.initialize = function() {
    this.entity.camera.camera.requestDepthMap();
    this.antiCacheCount = 0; // Запрещаем движку кэшировать шейдер, чтобы мы могли обновлять его в реальном времени
    
    this.SetupDepthViz();
};

DepthVisualize.prototype.SetupDepthViz = function(){
    var device = this.app.graphicsDevice;
    var chunks = pc.shaderChunks;
    
    this.fs = '';
    this.fs += 'varying vec2 vUv0;';
    this.fs += 'uniform sampler2D uDepthMap;';
    this.fs += '';
    this.fs += 'float unpackFloat(vec4 rgbaDepth) {';
    this.fs += '    const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);';
    this.fs += '    float depth = dot(rgbaDepth, bitShift);';
    this.fs += '    return depth;';
    this.fs += '}';
    this.fs += '';
    this.fs += 'void main(void) {';
    this.fs += '    float depth = unpackFloat(texture2D(uDepthMap, vUv0)) * 30.0; ';
    this.fs += '    gl_FragColor = vec4(vec3(depth),1.0);';
    this.fs += '}';
        
    this.shader = chunks.createShaderFromCode(device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount);
    this.antiCacheCount ++;
    
    // Мы вручную создаём вызов отрисовки, чтобы отрендерить карту глубин поверх всего остального
    this.command = new pc.Command(pc.LAYER_FX, pc.BLEND_NONE, function () {
       pc.drawQuadWithShader(device, null, this.shader);
    }.bind(this));
    this.command.isDepthViz = true; // Просто помечаем его так, чтобы можно было удалить позже

    this.app.scene.drawCalls.push(this.command);
};

// код update, вызываемый в каждом кадре
DepthVisualize.prototype.update = function(dt) {
    
};

// метод swap, вызываемый для горячей перезагрузки скрипта
// здесь наследует состояние нашего скрипта
DepthVisualize.prototype.swap = function(old) { 
    this.antiCacheCount = old.antiCacheCount;
    
    // Удаляем вызов отрисовки визуализации глубин 
    for(var i=0;i


Попробуйте скопировать код и закомментировать/раскомментировать строку this.app.scene.drawCalls.push(this.command); для включения/отключения рендеринга глубин. Это должно быть похоже на изображение ниже.

c0993ac4f8ed7a4c739658bbff69e455.png


Задача 3: поверхность воды не отрисовывается в буфер глубин. Движок PlayCanvas делает так намеренно. Можете разобраться, почему? Что особенного в материале воды? Иными словами, учитывая наши правила проверки глубин, что бы произошло, если бы пиксели воды записывались в буфер глубин?


Подсказка: в Water.js можно изменить одну строку, что позволит записывать воду в буфер глубин.

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

Реализация хитрости


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

Определим в Water.frag следующие uniform-переменные:

// Все эти uniform-переменные автоматически инъектируются движком PlayCanvas
uniform sampler2D uDepthMap;
uniform vec4 uScreenSize;
uniform mat4 matrix_view;
// Эти нам необходимо настроить самостоятельно
uniform vec4 camera_params;


Определим эти вспомогательные функции над основной функцией:

#ifdef GL2
    float linearizeDepth(float z) {
        z = z * 2.0 - 1.0;
        return 1.0 / (camera_params.z * z + camera_params.w);
    }
#else
    #ifndef UNPACKFLOAT
    #define UNPACKFLOAT
    float unpackFloat(vec4 rgbaDepth) {
        const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);
        return dot(rgbaDepth, bitShift);
    }
    #endif
#endif

float getLinearScreenDepth(vec2 uv) {
    #ifdef GL2
        return linearizeDepth(texture2D(uDepthMap, uv).r) * camera_params.y;
    #else
        return unpackFloat(texture2D(uDepthMap, uv)) * camera_params.y;
    #endif
}

float getLinearDepth(vec3 pos) {
    return -(matrix_view * vec4(pos, 1.0)).z;
}

float getLinearScreenDepth() {
    vec2 uv = gl_FragCoord.xy * uScreenSize.zw;
    return getLinearScreenDepth(uv);
}


Передадим шейдеру информацию о камере в Water.js. Вставьте это туда, где вы передаёте другие uniform-переменные наподобие uTime:

if(!this.camera){
    this.camera = this.app.root.findByName("Camera").camera;
}
var camera = this.camera; 
var n = camera.nearClip;
var f = camera.farClip;
var camera_params = [
    1/f,
    f,
    (1-f / n) / 2,
    (1 + f / n) / 2
];
        
material.setParameter('camera_params', camera_params);


Наконец, нам нужна позиция в мире каждого пикселя для нашего фрагментного шейдера. Мы должны получить её от вершинного шейдера. Поэтому определим в Water.frag varying-переменную:

varying vec3 WorldPosition;


Определим ту же varying-переменную в Water.vert. Затем присвоим ей искажённую позицию из вершинного шейдера, чтобы полный код выглядел так:

attribute vec3 aPosition;
attribute vec2 aUv0;

varying vec2 vUv0;
varying vec3 WorldPosition;

uniform mat4 matrix_model;
uniform mat4 matrix_viewProjection;

uniform float uTime;

void main(void)
{
    vUv0 = aUv0;   
    vec3 pos = aPosition;

    pos.y +=  cos(pos.z*5.0+uTime) * 0.1 * sin(pos.x * 5.0 + uTime);
    
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
    
    WorldPosition = pos;
}


Реализуем хитрость по-настоящему


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

float worldDepth = getLinearDepth(WorldPosition);
float screenDepth = getLinearScreenDepth();


Задача 4: одно из этих значений никогда не будет больше другого (если считать, что depthTest = true). Можете определить, какое?


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

color = vec4(vec3(screenDepth - worldDepth),1.0);
gl_FragColor = color;


И это должно выглядеть примерно так:

ad2173883ebe214bbec6c97921810755.png


То есть мы корректно выбираем границы любого объекта, погружаемого в воду в реальном времени! Разумеется, можно масштабировать разницу, чтобы сделать пену гуще или реже.

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

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

void main(void)
{
    vec4 color = vec4(0.0,0.7,1.0,0.5);
    
    vec2 pos = vUv0 * 2.0;
    pos.y += uTime * 0.02;
    
    vec4 WaterLines = texture2D(uSurfaceTexture,pos);
    color.rgba += WaterLines.r * 0.1;
    
    float worldDepth = getLinearDepth(WorldPosition);
    float screenDepth = getLinearScreenDepth();
    float foamLine = clamp((screenDepth - worldDepth),0.0,1.0) ;
    
    if(foamLine < 0.7){
        color.rgba += 0.2;
    }
    
    gl_FragColor = color;
}


Подводим итоги


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

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

Исходный код


Готовый проект PlayCanvas можно найти здесь. В нашем репозитории также есть порт проекта под Three.js.

© Habrahabr.ru