[Перевод] Создаём эффект распространения цвета в Unity

iai5inmdx06iz81yoz8pu_2oyui.gif


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

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


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

43b2836ad62ee59df7efdd98494d0140.png


Для этого эффекта я использовала новый пакет 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);


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

596217fc5e9caed9176480f1f9eb2bb1.png



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

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

Обычно чтобы перейти из одного пространства координат в другое необходима матрица, задающая преобразование из пространства координат 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);


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

b106b65cc3d1441aacd5042ee21b25a6.png


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

  • Убедитесь, что у камеры включен рендеринг текстуры глубин.
  • Убедитесь, что у камеры включено 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;
}


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

a914f2b787501e385ef27746865429bd.png


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

f9edeb7cea30fb33b72705a568264638.png


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

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


Теперь, когда у нас есть позиции в мировом пространстве, можно отрисовать в сцене круг цвета! Нам нужно задать радиус, в пределах которого эффект будет отрисовывать цвет. За его пределами эффект будет отрисовывать картинку в градациях серого. Чтобы задать его, необходимо настроить значения для радиуса эффекта (_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);
}


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

9836befb050f5a354b1eb4e6faee50d5.png



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

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


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

_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;

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


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

ecb3bc76112c56270745db56f2272e7c.gif


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


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

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

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


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

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


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

51fb77f570eb4ba77fa4e8eaa8cadb20.gif



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

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

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

© Habrahabr.ru