[Перевод] Создаём эффект распространения цвета в Unity
На этот эффект меня вдохновил эпизод 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-координат экранного пространства.