Шейдер воды для Web. Часть 1

Шейдер воды для Web. Часть 1

В Руководстве по шейдерам для начинающих я сосредоточился исключительно на фрагментных шейдерах, которых достаточно для любого 2D эффекта и всякого примера с ShaderToy.

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

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

шейдер воды тьюториал

В частности, этот эффект состоит из:

  1. Подразделенной прозрачной сетки воды со смещенными вершинами, чтобы сделать волны.
  2. Статических полос воды на поверхности.
  3. Искусственного раскачивания лодок.
  4. Динамических полос пены вокруг краев объектов в воде.
  5. Постпроцессного искажения всего под водой.

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

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

Настройка окружения

Цель этого раздела – настроить наш проект PlayCanvas и разместить некоторые объекты окружения, чтобы протестировать воду.

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

пустой проект

Вставка моделей

Проект Google Poly – это действительно отличный ресурс 3D моделей для Web. Вот модель лодки, которую я использовал. Как только вы загрузите и разархивируете ее, вы должны найти файлы .obj и .png.

  1. Перетащите оба файла в окно ресурсов в своем проекте PlayCanvas.
  2. Выберите материал, который был автоматически создан, и установите его диффузную карту в файл .png.
настройки объекта лодка

Теперь вы можете перетащить Tugboat.json в вашу сцену и удалить объекты Box и Plane. Вы можете масштабировать лодку, если она выглядит слишком маленькой (я установил на 50).

масштабирование объекта

Аналогичным образом вы можете добавить в свою сцену любые другие модели.

Орбитальная камера

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

  1. Скопируйте содержимое mouse-input.js и orbit-camera.js из того туториала в файлы с таким же именем в вашем проекте.
  2. Добавьте компонент Script к своей камере.
  3. Прикрепите два скрипта к камере.

Совет: Вы можете создавать папки в окне ресурсов, чтобы все упорядочить. Я поместил эти два сценария для камеры в Scripts/Camera/, свою модель – в Models/, а мой материал – в Materials/.

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

Подразделенная поверхность воды

Задача этой части сгенерировать подразделенную сетку и использовать ее в качестве поверхности нашей воды.

Чтобы сгенерировать поверхность воды, мы собираемся адаптировать код из этого урока по созданию местности. Создайте новый файл скрипта с именем Water.js. Отредактируйте этот скрипт и создайте новую функцию GeneratePlaneMesh, которая выглядит следующим образом:

Water.prototype.GeneratePlaneMesh = function(options){
    // 1 - Установите параметры по умолчанию, если они не указаны
    if(options === undefined)
        options = {subdivisions:100, width:10, height:10};
    // 2 - Сгенерируйте точки, uv и индексы 
    var positions = [];
    var uvs = [];
    var indices = [];
    var row, col;
    var normals;
 
    for (row = 0; row <= options.subdivisions; row++) {
        for (col = 0; col <= options.subdivisions; col++) {
            var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0));
             
            positions.push(position.x, position.y, position.z);
             
            uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions);
        }
    }
 
    for (row = 0; row < options.subdivisions; row++) {
        for (col = 0; col < options.subdivisions; col++) {
            indices.push(col + row * (options.subdivisions + 1));
            indices.push(col + 1 + row * (options.subdivisions + 1));
            indices.push(col + 1 + (row + 1) * (options.subdivisions + 1));
 
            indices.push(col + row * (options.subdivisions + 1));
            indices.push(col + 1 + (row + 1) * (options.subdivisions + 1));
            indices.push(col + (row + 1) * (options.subdivisions + 1));
        }
    }
     
    // Вычислите нормали 
    normals = pc.calculateNormals(positions, indices);
 
     
    // Сделайте реальную модель
    var node = new pc.GraphNode();
    var material = new pc.StandardMaterial();
    
    // Создайте сетку 
    var mesh = pc.createMesh(this.app.graphicsDevice, positions, {
        normals: normals,
        uvs: uvs,
        indices: indices
    });
 
    var meshInstance = new pc.MeshInstance(node, mesh, material);
     
    // Добавьте ее к этому объекту 
    var model = new pc.Model();
    model.graph = node;
    model.meshInstances.push(meshInstance);
     
    this.entity.addComponent('model');
    this.entity.model.model = model;
    this.entity.model.castShadows = false; // Нам не нужно, чтобы сама поверхность воды отбрасывала тень 
};

Теперь вы можете вызвать это в функции initialize:

Water.prototype.initialize = function() {
    this.GeneratePlaneMesh({subdivisions:100, width:10, height:10});
};

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

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

застывшие волны

Волны

Задача этой части – дать поверхности воды специальный материал и создать анимированные волны.

Чтобы получить желаемый эффект, нам нужно установить специальный материал. Большинство 3D-движков будут иметь некоторые предопределенные шейдеры для рендеринга объектов и способ их переопределения. Вот хороший справочник как это делается в PlayCanvas.

Добавка шейдера

Давайте создадим новую функцию CreateWaterMaterial, которая определяет новый материал с помощью пользовательского шейдера и возвращает его:

Water.prototype.CreateWaterMaterial = function(){
    // Создайте пустой материал  
    var material = new pc.Material();
    // Имя просто облегчает поиск при отладке
    material.name = "DynamicWater_Material";
     
    // Создайте определение шейдера 
    // динамически установите точность в зависимости от устройства.
    var gd = this.app.graphicsDevice;
    var fragmentShader = "precision " + gd.precision + " float;\n";
    fragmentShader = fragmentShader + this.fs.resource;
     
    var vertexShader = this.vs.resource;
 
    // Определение шейдера, используемое для создания нового.
    var shaderDefinition = {
        attributes: {           
            aPosition: pc.gfx.SEMANTIC_POSITION,
            aUv0: pc.SEMANTIC_TEXCOORD0,
        },
        vshader: vertexShader,
        fshader: fragmentShader
    };
     
    // Создать шейдер из определения
    this.shader = new pc.Shader(gd, shaderDefinition);
     
    // Применить шейдер к этому материалу 
    material.setShader(this.shader);
     
    return material;
};

Эта функция извлекает код вершинного и фрагментного шейдера из атрибутов скрипта. Итак, давайте определим их в верхней части файла (после строки pc.createScript):

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

Теперь мы можем создать файлы шейдеров и прикрепить их к нашему сценарию. Вернитесь в редактор и создайте два новых файла шейдера: Water.frag и Water.vert. Присоедините эти шейдеры к вашему скрипту, как показано ниже.

добавление шейдеров к скрипту

Если новые атрибуты не отображаются в редакторе, нажмите кнопку Parse, чтобы обновить скрипт.

Теперь поместите этот основной шейдер в Water.frag:

void main(void)
{
    vec4 color = vec4(0.0,0.0,1.0,0.5);
    gl_FragColor = color;
}

А этот в Water.vert:

attribute vec3 aPosition;
 
uniform mat4 matrix_model;
uniform mat4 matrix_viewProjection;
 
void main(void)
{
    gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0);
}

Наконец, вернитесь к Water.js и заставьте его использовать наш новый специальный материал вместо стандартного. Так что вместо:

var material = new pc.StandardMaterial();

Сделайте:

var material = this.CreateWaterMaterial();

Теперь, если вы запустите игру, плоскость должна быть синего цвета.

синяя плоскость

Горячее обновление

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

Раскомментирование функции swap в любом файле скрипта (например, Water.is) включает горячее обновление. Мы увидим, как использовать это позже, чтобы поддерживать состояние, даже когда мы обновляем код в режиме реального времени. Но сейчас мы просто хотим повторно применить шейдеры, как только обнаружим изменение. Шейдеры компилируются перед запуском в WebGL, поэтому нам потребуется заново создать специальный материал, чтобы вызвать это.

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

// Код инициализации, вызывается для объекта один раз
Water.prototype.initialize = function() {
    this.GeneratePlaneMesh();
     
    // Сохранить текущие шейдеры 
    this.savedVS = this.vs.resource;
    this.savedFS = this.fs.resource;
     
};

И в update проверьте, были ли какие-либо изменения:

// Обновить код, вызываемый в каждом кадре
Water.prototype.update = function(dt) {
     
    if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){
        // Снова создайте материал, чтобы перекомпилировать шейдеры 
        var newMaterial = this.CreateWaterMaterial();
        // Примените его к модели 
        var model = this.entity.model.model;
        model.meshInstances[0].material = newMaterial;  
         
        // Сохраните новые шейдеры
        this.savedVS = this.vs.resource;
        this.savedFS = this.fs.resource;
    }
     
};

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

vec4 color = vec4(0.0,0.7,1.0,0.5);

Вершинные шейдеры

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

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

Вершинный шейдер по умолчанию возьмет положение в мировом пространстве для данной модели и вернет положение в пространстве экрана. Наша 3D сцена определяется в виде x, y и z, но ваш монитор представляет собой плоскую двухмерную плоскость, поэтому мы проецируем наш 3D мир на наш 2D экран. Эта проекция – то, о чем заботятся матрицы представлений, проекций и моделей, и она выходит за рамки данного учебного пособия, но если вы хотите точно узнать, что происходит на этом шаге, вот очень хорошее руководство.

Так что данная строка:

gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0);

Принимает aPosition как трехмерную положение в мировом пространстве для определенной вершины и преобразует его в gl_Position, которая является конечным 2D положением на экране. Префикс «a» в aPosition означает, что это значение является атрибутом. Помните, что однородная переменная – это значение, которое мы можем определить в процессоре для передачи шейдеру, а тот сохраняет одно и то же значение во всех пикселях/вершинах. Значение атрибута, с другой стороны, исходит из массива, определенного в процессоре. Вершинный шейдер вызывается один раз для каждого значения в этом массиве атрибутов.

Вы можете видеть, что эти атрибуты установлены в определении шейдера, которое мы настроили в Water.js:

var shaderDefinition = {
    attributes: {           
        aPosition: pc.gfx.SEMANTIC_POSITION,
        aUv0: pc.SEMANTIC_TEXCOORD0,
    },
    vshader: vertexShader,
    fshader: fragmentShader
};

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

Перемещение вершин

Допустим, вы хотите сжать плоскость, умножив все значения х на половину. Вы должны изменить aPosition или gl_Position?

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

attribute vec3 aPosition;
 
uniform mat4 matrix_model;
uniform mat4 matrix_viewProjection;
 
void main(void)
{
    vec3 pos = aPosition;
    pos.x *= 0.5;
     
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
}

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

attribute vec3 aPosition;
 
uniform mat4 matrix_model;
uniform mat4 matrix_viewProjection;
 
void main(void)
{
    vec3 pos = aPosition;
    //pos.x *= 0.5;
     
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
    gl_Position.x *= 0.5;
}

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

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

Задача № 2: Можете ли вы переместить всю плоскую поверхность на несколько единиц вверх (вдоль оси Y) в вершинном шейдере, не искажая ее форму?

Задача № 3: Я сказал, что gl_Position является 2D, но gl_Position.z все же существует. Можете ли вы запустить несколько тестов, чтобы определить, влияет ли это значение на что-либо, и если да, то для чего оно используется?

Добавляем время

Последнее, что нам нужно, прежде чем мы сможем создавать движущиеся волны, – это однородная переменная, которая будет использоваться как время. Объявите ее в вашем вершинном шейдере:

uniform float uTime;

Затем, чтобы передать ее нашему шейдеру, вернитесь к Water.js и определите переменную времени в инициализации:

Water.prototype.initialize = function() {
    this.time = 0; ///// Сначала определите время здесь 
     
    this.GeneratePlaneMesh();
     
    // Сохраните текущие шейдеры 
    this.savedVS = this.vs.resource;
    this.savedFS = this.fs.resource;
};

Теперь, чтобы передать это нашему шейдеру, мы используем material.setParameter. Сначала мы устанавливаем начальное значение в конце функции CreateWaterMaterial:

// Создать шейдер из определения
this.shader = new pc.Shader(gd, shaderDefinition);
 
////////////// Новая часть
material.setParameter('uTime',this.time);
this.material = material; // Сохранить ссылку на этот материал
////////////////
 
// Применить шейдер к этому материалу
material.setShader(this.shader);
 
return material;

Теперь в функции update мы можем увеличивать время и получать доступ к материалу, используя ссылку, которую мы для него создали:

this.time += 0.1; 
this.material.setParameter('uTime',this.time);

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

Water.prototype.swap = function(old) { 
    this.time = old.time;
};

Наконец, все готово. Запустите игру, чтобы убедиться в отсутствии ошибок. Теперь давайте переместим нашу плоскость по функции времени в Water.vert:

pos.y += cos(uTime)

И ваша плоскость должна двигаться вверх и вниз сейчас! Поскольку теперь у нас есть функция swap, вы снова можете обновить Water.js без необходимости перезапуска. Попробуйте увеличить или уменьшить время, чтобы проверить как это работает.

перемещение плоскости

Задача № 4: Можете ли вы переместить вершины, чтобы они выглядели как волны ниже?

волны на плоскости

В качестве подсказки я подробно рассказал о различных способах создания волн здесь. Это было в 2D, но у нас применяется та же математика. Если вы предпочитаете просто посмотреть на решение, вот суть.

Прозрачность

Задача этой части сделать поверхность воды прозрачной.

Вы могли заметить, что цвет, который мы возвращаем в Water.frag, имеет значение прозрачности 0.5, но поверхность по-прежнему полностью непрозрачна. Прозрачность во многих отношениях остается открытой проблемой в компьютерной графике. Один из дешевых способов добиться ее – использовать смешивание.

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

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

Чтобы заставить прозрачность работать так, как мы ожидаем, нам надо, чтобы комбинированный цвет результата был source, умноженным на альфу, плюс destination, умноженный на единицу минус альфа. Другими словами, если альфа равна 0.4, окончательный цвет должен быть:

finalColor = source * 0.4 + destination * 0.6;

В PlayCanvas опция pc.BLEND_NORMAL делает именно это.

Чтобы включить это, просто установите свойство для материала внутри CreateWaterMaterial:

material.blendType = pc.BLEND_NORMAL;

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

полупрозрачная вода

Мы можем исправить это, используя alpha to coverage, который является методом множественной выборки для достижения прозрачности вместо смешивания:

//material.blendType = pc.BLEND_NORMAL;
material.alphaToCoverage = true;

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

Заключение

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

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

Исходный код

Здесь вы можете найти законченный и размещенный проект PlayCanvas. В этом хранилище также доступен порт Three.js.

Источник: https://gamedevelopment.tutsplus.com/tutorials/creating-toon-water-for-the-web-part-1–cms-30447

Понравилась статья? Поделиться с друзьями:
Автор natalya
Переводит для Вас самые интересные статьи про разработку игр. По образованию физик-программист. Техническими переводами начала подрабатывать еще на старших курсах и постепенно это переросло в основное занятие. Интересуется гуманитарными технологиями, пробует себя в журналистике.

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

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