Анимированный объём при помощи встроенного шейдера

Конечно же, после смерти Flash веб не превратился в простую, скучную плоскость. К старту курса по фронтенду показываем, как при помощи Three.js и технологии WebGL создать магический шар с анимацией внутри объёма. Автор статьи не только опубликовал код на CodePen для удобной демонстрации, но и добавил на страницу ползунки, чтобы вы могли экспериментировать с шаром и сразу видеть результат. В конце вы найдёте ссылку на исходный код на Github и демонстрацию не на CodePen.


В апреле 2019 года Гарри Алисавакис написал отличный пост об эффекте "магических шариков", которым поделился в Твиттере. Сначала посмотрите эту статью, чтобы увидеть эффект в целом. Статья содержит краткое описание техники, но цель руководства — показать код в рамках Three.js. При этом метод немного упрощён. Это руководство — промежуточное знакомство с Three.js и GLSL.

Обзор

Вначале рекомендую прочитать пост Гарри: он представляет полезные наглядные примеры, а в списке ниже — их суть:

  • Поиск тектуры смещается в зависимости от направления камеры, добавляя материалу глубину. 

  • На каждой итерации применяются "срезы" карты высот по глубине для динамики объёма.

  • Помск текстуры перемещается прокручиванием шума, так движения становятся волнообразными.

В статье Гарри было несколько не совсем понятных частей, наверное, из-за разницы возможностей в Unity и Three.js:

  • Первый момент — переход от параллаксного отображения на плоскости к отображению на сфере.

  • Второй — как получить касательные к вершинам для преобразования в касательное пространство. Я не был уверен, вычисляется ли шум для карты высот как код внутри шейдера или рендерится предварительно. После экспериментов я пришёл к своим выводам, но я призываю придумать ваши вариации этого метода.

Пример ниже — шаблон для приложения Three.js с жизненным циклом init и tick, с управлением цветом и картой окружения из Poly Haven для освещения.

1. Пустой шар

Магические шары сделаны из стекла, а шарики Гарри блестели. Чтобы сделать по-настоящему красивый материал, нужно написать сложный код шейдеров PBR, поэтому просто возьмём встроенный в Three.js PBR-материал и подключим к нему наши магические кусочки.

onBeforeCompile — свойство обратного вызова базового класса THREE.Material, которое позволяет применять исправления к встроенным шейдерам до компиляции в WebGL. Эта техника — хакерская, она не слишком хорошо объясняется в официальной документации, но узнать подробности о ней можно здесь.

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

Я обнаружил элегантный способ расширить встроенные материалы при помощи экспериментальных Three Node Materials. Находка заслуживает отдельного руководства, поэтому я буду придерживаться распространённого подхода с onBeforeCompile.

Хорошая база для начала — MeshStandardMaterial. В нём есть блеск и отражение окружения, благодаря которым материал будет очень похожим на стекло; также, если вы хотите добиться царапин на поверхности, есть возможность добавить карту нормалей. 

Изменить нужно только основной цвет, на который накладывается освещение; найти его легко. Фрагментный шейдер для MeshStandardMaterial определён в meshphysical_frag.glsl.js — подмножестве MeshPhysicalMaterial, поэтому они оба определены в одном файле).

Часто приходится копаться во включённых директивой #include кусках шейдера, но у нас тот редкий случай, когда переменная, которую мы хотим изменить, на виду. Вот первая строка функции main():

vec4 diffuseColor = vec4( diffuse, opacity );

Она обычно считывает данные из uniform-переменных диффузии и непрозрачности в JS-свойствах материала .color и .opacity, следующий код выполняет сложную работу по освещению.

Заменим эту строку присвоением diffuseColor, чтобы иметь возможность нанести любой рисунок на поверхность шара. Сделать это можно при помощи строковых методов JavaScript предоставленное обратному вызову onBeforeCompile поля .fragmentShader в shader.

material.onBeforeCompile = shader => {
  shader.fragmentShader = shader.fragmentShader.replace('/vec4 diffuseColor.*;/, `
    // Assign whatever you want!
    vec4 diffuseColor = vec4(1., 0., 0., 1.);
  `)
}

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

2. Объём

Теперь начинается самое сложное — применение рассеянного цвета для иллюзии объёма внутри шарика. В раннем посте Гарри о параллаксе рассказывается, как найти направление камеры в касательном пространстве и использовать его для смещения UV-координат, а на learnopengl.com и в этой архивной заметке объясняется, как этот общий принцип работает для эффектов параллакса.

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

Кроме того, сфера — не очень хорошая форма для касательных из-за теоремы о волосатом шарике [или о ёжике в Европе] (да, она существует), а функция Three.js computeTangents() демонстрировала разрывы, поэтому шейдеры придётся вычислять вручную.

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

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

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

Сначала я написал функцию для выполнения этого сферического преобразования — XYZ→UV — на основании ответов из интернета, но оказалось, что функция equirectUv в common.glsl.js делает то же самое. Мы можем использовать её повторно, если в стандартном шейдере поместим логику построения лучей после строки #include_trailing_comma.

Карта высоты

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

Чтобы решить эту проблему, создадим текстуру с помощью Blender. Один из способов сделать это — согнуть сетку высокого разрешения "Grid" в сферу с помощью двух экземпляров модификатора "Simple Deform", подключить полученные координаты текстуры "Object" к выбранному вами процедурному шейдеру, а затем выполнить emissive bake с помощью рендера Cycles. Я также добавил контурные вырезы возле полюсов и модификатор подразбиения, чтобы избежать артефактов при запекании.

Результат:

Реймаршинг (рейкастинг)

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

uniform sampler2D heightMap;
uniform vec3 colorA;
uniform vec3 colorB;
uniform float iterations;
uniform float depth;
uniform float smoothing;

/**
  * @param rayOrigin - Point on sphere
  * @param rayDir - Normalized ray direction
  * @returns Diffuse RGB color
  */
vec3 marchMarble(vec3 rayOrigin, vec3 rayDir) {
  float perIteration = 1. / float(iterations);
  vec3 deltaRay = rayDir * perIteration * depth;

  // Start at point of intersection and accumulate volume
  vec3 p = rayOrigin;
  float totalVolume = 0.;

  for (int i=0; i<iterations; ++i) {
    // Read heightmap from current spherical direction
    vec2 uv = equirectUv(p);
    float heightMapVal = texture(heightMap, uv).r;

    // Take a slice of the heightmap
    float height = length(p); // 1 at surface, 0 at core, assuming radius = 1
    float cutoff = 1. - float(i) * perIteration;
    float slice = smoothstep(cutoff, cutoff + smoothing, heightMapVal);

    // Accumulate the volume and advance the ray forward one step
    totalVolume += slice * perIteration;
    p += deltaRay;
  }
  return mix(colorA, colorB, totalVolume);
}

/**
 * We can user this later like:
 *
 * vec4 diffuseColor = vec4(marchMarble(rayOrigin, rayDir), 1.0);
 */

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

Инъекция глобальных переменных шейдера (uniform)

И последнее замечание, прежде чем увидеть плоды труда, — как мы включим все эти пользовательские uniform в наш модифицированный материал? Нельзя просто взять и приклеить материал к material.uniforms, как это делается в случае с THREE.ShaderMaterial. Хитрость в том, чтобы создать кастомный объект uniform, а затем подключить его содержимое к аргументу шейдера в onBeforeCompile. Например так:

const myUniforms = {
  foo: { value: 0 }
}

material.onBeforeCompile = shader => {
  shader.uniforms.foo = myUniforms.foo

  // ... (all your other patches)
}

Когда шейдер пытается прочитать свою ссылку shader.uniforms.foo.value, на самом деле он читает из вашего локального myUniforms.foo.value, поэтому любое изменение значений в вашем объекте uniform отражается на шейдере. Обычно, чтобы подключить все uniform сразу, я использую спред-оператор JavaScript:

const myUniforms = {
  // ...(lots of stuff)
}

material.onBeforeCompile = shader => {
  shader.uniforms = { ...shader.uniforms, ...myUniforms }

  // ... (all your other patches)
}

Собрав всё воедино, мы получим газообразный (и стеклянный) объём. Я добавил ползунки, чтобы вы могли настраивать количество итераций, сглаживание, максимальную глубину и цвета.

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

3. Волнообразное движение

Последний штрих — оживим шарик анимацией объёма. Гарри в этом посте объясняет, как он добивается оживления с помощью 2D-текстуры смещения. Однако, как и в случае с картой высот, плоская текстура смещения деформируется вблизи полюсов сферы, поэтому мы снова будем делать собственные. Вы можете использовать те же настройки Blender, что и раньше, но на этот раз давайте запечём шумовую 3D-текстуру в каналы RGB:

В функции marchMarble прочитаем текстуру с помощью той же функции equirectUv, что и раньше, отцентрируем значения, а затем добавим масштабированную версию этого вектора в позицию, используемую для поиска текстуры карты высот. Чтобы анимировать смещение, введите временную uniform и используйте её для прокрутки текстуры смещения по горизонтали. Для большего эффекта мы дважды сэмплируем карту смещения (один раз вертикально, а после вверх ногами, чтобы они никогда не совпадали идеально), прокрутим их в противоположных направлениях и сложим вместе, чтобы получить хаотичный шум. Эта стратегия используется в водных шейдерах для волн.

uniform float time;
uniform float strength;

// Lookup displacement texture
vec2 uv = equirectUv(normalize(p));
vec2 scrollX = vec2(time, 0.);
vec2 flipY = vec2(1., -1.);
vec3 displacementA = texture(displacementMap, uv + scrollX).rgb;
vec3 displacementB = texture(displacementMap, uv * flipY - scrollX).rgb;

// Center the noise
displacementA -= 0.5;
displacementB -= 0.5;

// Displace current ray position and lookup heightmap
vec3 displaced = p + strength * (displacementA + displacementB);
uv = equirectUv(normalize(displaced));
float heightMapVal = texture(heightMap, uv).r;

И вот он, ваш магический шар!

Дополнение

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

Создано при помощи узла "Волновая текстура" (Wave texture) в Blender
Создано при помощи узла "Волновая текстура" (Wave texture) в Blender

Или как насчёт карты Земли?

Попробуйте перетащить ползунок displacement (смещение) и посмотрите, как танцуют континенты!

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

И последнее

И последнее: я вернусь к статье Гарри, где он предлагает смешивать два цвета HDR. В основном это означает смешивать цвета, код RGB которых выходят за пределы типичного диапазона [0, 1]. Если мы подключим такой цвет к нашему шейдеру как есть, он создаст цветовые артефакты в пикселях, где света слишком много.

Проблема решается оборачиванием цвета в вызов toneMapping(), как в tonemapping_fragment.glsl.js, который "приглушает" цветовой диапазон. Я не смог найти определение функции, но она работает! В Pen ниже, чтобы вывести цвета за пределы диапазона [0, 1] и наблюдать, как их смешивание в HDR создаёт приятные цветовые палитры, я добавил ползунки цветового множителя.

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

Python, веб-разработка

Data Science и Machine Learning

Мобильная разработка

Java и C#

От основ — в глубину

А также:

Комментарии (1)


  1. v1000
    03.08.2021 22:17

    Xcom Enemy Unknown ностальгия.