Pull to refresh

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

Reading time11 min
Views9.1K
Original author: Omar Shehata
В своём туториале «Создание шейдеров» я в основном рассматривал фрагментные шейдеры, которых достаточно для реализации любых 2D-эффектов и примеров на ShaderToy. Но существует целая категория техник, требующих использования вершинных шейдеров. В этом туториале я расскажу о создании стилизованного мультяшного шейдера воды и познакомлю вас с вершинными шейдерами (vertex shaders). Также я расскажу о буфере глубин и о том, как использовать его для получения дополнительной информации о сцене и для создания линий морской пены.

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


Этот эффект состоит из следующих элементов:

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

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

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

Настройка среды


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

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


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


Отличным ресурсом для поиска 3D-моделей для веба является проект Google Poly. Модель лодки я взял оттуда. Скачав и распаковав архив, вы найдёте в нём файлы .obj и .png.

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


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


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

Летающая по орбите камера


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

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

Подсказка: чтобы упорядочить проект, можно создавать в окне Assets папки. Я положил эти два скрипта камеры в папку 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.js), мы включим горячую перезагрузку. Позже мы увидим, как пользоваться этим, чтобы сохранять состояние даже при обновлении кода в реальном времени. Но пока мы просто хотим заново применять шейдеры после внесения изменений. Перед запуском в WebGL шейдеры компилируются, поэтому для выполнения этого нам нужно заново создать наш материал.

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

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

А в update мы проверяем, произошли ли какие-нибудь изменения:

// код 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 как позицию в 3D-мире конкретной вершины и преобразует её в gl_Position, то есть в конечную позицию на 2D-экране. Префикс «a» в aPosition обозначает, что это значение является атрибутом. Не забывайте, что переменная uniform — это значение, которое мы можем определить в ЦП и передавать шейдеру. Оно сохраняет одно и то же значение для всех пикселей/вершин. С другой стороны, значение атрибута получается из задаваемого ЦП массива. Вершинный шейдер вызывается для каждого значения этого массива атрибутов.

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

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

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

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


Допустим, мы хотим сжать всю плоскость, умножив все значения x на 0,5. Нам нужно изменить 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 двухмерна, но gl_Position.z тоже существует. Можете ли вы выполнить проверки, чтобы понять, влияет ли это значение на что-нибудь, и если это так, то для чего оно используется?

Добавление времени


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

uniform float uTime;

Теперь, чтобы передать её в шейдер, вернёмся в Water.js и в initialize определим переменную времени:

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, но математические вычисления применимы и к нашему случаю. Если вы просто хотите посмотреть решение, то вот gist.

Просвечиваемость


Цель этого раздела — создание просвечивающей поверхности воды.

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

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

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

Чтобы альфа-канал работал в соответствии с нашими ожиданиями, мы хотим, чтобы комбинированный цвет результата был источником, умноженным на альфа-канал плюс целевым пикселем, умноженным на единицу минус альфа. Другими словами, если alpha = 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.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+21
Comments0

Articles