На этот эффект меня вдохновил эпизод Powerpuff Girls. Я хотела создать эффект распространения цвета в чёрно-белом мире, но реализовать его в координатах мирового пространства, чтобы видеть, как цвет закрашивает объекты, а не просто плоско распределяется по экрану, как в мультике.

Эффект я создала в новом Lightweight Rendering Pipeline движка Unity, встроенном примере конвейера Scriptable Rendering Pipeline. Все концепции применимы и к другим конвейерам, но некоторые встроенные функции или матрицы могут иметь другие названия. Также я воспользовалась новым стеком постобработки, но в туториале опущу подробное описание его настройки, потому что о ней достаточно хорошо рассказывается в других руководствах, например в этом видео.



Эффект постобработки в градациях серого


Просто для справки — вот как выглядит сцена без эффектов постобработки.


Для этого эффекта я использовала новый пакет Unity 2018 Post-Processing, который можно скачать в менеджере пакетов. Если вы не знаете, как им пользоваться, то рекомендую этот туториал.

Я написала собственный эффект, расширив написанные на C# классы PostProcessingEffectSettings и PostProcessEffectRenderer, исходный код которых можно увидеть здесь. На самом деле я не делала ничего особо интересного с этими эффектами на стороне ЦП (в коде на C#) кроме того, что добавила группу общих свойств в Inspector, поэтому не буду объяснять в туториале, как это делается. Надеюсь, мой код говорит сам за себя.

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

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

Почему мы используем скалярное произведение? Не забывайте, что скалярные произведения вычисляются следующим образом:

dot(a, b) = ax * bx + ay * by + az * bz

В данном случае мы умножаем каждый канал значения цвета на вес. Затем мы складываем эти произведения, чтобы свести их к единому скалярному значению. Когда цвет RGB имеет одинаковые значения в каналах R, G и B, цвет становится серым.

Вот как выглядит код шейдера:

float4 fullColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.screenPos);
float3 weight = float3(0.299, 0.587, 0.114);
float luminance = dot(fullColor.rgb, weight);
float3 greyscale = luminance.xxx;

return float4(greyscale, 1.0);

Если базовый шейдер настроен правильно, то эффект постобработки должен окрасить весь экран в градации серого.




Отрисовка эффекта цвета в мировом пространстве


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

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

Обычно чтобы перейти из одного пространства координат в другое необходима матрица, задающая преобразование из пространства координат A в пространство B. Чтобы перейти из A в B, мы умножаем вектор в пространстве координат A на эту матрицу преобразований. В нашем случае мы выполним следующий переход: пространство усечённых координат (clip space) -> видовое пространство (view space) -> мировое пространство (world space). То есть нам нужна матрица clip-to-view-space и матрица view-to-world-space, которые предоставляет Unity.

Однако в предоставляемых Unity координатах пространства усечённых координат отсутствует значение z, определяющее глубину пикселя, или расстояние до камеры. Нам нужно это значение, чтобы перейти из пространства усечённых координат в видовое пространство. Давайте начнём с этого!

Получение значения буфера глубин


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

Во-первых, убедимся в том, что буфер глубин действительно рендерится, нажав в Inspector на раздел камеры «Add Additional Data» и проверив, что установлен флажок «Requires Depth Texture». Также убедимся, что для камеры включен параметр «Allow MSAA». Я не знаю, почему для работы эффекта необходимо поставить этот флажок, но так оно и есть. Если буфер глубин отрисовывается, то в отладчике кадров (frame debugger) вы должны увидеть этап «Depth Prepass».

Создадим в файле hlsl сэмплер _CameraDepthTexture

TEXTURE2D_SAMPLER2D(_CameraDepthTexture, sampler_CameraDepthTexture);

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

float3 GetWorldFromViewPosition (VertexOutput i) {
  float z = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, i.screenPos).r;
  return z.xxx;
}

Во фрагментном шейдере отрисуем значение сэмпла текстуры глубин.

float3 depth = GetWorldFromViewPosition(i);
return float4(depth, 1.0);

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


Вот какие шаги можно предпринять, если у вас возникнут проблемы:

  • Убедитесь, что у камеры включен рендеринг текстуры глубин.
  • Убедитесь, что у камеры включено MSAA.
  • Попробуйте изменять ближнюю и дальнюю плоскости камеры .
  • Убедитесь, что объекты, которые вы ожидаете увидеть в буфере глубин, используют шейдер с проходом глубин (depth pass). Это гарантирует, что объект выполняет отрисовку в буфер глубин. Все стандартные шейдеры в LWRP делают это.

Получение значения в мировом пространстве


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

Учтите, что необходимые для этих операций матрицы преобразований уже имеются в библиотеке SRP. Однако они содержатся в библиотеке C# движка Unity, поэтому я вставила их в шейдер в функции Render скрипта ColorSpreadRenderer:

sheet.properties.SetMatrix("unity_ViewToWorldMatrix", context.camera.cameraToWorldMatrix);
sheet.properties.SetMatrix("unity_InverseProjectionMatrix", projectionMatrix.inverse);

Теперь давайте расширим нашу функцию GetWorldFromViewPosition.

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

Наконец, мы можем умножить позицию в видовом пространстве на ViewToWorldMatrix, чтобы получить позицию в мировом пространстве.

float3 GetWorldFromViewPosition (VertexOutput i) {
  // получаем значение глубины
  float z = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, i.screenPos).r;

  // получаем позицию в видовом пространстве
  float4 result = mul(unity_InverseProjectionMatrix, float4(2*i.screenPos-1.0, z, 1.0));
  float3 viewPos = result.xyz / result.w;

  // получаем позицию в мировом пространстве
  float3 worldPos = mul(unity_ViewToWorldMatrix, float4(viewPos, 1.0));
  return worldPos;
}

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


(Заметьте, что значения в мировом пространстве гораздо больше 1.0, поэтому не волнуйтесь о том, чтобы эти цвета имели какой-то смысл; вместо этого просто убедитесь, что результаты одинаковы для «верного» и «вычисленного» ответов.) Далее вернём на тестовый объект обычный материал (а не материал теста мирового пространства), а затем снова включим эффект постобработки. Мои результаты выглядят так:


Это полностью похоже на написанный мной тестовый шейдер, то есть вычисления мирового пространства скорее всего верны!

Отрисовка круга в мировом пространстве


Теперь, когда у нас есть позиции в мировом пространстве, можно отрисовать в сцене круг цвета! Нам нужно задать радиус, в пределах которого эффект будет отрисовывать цвет. За его пределами эффект будет отрисовывать картинку в градациях серого. Чтобы задать его, необходимо настроить значения для радиуса эффекта (_MaxSize) и центра круга (_Center). Я задала эти значения в классе C# ColorSpread, чтобы они были видны в инспекторе. Давайте расширим наш фрагментный шейдер, заставив его проверять, находится ли текущий пиксель внутри радиуса окружности:

float4 Frag(VertexOutput i) : SV_Target
{
  float3 worldPos = GetWorldFromViewPosition(i);

  // проверяем, находится ли расстояние в пределах макс. радиуса
  // выбираем градации серого, если за пределами, полный цвет, если внутри
  float dist = distance(_Center, worldPos);
  float blend = dist <= _MaxSize? 0 : 1;

  // обычный цвет
  float4 fullColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.screenPos);

  // градации серого
  float luminance = dot(fullColor.rgb, float3(0.2126729, 0.7151522, 0.0721750));
  float3 greyscale = luminance.xxx;

  // решает, выбрать ли цвет или градации серого
  float3 color = (1-blend)*fullColor + blend*greyscale;

  return float4(color, 1.0);
}

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




Добавление спецэффектов


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

Анимация увеличения круга


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

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

_GrowthSpeed задаёт скорость увеличения круга.

// вычисляем радиус на основании времени начала анимации и текущего времени
float timeElapsed = _Time.y - _StartTime;
float effectRadius = min(timeElapsed * _GrowthSpeed, _MaxSize);

// ограничиваем радиус, чтобы не получить странных артефактов
effectRadius = clamp(effectRadius, 0, _MaxSize);

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

// проверяем, находится ли расстояние в пределах текущего радиуса эффекта
// выбираем градации серого, если за пределами, полный цвет, если внутри
float dist = distance(_Center, worldPos);
float blend = dist <= effectRadius? 0 : 1;

// вся остальная работа с цветом...

Вот как должен выглядеть результат:


Добавление к радиусу шума


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

Для начала нам нужно сэмплировать текстуру в мировом пространстве. UV-координаты i.screenPos находятся в экранном пространстве, и если мы выполним сэмплирование на их основе, то форма эффекта будет перемещаться вместе с камерой; поэтому давайте воспользуемся координатами в мировом пространстве. Я добавила параметр _NoiseTexScale для управления масштабом сэмпла текстуры шума, потому что координаты в мировом пространстве довольно велики.

// получаем для текстуры шума позицию сэмплирования в мировом пространстве
float2 worldUV = worldPos.xz;
worldUV *= _NoiseTexScale;

Теперь давайте сэмплируем текстуру шума и прибавим это значение к радиусу эффекта. Я использовала масштаб _NoiseSize для большего контроля над размером шума.

// прибавляем шум к радиусу
float noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, worldUV).r;
effectRadius -= noise * _NoiseSize;

Вот как выглядят результаты после некоторой настройки:




В завершение


Следить за обновлениями туториалов можно в моём Twitter, а в Twitch я провожу стримы по кодингу! (Также время от времени я стримлю игры, поэтому не удивляйтесь, если увидите, что я сижу в пижаме и играю в Kingdom Hearts 3.)

Благодарности:

  • Все модели проекта взяты в этом LowPoly Environment Pack из магазина Unity.
  • Эффект ScreenSpaceReflections из движка Unity очень помог мне разобраться в том, как получить трёхмерную позицию в видовом пространстве из двухмерных UV-координат экранного пространства.

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


  1. Alex_ME
    19.01.2019 13:42

    Интересно, а можно ли передать мировые координаты вершины из вершинного шейдера во фрагментный? По-идее, можно любые данные передавать, а они будут интерполированы между вершинами.


    1. FDsagizi
      20.01.2019 00:14

      В пост процессе у вас всего 2 треугольника чтобы закрасить весь экран. А в растеризации обычных объектов, где много треугольников — да можно.


    1. allcreater
      20.01.2019 00:16

      Конечно, можно! Более того, их и так весьма часто передают.
      Но в данном случае это не поможет.

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

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


      1. Alex_ME
        20.01.2019 00:29

        Да нет, спасибо за разъяснения. У меня очень-очень поверхностные знания об OpenGL. Я знаю некие основы, делал некие тестовые вещи, но с трудом представляю, как всё это компонуется в сложные сцены, что мы видим в играх.

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


        1. allcreater
          20.01.2019 15:01

          Обычно да, после draw call видеокарта начинает трудиться и отрисовывать переданные объекты, от трансформации вершин до растериазации. Однако, вызовы асинхронные, поэтому написав где-то в программе glDrawArrays(или что-то в этом духе), мы можем быть уверены только в том, что объекты будут нарисованы в порядке вызовов draw call'ов, а так же в том, что с этого момента наш объект уже в очереди.
          По идее, когда графический движок (или откуда мы там дёргаем API) успевает скормить видеокарте новую порцию данных прямо перед моментом завершения отрисовки, никто не простаивает.

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


  1. dimonoid
    20.01.2019 05:24

    _


  1. dimonoid
    20.01.2019 05:33

    На assassin's creed (3, 4, и Odissey как минимум, в других не знаю) похоже, только там ещё и вершинный шейдер используется при загрузке новой локации.


  1. FDsagizi
    20.01.2019 08:25

    Неужели, даже в новом LightWaight чтобы получить текстуру глубины, сцену нужно отрисовывать 2 раз? И это в 2019 году!!!


  1. yurec_bond
    20.01.2019 20:41

    Здесь про немного другой эффект но наверняка можно найти полезные идеи
    www.youtube.com/watch?v=OKoNp2RqE9A