[Перевод] Создаем 2D-порталы с помощью шейдеров

В этой статье я расскажу о том, как достичь вот такого эффекта:

56279b4705ab4342806d29058661202c.gif

По сути, шейдер, о котором пойдет речь, работает как пост-эффект для камеры или встроенные фильтры blur и vignette в Unity. Он принимает входное изображение (точнее, RenderTexture) и выводит его с наложенными эффектами.

Все началось с игры для тридцатого гейм-джема Ludum Dare на тему Connected Worlds (Объединенные миры). Задумка была следующей: два персонажа находятся по разные стороны экрана, разделенного на две одинаковых части, и посылают друг другу сигналы. Многие игроки не могли разобраться в этой механике, поэтому я слегка изменил ее.

Я сделал так, чтобы, попадая в портал, сигнал материализовывался в параллельном мире — на другой части экрана. При этом для работы портала я придумал несколько вариантов, например, чтобы при попадании в него персонажи менялись местами. Но все это было непонятно и еще больше сбивало с толку.

Я долго ломал голову над этой проблемой, но настраивать каждый движущийся объект было бы слишком сложно. Тогда я решил, что для этой цели нужно написать шейдер.
По сути, шейдер, о котором пойдет речь, работает как пост-эффект для камеры или встроенные фильтры blur и vignette в Unity. Он принимает входное изображение (точнее, RenderTexture) и выводит его с наложенными эффектами.

1. Настраиваем шейдер и пост-эффекты

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

083d6a5b0bb64eb5994fc9856ce4e70d.png

Важнее всего изменить параметр Clear Flags (чтобы при рендеринге экран не обновлялся), переключить камеру в ортографический режим и задать значение глубины выше, чем для других камер (чтобы поставить камеру последней в очереди прорисовки). Затем пишем новый скрипт (PortalEffect.cs) с таким исходным кодом:

using UnityEngine;
using UnityStandardAssets.ImageEffects;
    [ExecuteInEditMode]
    [RequireComponent(typeof (Camera))]
    public class PortalEffect : PostEffectsBase
    {
        private Material portalMaterial;
        public Shader PortalShader = null;
        public override bool CheckResources()
        {
            CheckSupport(false);
            portalMaterial = CheckShaderAndCreateMaterial(PortalShader, portalMaterial);
            if (!isSupported)
                ReportAutoDisable();
            return isSupported;
        }

        public void OnDisable()
        {
            if (portalMaterial)
                DestroyImmediate(portalMaterial);
        }
        public void OnRenderImage(RenderTexture source, RenderTexture destination)
        {
            if (!CheckResources() || portalMaterial == null)
            {
                Graphics.Blit(source, destination);
                return;
            }
            Graphics.Blit(source, destination, portalMaterial);
        }
}

Теперь создаем новый шейдер PortalShader.shader со следующим кодом:

Shader "VividHelix/PortalShader" {
    Properties {
  _MainTex ("Base (RGB)", 2D) = "white" {}
 }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            uniform sampler2D _MainTex;
            
            struct vertOut {
                float4 pos:SV_POSITION;
            };
            vertOut vert(appdata_base v) {
                vertOut o;
                o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
                return o;
            }
            fixed4 frag(vertOut i) : SV_Target {
                return fixed4(.5,.5,.5,.1);
            }
            ENDCG
        }
    }
}

Создав шейдер, не забудьте задать его в свойстве PortalShader скрипта PortalEffect.
Вот так выглядит экран до активации эффекта:

6131b2fe406d4575afa10bf6928da88e.png

А так — после активации:

7d1016df3f1947a1b56edc791489eb99.png

Серый цвет появляется из-за строки fixed4(.5,.5,.5,.1) и состоит из 50% красного, зеленого, синего и альфы со значением 1.

2. Добавляем UV-координаты

Теперь добавим в шейдер UV-координаты. Их значения могут варьироваться в диапазоне от 0 до 1. Проще всего представить, что этот эффект накладывается на четырехугольник, выполненный по размеру экрана, с текстурой, прорисованной предыдущими камерами.

Следующий фрагмент кода:

struct vertOut {
    float4 pos:SV_POSITION;
    float4 uv:TEXCOORD0;
};
vertOut vert(appdata_base v) {
    vertOut o;
    o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
    o.uv = v.texcoord;
    return o;
}
fixed4 frag(vertOut i) : SV_Target {
    return tex2D(_MainTex, 1-i.uv);
}

Таким образом, мы дважды переворачиваем изображение по вертикали и горизонтали, что соответствует повороту на 180 градусов:

47ce8fd3790d419fa879fc02d2142afe.png

Обратите внимание на кусок 1-i.uv. Если сократить его до i.uv, мы получим так называемый «идентичный» эффект, который оставляет исходное изображение без изменений. Строка return tex2D(_MainTex, float2(1-i.uv.x,i.uv.y)); просто перевернет изображение по горизонтали (слева направо):

ef710f0a45bc46e7b0784eb55ef68fd4.png

3. Переносим область экрана

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

fixed4 frag(vertOut i) : SV_Target {
    float2 newUV = float2(i.uv.x, i.uv.y);
    if (i.uv.x < .25){
        newUV.x = newUV.x + .5;
    }
    return tex2D(_MainTex, newUV);
}

b6c494895f3940a9823e54d25e2610e5.png

На скриншоте вы можете увидеть, как участок на левой части экрана скопирован из правой. Размер этого участка можно отрегулировать, изменив значение .25. Мы также добавляем .5, чтобы изображение переместилось на противоположную часть экрана — с 0–0.25 до 0.5–0.75 на оси x.

4. Переносим круговую область

Чтобы аналогичным образом перенести круговую область, добавим функцию расстояния:

if (distance(i.uv.xy, float2(.25,.75)) < .1){
    newUV.x = newUV.x + .5;
}

80a7ca541c6a404689b977a0edc6e9bb.png

Как вы видите, вместо круга у нас получился овал. Проблема в том, что ширина и высота экрана неодинаковые (мы рассчитываем расстояние в диапазоне 0–1). Высота овала равна 20% от высоты экрана, а ширина — 20% от его ширины (исходя из значения радиуса .1 или 10%).

5. Переносим круговую область заново

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

fixed4 frag(vertOut i) : SV_Target {
    float2 scrPos = float2(i.uv.x * _ScreenParams.x, i.uv.y * _ScreenParams.y);
    if (distance(scrPos, float2(.25 * _ScreenParams.x,.75 * _ScreenParams.y)) < 50){
        scrPos.x = scrPos.x + _ScreenParams.x/2;
    }
    return tex2D(_MainTex, float2(scrPos.x/_ScreenParams.x, scrPos.y/_ScreenParams.y));
}

1a856577325e4de29fb954c149efdf7c.png

6. Меняем области местами

Чтобы завершить двойную замену, нам остается перенести аналогичную область в правую половину экрана:

if (distance(scrPos, float2(.25 * _ScreenParams.x,.75 * _ScreenParams.y)) < 50){
    scrPos.x = scrPos.x + _ScreenParams.x/2;
}else if (distance(scrPos, float2(.75 * _ScreenParams.x,.75 * _ScreenParams.y)) < 50){
    scrPos.x = scrPos.x - _ScreenParams.x/2;
}

Вот, что должно получиться:

4acbc87c781745f7a3979110f3bd418b.png

7. Добавляем размытые грани

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

Сначала все просто:


float lerpFactor=0;
if (distance(scrPos, float2(.25 * _ScreenParams.x,.75 * _ScreenParams.y)) < 50){
    scrPos.x = scrPos.x + _ScreenParams.x/2;
    lerpFactor = .8;
}else if (distance(scrPos, float2(.75 * _ScreenParams.x,.75 * _ScreenParams.y)) < 50){
    scrPos.x = scrPos.x - _ScreenParams.x/2;
    lerpFactor = .8;
}
return lerp(tex2D(_MainTex, i.uv), tex2D(_MainTex, float2(scrPos.x/_ScreenParams.x, scrPos.y/_ScreenParams.y)), lerpFactor);

Этот код «размоет» края перемещенных областей, используя 80% (что соответствует 0.8) перемещенных пикселов:

9b100995c43e4940beb3edd559f440fb.png

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

float lerpFactor=0;
float2 leftPos = float2(.25 * _ScreenParams.x,.75 * _ScreenParams.y);
if (distance(scrPos, leftPos) < 50){
    lerpFactor = (50-distance(scrPos, leftPos))/50;
    scrPos.x = scrPos.x + _ScreenParams.x/2;
}   
return lerp(tex2D(_MainTex, i.uv), tex2D(_MainTex, float2(scrPos.x/_ScreenParams.x, scrPos.y/_ScreenParams.y)), lerpFactor);


Как вы видите, это работает, но требует дополнительной настройки:

5dddd00e527149408882335793d59d3a.png

8. Размытие граней с эффектом виньетирования

Для решения этой проблемы я предлагаю пойти обходным путем. Допустим, мы хотим размыть только внешнюю границу с толщиной 15. Это значит, что для расстояний 35 и менее коэффициент линейной интерполяции должен быть равен 1, а для расстояния 50 — этот коэффициент должен быть равен нулю. В ветви if расстояние указано в диапазоне от 0 до 50. Итак, чтобы вывести конечную формулу, составим небольшую табличку:

27fe353ee1154677bdec34732ed625b2.png

Функция saturate равна clamp (0,1) (преобразуя отрицательные значения в 0).
Используя конечную формулу lerpFactor = 1-saturate((distance(scrPos, leftPos)-35)/15), мы получаем такой результат:

ef710f0a45bc46e7b0784eb55ef68fd4.png

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

float2 leftPos = float2(.25 * _ScreenParams.x,.75 * _ScreenParams.y);
float2 rightPos = float2(.75 * _ScreenParams.x,.75 * _ScreenParams.y);
if (distance(scrPos, leftPos) < 50){
    lerpFactor = 1-saturate((distance(scrPos, leftPos)-35)/15);
    scrPos.x = scrPos.x + _ScreenParams.x/2;
} else if (distance(scrPos, rightPos) < 50){
    lerpFactor = 1-saturate((distance(scrPos, rightPos)-35)/15);
    scrPos.x = scrPos.x - _ScreenParams.x/2;
}

4acbc87c781745f7a3979110f3bd418b.png

9. Настраиваем параметры шейдера

Наш шейдер почти готов, но с hardcoded-значениями от него мало толку. Мы можем извлечь их в параметры шейдера и изменить с помощью кода.

После извлечения конечный код шейдера выглядит так:

Shader "VividHelix/PortalShader" {
    Properties {
  _MainTex ("Base (RGB)", 2D) = "white" {}
        _Radius ("Radius", Range (10,200)) = 50
  _FallOffRadius ("FallOffRadius", Range (0,40)) = 20
        _RelativePortals ("RelativePortals", Vector) = (.25,.25,.75,.75)
 }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            uniform sampler2D _MainTex;
            uniform half _Radius;
            uniform half _FallOffRadius;
            uniform half4 _RelativePortals;
            
            struct vertOut {
                float4 pos:SV_POSITION;
                float4 uv:TEXCOORD0;
            };

            vertOut vert(appdata_base v) {
                vertOut o;
                o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
                o.uv = v.texcoord;
                return o;
            }

            fixed4 frag(vertOut i) : SV_Target {
                float2 scrPos = float2(i.uv.x * _ScreenParams.x, i.uv.y * _ScreenParams.y);
                float lerpFactor=0;
                float2 leftPos = float2(_RelativePortals.x * _ScreenParams.x,_RelativePortals.y * _ScreenParams.y);
                float2 rightPos = float2(_RelativePortals.z * _ScreenParams.x,_RelativePortals.w * _ScreenParams.y);
                if (distance(scrPos, leftPos) < _Radius){
                    lerpFactor = 1-saturate((distance(scrPos, leftPos) - (_Radius-_FallOffRadius)) / _FallOffRadius);
                    scrPos.x = scrPos.x + rightPos.x - leftPos.x;
                    scrPos.y = scrPos.y + rightPos.y - leftPos.y;
                } else if (distance(scrPos, rightPos) < _Radius){
                    lerpFactor = 1-saturate((distance(scrPos, rightPos)- (_Radius-_FallOffRadius)) / _FallOffRadius);
                    scrPos.x = scrPos.x + leftPos.x - rightPos.x;
                    scrPos.y = scrPos.y + leftPos.y - rightPos.y;
                }
                return lerp(tex2D(_MainTex, i.uv), tex2D(_MainTex, float2(scrPos.x/_ScreenParams.x, scrPos.y/_ScreenParams.y)), lerpFactor);
            }
            ENDCG
        }
    }
}

Стандартные (асимметричные) значения дают нам такой результат:

1200491f8c0d4afea789a59333b136ea.png

В нашем случае параметры шейдера можно задать в PortalEffect.cs:



public void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    if (!CheckResources() || portalMaterial == null)
    {
        Graphics.Blit(source, destination);
        return;
    }

    portalMaterial.SetFloat("_Radius", Radius);
    portalMaterial.SetFloat("_FallOffRadius", FallOffRadius);
    portalMaterial.SetVector("_RelativePortals", new Vector4(.2f, .6f, .7f, .6f)); 
    Graphics.Blit(source, destination, portalMaterial);
}  

10. Последние штрихи

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

3f98502588a74ddd9cde93f2ee6476e2.gif
6f7e4b055cf04bdd9a924a8f99b58c64.gif
9b95185d953e4d51b37bc31111c89b55.gif

Кардинально изменив стиль игры, я использовал шейдер «walls on fire» (горящие стены) для рендеринга обычных круглых спрайтов вокруг порталов. Учитывая то, что процесс рендеринга происходит до того, как порталы меняются местами, этот эффект выглядит довольно круто:

687a32f9599f4553b8bdf945607890c8.gif
7c8bdb8c015f4071b79a553265cedbce.gif
a371d3b61105489fbff293e96d030729.gif

11. Конечный результат

Вот еще несколько гифок, демонстрирующих конечный результат в действии:

7b0c63c622f94ea3a7ccb219ac2eb4be.gif
56279b4705ab4342806d29058661202c.gif

© Habrahabr.ru