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



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


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

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

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

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

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



Важнее всего изменить параметр 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.
Вот так выглядит экран до активации эффекта:



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



Серый цвет появляется из-за строки 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 градусов:



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



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);
}




На скриншоте вы можете увидеть, как участок на левой части экрана скопирован из правой. Размер этого участка можно отрегулировать, изменив значение .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;
}




Как вы видите, вместо круга у нас получился овал. Проблема в том, что ширина и высота экрана неодинаковые (мы рассчитываем расстояние в диапазоне 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));
}




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


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



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) перемещенных пикселей:



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

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



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



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

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



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



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

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




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
        }
    }
}


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



В нашем случае параметры шейдера можно задать в 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. Последние штрихи

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





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





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

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


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


  1. Gasparfx
    05.01.2016 00:08
    +1

    Интересный эффект, красиво выглядит. А можете проект-образец выложить, чтобы поиграться?
    Не совсем понял к чему эти сложности с расчетом круга по радиусу. Почему не использовать текстуру маску?


    1. iOrange
      05.01.2016 00:35

      Потому что шейдеры — звучит круто.
      Можно конечно придумать независимость от разрешения экрана, но это все глупости, ибо дешевле заготовить несколько текстур, а небольшой их скейлинг даже пойдет на пользу мягким границам портала.
      А так — имеем максимальную нагрузку GPU ненужной работой, нагрев телефона, разряд батареи и ЧСВ программиста.


    1. Darina_PL
      05.01.2016 11:39

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


      1. Gasparfx
        05.01.2016 12:52

        Не заметил, что это перевод, прошу прощения. И спасибо за материал. Тема визуальных эффектов и шейдеров вообще очень интересная, нужно подобных вещей.


  1. ZimM
    05.01.2016 14:16

    Реализация этого как полноэкранный эффект — это полный ппц с точки зрения производительности. Неужели нельзя было ограничиться квадом размером ровно с портал? Особенно если бы порталов было бы несколько…
    (вопрос риторический, я знаю, что это перевод)


    1. iOrange
      05.01.2016 16:09
      -1

      Там еще много таких ппц — стоит только почитать блог автора. То как реализованы его «walls on fire» — это еще тот ппц. Впрочем, это не его проблема, а того что Unity дает низкий порог вхождения, потому на мобилы сейчас клепают игры все кому не лень, и качество там соответствующее.

      А у Darina_PL как обычно перевод подан как статья от компании Плариум (которая тоже вроде бы играми занимается).
      Интересно, а будут статьи по собственным наработкам?


      1. Darina_PL
        05.01.2016 19:52

        Статей по нашим проектам в блоге большое количество — мы чередуем переводы с собственными материалами.
        Нигде в статье не указано, что авторство наше — рядом с заголовком есть плашка «перевод», внизу статьи указан автор и ссылка на оригинал. Переводы оформляются по правилам сайта.


        1. iOrange
          05.01.2016 22:04
          +1

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

          Несолидно.


        1. DiscoDeer
          07.01.2016 04:49

          Тут вы несколько лукавите. Если пролистать первые три страницы блога (дальше лень) и проигнорировать «дайджесты», то от вашей компании только 2 технические статьи. Из трёх страниц.


  1. Stridemann
    11.01.2016 16:46

    Когда-то делал порталы, только в 3Д www.youtube.com/watch?v=wh7UcmlXeQs
    Шейдеры, камеры + постобработка (накладывание результата))


    1. Gasparfx
      11.01.2016 21:51

      А исходнички нельзя нигде посмотреть? Очень уж люблю такие вещи.


      1. Stridemann
        12.01.2016 02:39

        Не могу найти тот проект, всё обыскал.
        С кода там в основном положение камер (зеркальная позиция от глаз (камеры) игрока) с обратной стороны порталов, изображение с которых накладывается по маске шейдером через Graphics.Blit при постобработке.
        Сначала снимал одной камерой ч/б маску накладывания, потом само изображение, далее в шейдере в альфу изображения ложил маску и накладывал на экран при постобработке. Шейдер вручную писался.


  1. Stridemann
    12.01.2016 02:39

    deleted