В этой статье я расскажу о том, как достичь вот такого эффекта:
По сути, шейдер, о котором пойдет речь, работает как пост-эффект для камеры или встроенные фильтры 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 и менее коэффициент линейной интерполяции должен быть равен 1, а для расстояния 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. Конечный результат
Вот еще несколько гифок, демонстрирующих конечный результат в действии:
Автор: Plarium