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

image


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

Преломление и эффекты постобработки


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

Постобработка


В общем случае эффект постобработки — это любой эффект, применяемый к всей сцене после её рендеринга, например, оттенки цвета или эффект старого ЭЛТ-экрана. Вместо рендеринга сцены непосредственно на экран, мы сначала рендерим её в буфер или текстуру, а затем, пропустив сцену через свой шейдер, выполняем рендеринг на экран.
В PlayCanvas можно настроить этот эффект постобработки созданием нового скрипта. Назовём его Refraction.js и скопируем в него как заготовку этот шаблон:

//--------------- ОПРЕДЕЛЕНИЕ ЭФФЕКТА ПОСТОБРАБОТКИ------------------------//
pc.extend(pc, function () {
    // Конструктор - создаёт экземпляр нашего эффекта постобработки
    var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
        fragmentShader = fragmentShader + fs;
        
        // это определение шейдера для эффекта
        this.shader = new pc.Shader(graphicsDevice, {
            attributes: {
                aPosition: pc.SEMANTIC_POSITION
            },
            vshader: vs,
            fshader: fs
        });
        
        this.buffer =  buffer; 
    };

    // Наш эффект должен порождаться из pc.PostEffect
    RefractionPostEffect = pc.inherits(RefractionPostEffect, pc.PostEffect);

    RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
        // Каждый постэффект должен реализовывать метод render,
        // который задаёт все параметры, которые могут понадобиться шейдеру,
        // а также рендерит эффект на экран
        render: function (inputTarget, outputTarget, rect) {
            var device = this.device;
            var scope = device.scope;

            // Задаём шейдер в качестве входного целевого рендера. Это изображение, отрендеренное из нашей камеры
            scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);            

            // Отрисовываем четырёхугольник полного экрана в целевой вывод. В нашем случае цеелвым выводом является экран.
            // Отристовка четырёхугольника полного экрана будет выполнять определённый выше шейдер
            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
        }
    });

    return {
        RefractionPostEffect: RefractionPostEffect
    };
}());

//--------------- ОПРЕДЕЛЕНИЕ СКРИПТА------------------------//
var Refraction = pc.createScript('refraction');

Refraction.attributes.add('vs', {
    type: 'asset',
    assetType: 'shader',
    title: 'Vertex Shader'
});

Refraction.attributes.add('fs', {
    type: 'asset',
    assetType: 'shader',
    title: 'Fragment Shader'
});

// Код initialize вызывается один раз для каждой сущности
Refraction.prototype.initialize = function() {
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource);

    // добавляем эффект очередь камеры postEffects
    var queue = this.entity.camera.postEffects;
    queue.addEffect(effect);
    
    this.effect = effect;
    
    // Сохраняем текущие шейдеры для горячей перезагрузки
    this.savedVS = this.vs.resource;
    this.savedFS = this.fs.resource;
};

Refraction.prototype.update = function(){
     if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){
         this.swap(this);
     }
};

Refraction.prototype.swap = function(old){
    this.entity.camera.postEffects.removeEffect(old.effect);
    this.initialize(); 
};


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

precision highp float;

uniform sampler2D uColorBuffer;
varying vec2 vUv0;

void main() {
    vec4 color = texture2D(uColorBuffer, vUv0);
    
    gl_FragColor = color;
}


И Refraction.vert с базовым вершинным шейдером:

attribute vec2 aPosition;
varying vec2 vUv0;

void main(void)
{
    gl_Position = vec4(aPosition, 0.0, 1.0);
    vUv0 = (aPosition.xy + 1.0) * 0.5;
}


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

Вместо простого возврата цвета в Refraction.frag попробуйте присвоить красному компоненту значение 1.0, что должно придать картинке показанный ниже вид.

981b78937b536280aeacdb3ef8c33897.png


Шейдер искажений


Для создания анимированного искажения нам необходимо добавить uniform-переменную времени, поэтому давайте создадим её внутри этого конструктора постэффекта в Refraction.js:

var RefractionPostEffect = function (graphicsDevice, vs, fs) {
    var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
    fragmentShader = fragmentShader + fs;
    
    // это определение шейдера для нашего эффекта
    this.shader = new pc.Shader(graphicsDevice, {
        attributes: {
            aPosition: pc.SEMANTIC_POSITION
        },
        vshader: vs,
        fshader: fs
    });
    
    // >>>>>>>>>>>>> Здесь инициализируем время
    this.time = 0;
    
    };


Теперь внутри функции render мы передаём её нашему шейдеру, чтобы увеличить её:

RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
    // Каждый постэффект должен реализовывать метод render,
    // который задаёт все необходимые шейдеру параметры,
    // а также рендерит эффект на экране
    render: function (inputTarget, outputTarget, rect) {
        var device = this.device;
        var scope = device.scope;

        // Задаём шейдер в качестве входного целевого рендера. Это изображение, отрендеренное из нашей камеры
        scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);   
        /// >>>>>>>>>>>>>>>>>> Здесь мы передаём uniform-переменную времени
        scope.resolve("uTime").setValue(this.time);
        this.time += 0.1;

        // Отрисовываем четырёхугольник полного экрана в целевой вывод. В нашем случае цеелвым выводом является экран.
        // Отристовка четырёхугольника полного экрана будет выполнять определённый выше шейдер
        pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
    }
});


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

precision highp float;

uniform sampler2D uColorBuffer;
uniform float uTime;

varying vec2 vUv0;

void main() {
    vec2 pos = vUv0;
    
    float X = pos.x*15.+uTime*0.5;
    float Y = pos.y*15.+uTime*0.5;
    pos.y += cos(X+Y)*0.01*cos(Y);
    pos.x += sin(X-Y)*0.01*sin(Y);
    
    vec4 color = texture2D(uColorBuffer, pos);
    
    gl_FragColor = color;
}


Если всё сделано верно, то вся картинка должна выглядеть так, как будто полностью находится под водой.

bbda8e0b0fe175450f789f2a7ec94304.gif


Задача 1: сделайте так, чтобы искажения применялись только к нижней части экрана.


Маски камеры


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

41e06f284290afbf768f6f615360c8bf.png


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

Давайте добавим поверхности воды булев атрибут, чтобы знать, используется ли она как маска. Добавим в Water.js следующее:

Water.attributes.add('isMask', {type:'boolean',title:"Is Mask?"});


Затем мы как обычно можем передать его в шейдер с помощью material.setParameter('isMask',this.isMask);. Затем объявить его в Water.frag и окрашивать пиксель белым, если атрибут имеет значение true.

// Объявляем вверху новую uniform
uniform bool isMask;

// В конце основной функции переопределяем цвет, делая его белым 
// если маска равна true 
if(isMask){
   color = vec4(1.0); 
}


Убедимся, что это работает, включив свойство «Is Mask?» в редакторе и перезапустив игру. Она должна выглядеть белой, как на изображении выше.

Теперь для повторного рендеринга сцены нам нужна вторая камера. Создадим в редакторе новую камеру и назовём её CameraMask. Также дублируем в редакторе сущность Water и назовём дубликат WaterMask. Убедитесь, что для сущности Water «Is Mask?» равно false, а WaterMask равно true.

Чтобы приказать новой камере выполнять рендеринг в текстуру, а не на экран, создадим новый скрипт CameraMask.js и прикрепим его к новой камере. Мы создаём RenderTarget для захвата вывода этой камеры:

// Код initialize выполняется один раз для каждой сущности
CameraMask.prototype.initialize = function() {
    // Создаём 512x512x24-битный целевой рендер с буфером глубин
    var colorBuffer = new pc.Texture(this.app.graphicsDevice, {
        width: 512,
        height: 512,
        format: pc.PIXELFORMAT_R8_G8_B8,
        autoMipmap: true
    });
    colorBuffer.minFilter = pc.FILTER_LINEAR;
    colorBuffer.magFilter = pc.FILTER_LINEAR;
    var renderTarget = new pc.RenderTarget(this.app.graphicsDevice, colorBuffer, {
        depth: true
    });

    this.entity.camera.renderTarget = renderTarget;
};


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

Refraction.prototype.initialize = function() {
    var cameraMask = this.app.root.findByName('CameraMask');
    var maskBuffer = cameraMask.camera.renderTarget.colorBuffer;
    
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource, maskBuffer);
    
    // ...
    // Остальная часть функции такая же, как и раньше
    
};


Заметьте, что я передаю эту текстуру-маску как аргумент конструктору постэффекта. Нам нужно создать ссылку на него в нашем конструкторе, поэтому это будет выглядеть так:

//// Добавили новый аргумент в строке ниже
var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
        fragmentShader = fragmentShader + fs;
        
        // это определение шейдера для нашего эффекта
        this.shader = new pc.Shader(graphicsDevice, {
            attributes: {
                aPosition: pc.SEMANTIC_POSITION
            },
            vshader: vs,
            fshader: fs
        });
        
        this.time = 0;
        //// <<<<<<<<<<<<< Здесь сохраняем буфер
        this.buffer = buffer; 
    };


Наконец в функции render передаём буфер нашему шейдеру:

scope.resolve("uMaskBuffer").setValue(this.buffer);


Теперь чтобы убедиться, что всё это работает, я оставлю это вам в качестве задачи.

Задача 2: отрендерите uMaskBuffer на экран, чтобы убедиться, что он является выводом второй камеры.


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

2d80937472cd112108f303959c35d4c1.png


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

CameraMask.prototype.update = function(dt) {
    var pos = this.CameraToFollow.getPosition();
    var rot = this.CameraToFollow.getRotation();
    this.entity.setPosition(pos.x,pos.y,pos.z);
    this.entity.setRotation(rot);
};


В initialize определим CameraToFollow:

this.CameraToFollow = this.app.root.findByName('Camera');


Маски отсечения


Обе камеры сейчас рендерят одно и то же. Мы хотим, чтобы камера маски рендерила всё, кроме настоящей воды, а настоящая камера рендерила всё, кроме воды маски.

Для этого мы можем использовать битовую маску отсечения камеры. Она работает аналогично маскам коллизий. Объект будет отсекаться (то есть не рендериться), если результат побитового AND между его маской и маской камеры равен 1.

Допустим, у Water задан бит 2, а у WaterMask — бит 3. У настоящей камеры должны быть заданы все биты, кроме 3, а у камеры маски — все биты за исключением 2. Проще всего сказать «все биты, кроме N», следующим образом:

~(1 << N) >>> 0


Подробнее прочитать о побитовых операциях можно здесь.

Для настройки масок отсечения камеры мы можем вставить в нижней части initialize скрипта CameraMask.js следующее:

    // Задаём все биты, кроме 2 
    this.entity.camera.camera.cullingMask &= ~(1 << 2) >>> 0;
    // Задаём все биты, кроме 3
    this.CameraToFollow.camera.camera.cullingMask &= ~(1 << 3) >>> 0;
    // Если хотите вывести эту битовую маску, то попробуйте следующее:
    // console.log((this.CameraToFollow.camera.camera.cullingMask >>> 0).toString(2));


Теперь в Water.js зададим бит 2 маски меша Water, а and the mask version of it on bit 3:

// Вставьте это в нижнюю часть initialize скрипта Water.js

// Задание масок отсечения
var bit = this.isMask ? 3 : 2; 
meshInstance.mask = 0; 
meshInstance.mask |= (1 << bit);


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

d55e1b271351df16edab549ca065837f.png


Применение маски


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

// Проверяем исходную позицию, а также новую искажённую позицию
vec4 maskColor = texture2D(uMaskBuffer, pos);
vec4 maskColor2 = texture2D(uMaskBuffer, vUv0);
// Мы не в белом пикселе?
if(maskColor != vec4(1.0) || maskColor2 != vec4(1.0)){
    // Возвращаем его к исходной позиции
    pos = vUv0;
}


И это должно решить нашу задачу!

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

Сглаживание


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

Мы можем применить дополнительное сглаживание поверх нашего эффекта как ещё один постэффект. К счастью, в PlayCanvas store есть ещё одна переменная, которую мы можем использовать. Зайдите на страницу ассетов скриптов, нажмите на большую зелёную кнопку скачивания, и выберите в появившемся списке свой проект. Скрипт появится в корне окна Assets как posteffect-fxaa.js. Просто прикрепите его к сущности Camera, и ваша сцена начнёт выглядеть намного лучше!

Мысли в завершение


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

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

Теперь у вас есть красивый эффект воды, который можно применять в своих играх! Можете настраивать его и добавлять собственные детали. С водой можно сделать гораздо большее (я даже не упоминал ни о каком из видов отражений). Ниже представлена пара идей.

Волны на основе шума


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

Динамические следы пены


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

Исходный код


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

© Habrahabr.ru