Продвинутый Three.js: шейдерные материалы и постобработка

  • Tutorial


В сети довольно много введений в основы работы с Three.js, но можно заметить нехватку материалов по более продвинутым темам. И одна из этих тем – это объединение шейдеров и сцены с трехмерными моделями. В глазах многих начинающих разработчиков это как будто несовместимые вещи из разных миров. Сегодня на простом примере “плазменной сферы” мы посмотрим, что такое ShaderMaterial и с чем его едят, а также что такое EffectComposer и как можно быстро сделать постобработку для отрендеренной сцены.


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



Но приступим…


ShaderMaterial – что это?


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


Именно эта мысль составляет основу для шейдерного материала. Создавая материал для трехмерного объекта мы вместо текстуры для него указывам шейдеры. В базовом виде это выглядит примерно так:


const shaderMaterial = new THREE.ShaderMaterial({
    uniforms: {
        // ...
    },
    vertexShader: ‘...’,
    fragmentShader: ‘...’
});

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



В качестве материала для сферы будем использовать ShaderMaterial:


const geometry = new THREE.SphereBufferGeometry(30, 64, 64);

const shaderMaterial = new THREE.ShaderMaterial({
    uniforms: {
        // . . .
    },
    vertexShader:   document.getElementById('sphere-vertex-shader').textContent,
    fragmentShader: document.getElementById('sphere-fragment-shader').textContent
});

const sphere = new THREE.Mesh(geometry, shaderMaterial);

SCENE.add(sphere);

Вершинный шейдер будет нейтральным:


void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Обратите внимание, что Three.js передает свои uniform-переменные. Нам не обязательно что-либо делать, они подразумеваются. В себе они содержат всевозможные матрицы, к которым мы и так имеем доступ из JS, а также положение камеры. Представьте, что в начале самих шейдеров вставляется что-то такое:


// = object.matrixWorld
uniform mat4 modelMatrix;

// = camera.matrixWorldInverse * object.matrixWorld
uniform mat4 modelViewMatrix;

// = camera.projectionMatrix
uniform mat4 projectionMatrix;

// = camera.matrixWorldInverse
uniform mat4 viewMatrix;

// = inverse transpose of modelViewMatrix
uniform mat3 normalMatrix;

// = camera position in world space
uniform vec3 cameraPosition;

Кроме этого в вершинный шейдер передается несколько переменных-атрибутов:


attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

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


Традиционно координаты в пространстве обозначаются как (x, y, z), а координаты на плоскости текстуры как (u, v). Отсюда и название переменной. Вы часто будете его встречать в различных примерах. По идее нам нужно передать эти координаты во фрагментный шейдер, чтобы с ними там работать. Это и сделаем.


varying vec2 vUv;

void main() {
    vUv = uv;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Фрагментный шейдер для начала пусть будет чем-то таким:


#define EPSILON 0.02

varying vec2 vUv;

void main() {
    if ((fract(vUv.x * 10.0) < EPSILON)
        || (fract(vUv.y * 10.0) < EPSILON)) {
        gl_FragColor = vec4(vec3(0.0), 1.0);
    } else {
        gl_FragColor = vec4(1.0);
    }
}

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



То есть во фрагментном шейдере мы делаем плоскую текстуру, как в центре на этой иллюстрации, а Three.js потом ее натягивает на сферу. Очень удобно.



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


Ок, а что с этим можно делать?


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


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



Для начала добавим время в качестве uniform-переменной для шейдеров в нашем материале. Без времени никуда. Мы уже делали это на чистом JS, но в Three.js все так же просто. Пусть время в шейдерах будет называться uTime, а храниться будет в переменной TIME:


function updateUniforms() {
    SCENE.traverse((child) => {
        if (child instanceof THREE.Mesh
            && child.material.type === 'ShaderMaterial') {
            child.material.uniforms.uTime.value = TIME;
            child.material.needsUpdate = true;
        }
    });
}

Обновляем все при каждом вызове функции animate:


function animate() {
    requestAnimationFrame(animate);
    TIME += 0.005;
    updateUniforms();
    render();
}

Огонь


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


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


uniform float uTime;

varying vec2 vUv;

float rand(vec2);
float noise(vec2);

void main() {
    vec2 position1 = vec2(vUv.x * 4.0, vUv.y - uTime);
    vec2 position2 = vec2(vUv.x * 4.0, vUv.y - uTime * 2.0);
    vec2 position3 = vec2(vUv.x * 4.0, vUv.y - uTime * 3.0);

    float color = (
          noise(position1 * 5.0)
        + noise(position2 * 10.0)
        + noise(position3 * 15.0)) / 3.0;

    gl_FragColor = vec4(0.0, 0.0, 0.0, color - smoothstep(0.1, 1.3, vUv.y));
}

float rand(vec2 seed) {
    return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123);
}

float noise(vec2 position) {
    vec2 blockPosition = floor(position);

    float topLeftValue     = rand(blockPosition);
    float topRightValue    = rand(blockPosition + vec2(1.0, 0.0));
    float bottomLeftValue  = rand(blockPosition + vec2(0.0, 1.0));
    float bottomRightValue = rand(blockPosition + vec2(1.0, 1.0));

    vec2 computedValue = smoothstep(0.0, 1.0, fract(position));

    return mix(topLeftValue, topRightValue, computedValue.x)
        + (bottomLeftValue  - topLeftValue)  * computedValue.y * (1.0 - computedValue.x)
        + (bottomRightValue - topRightValue) * computedValue.x * computedValue.y;
}

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


В такие моменты полезно вспомнить про функцию smoothstep

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


Для того, чтобы сделать огонь более интересным, перейдем к вершинному шейдеру и немного пошаманим…


Как сделать так, чтобы пламя немного “переливалось” в пространстве? У начинающих этот вопрос может вызвать большие затруднения, несмотря на свою простоту. Я видел весьма сложные подходы к решению этого вопроса, но по сути – нам нужно плавно подвигать вершины на сфере по линиям “из ее центра”. Туда-сюда, туда-сюда. Three.js уже передал нам текущее положение вершины и нормаль – воспользуемся ими. Для “туда-сюда” сгодится какая-нибудь ограниченная функция, например синус. Можно конечно поэкспериментировать, но синус – это вариант по умолчанию.


Не знаешь, что брать – бери синус. А еще лучше сумму синусов с разной частотой.

Сдвигаем координаты по нормали на полученное значение и пересчитываем по ранее известной формуле.


uniform float uTime;

varying vec2 vUv;

void main() {
    vUv = uv;

    vec3 delta = normal * sin(position.x * position.y * uTime / 10.0);
    vec3 newPosition = position + delta;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}

То, что мы получим – это уже не совсем сфера. Это… Я даже не знаю, есть ли у этого название. Но, опять же, не забываем поиграться с коэффициентами – они на многое влияют. При создании подобных эффектов часто что-то подбирается методом проб и ошибок и очень полезно развивать в себе “математическую интуицию” — способность более-менее представлять как ведет себя та или иная функция, как она зависит от каких переменных.


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


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


Возможность что-то сделать с отрендеренным Three.js изображением – это очень полезная штука, при этом незаслуженно забываемая в многочисленных сериях уроков. Технически это реализуется следующим образом: изображение, которое дал нам рендерер отправляется в EffectComposer (пока это будет черный ящик), который что-то в себе шаманит и выдает конечное изображение на canvas. То есть после рендерера добавляется еще один модуль. В этот композер мы передаем параметры – что ему делать с полученным изображением. Один такой параметр называется pass. В некотором смысле композер работает как какой-нибудь Gulp – сам он ничего не делает, мы ему даем плагины, которые уже выполняют работу. Возможно так говорить не совсем корректно, но идея должна быть понятна.


Все, что мы будем использовать дальше, не входит в базовый состав Three.js, так что подключаем немного зависимостей и зависимостей самих зависимостей:


<script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/EffectComposer.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/RenderPass.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/ShaderPass.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/shaders/CopyShader.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/shaders/LuminosityHighPassShader.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/postprocessing/UnrealBloomPass.js'></script>

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

В базовом виде композер создается примерно так:


COMPOSER = new THREE.EffectComposer(RENDERER);
COMPOSER.setSize(window.innerWidth, window.innerHeight);

const renderPass = new THREE.RenderPass(SCENE, CAMERA);
renderPass.renderToScreen = true;

COMPOSER.addPass(renderPass);

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


function render() {
    // RENDERER.render(SCENE, CAMERA);
    COMPOSER.render(SCENE, CAMERA);
}

Такой подход с использованием RenderPass в качестве первого pass – это стандартная практика при работе с EffectComposer. Обычно нам нужно сначала получить отрендеренное изображение сцены, чтобы потом уже с ним что-то делать.


В примерах с сайта Three.js в разделе postprocessing можно найти штуку под названием UnrealBloomPass. Это портированный скрипт из движка Unreal. Он добавляет небольшое свечение, которое можно использовать для создания более красивого освещения. Часто это будет первый шаг к улучшению изображения.


const bloomPass = new THREE.UnrealBloomPass(
        new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 1, 0.1);
bloomPass.renderToScreen = true;
COMPOSER.addPass(bloomPass);

Обратите внимание: опция renderToScreen задается только последнему Pass, который мы передали в композер.


Но давайте уже посмотрим, что за свечение этот bloomPass нам дал и как это сочетается со сферой:



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


Но мы пойдем еще дальше…


Больше шейдеров богу шейдеров!



Здесь очень полезно воспользоваться console.log и посмотреть на структуру композера. В нем можно обнаружить некие элементы с названиями renderTarget1, renderTarget2 и.т.д., где номера соответствуют индексам переданных passов. И тут становится понятно, почему EffectComposer так называется. Он работает по принципу фильтров в SVG. Помните, там можно результат выполнения одних фильтров использовать в других? Вот тут то же самое – можно комбинировать эффекты.


Использовать console.log для понимания внутренней структуры объектов Three.js да и многих других библиотек очень полезно. Чаще пользуйтесь этим подходом, чтобы лучше понимать что есть что.

Добавим еще один pass. На этот раз это будет ShaderPass.


const shader = {
    uniforms: {
        uRender: { value: COMPOSER.renderTarget2 },
        uTime: { value: TIME }
    },
    vertexShader:   document.getElementById('postprocessing-vertex-shader').textContent,
    fragmentShader: document.getElementById('postprocessing-fragment-shader').textContent
};

const shaderPass = new THREE.ShaderPass(shader);
shaderPass.renderToScreen = true;
COMPOSER.addPass(shaderPass);

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


Наверное здесь стоит притормозить и осознать всю магию...


Дальше создаем простой вершинный шейдер. В большинстве случаев на данном этапе нам не нужно ничего делать с вершинами, только передаем координаты (u, v) во фрагментный шейдер:


varying vec2 vUv;

void main() {
    vUv = uv;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

А уж во фрагментном можем развлекаться на свой вкус и цвет. Например можем добавить легкий glitch-эффект, сделать все черно-белым и еще с яркостью / контрастностью поиграться:


uniform sampler2D uRender;
uniform float uTime;

varying vec2 vUv;

float rand(vec2); 

void main() {
    float randomValue = rand(vec2(floor(vUv.y * 7.0), uTime / 1.0));

    vec4 color;

    if (randomValue < 0.02) {
        color = texture2D(uRender, vec2(vUv.x + randomValue - 0.01, vUv.y));
    } else {
        color = texture2D(uRender, vUv);
    }

    float lightness = (color.r + color.g + color.b) / 3.0;
    color.rgb = vec3(smoothstep(0.02, 0.7, lightness));

    gl_FragColor = color;
}

float rand(vec2 seed) {
    return fract(sin(dot(seed, vec2(12.9898,78.233))) * 43758.5453123);
}

Посмотрим на результат:



Как можно видеть, на сферу наложились фильтры. Она все еще трехмерная, ничего не сломалось, но на канвасе мы имеем обработанное изображение.


Заключение


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

  • +17
  • 3,9k
  • 2
Поделиться публикацией

Комментарии 2

    0
    Агонь!
      –2

      Годная статья, как раз вовремя, еще бы с нормал-мапами. Кстати, добавляйтесь в группу в тележку: webgl_ru, если вы там сидите.

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

      Самое читаемое