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

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

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

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

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

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

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

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

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

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

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

Также убедимся, что для камеры включен параметр «Allow MSAA». Во-первых, убедимся в том, что буфер глубин действительно рендерится, нажав в Inspector на раздел камеры «Add Additional Data» и проверив, что установлен флажок «Requires Depth Texture». Если буфер глубин отрисовывается, то в отладчике кадров (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 делают это.

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

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

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

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

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

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

Наконец, мы можем умножить позицию в видовом пространстве на 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-координат экранного пространства.

Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Ускоряем неускоряемое или знакомимся с SIMD

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

Kонсенсус в Exonum: как он работает

ExonumTM — это наш открытый фреймворк для создания приватных блокчейнов. Сегодня мы расскажем, как работает его алгоритм консенсуса. Изображение: Bitfury Зачем нужны алгоритмы консенсуса Прежде чем перейти к рассказу о том, как устроен алгоритм консенсуса ExonumTM, поговорим о том, зачем ...