Привет! Я хотел бы поделиться опытом написания шейдеров в Unity. Начнем с шейдера искажения пространства (Displacement/Refraction) в 2D, рассмотрим функционал, используемый для его написания (GrabPass, PerRendererData), а также уделим внимание проблемам, которые обязательно возникнут.
Информация пригодится тем, кто имеет общее представление о шейдерах и пробовал их создавать, но мало знаком с возможностями, которые предоставляет Unity, и не знает с какой стороны подступиться. Загляните, возможно, мой опыт поможет вам разобраться.
Вот такого результата мы хотим добиться.
Подготовка
Для начала создадим шейдер, который будет просто отрисовывать указанный спрайт. Он будет нашей основой для дальнейших манипуляций. Что-то будет в него добавляться, что-то наоборот удаляться. От стандартного “Sprites-Default” он будет отличаться отсутствием некоторых тегов и действий, которые не повлияют на результат.
Shader "Displacement/Displacement_Wave"
{
Properties
{
[PerRendererData]
_MainTex ("Main Texture", 2D) = "white" {}
_Color ("Color" , Color) = (1,1,1,1)
}
SubShader
{
Tags
{
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
Cull Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
fixed4 _Color;
sampler2D _MainTex;
v2f vert (appdata v)
{
v2f o;
o.uv = v.uv;
o.color = v.color;
o.vertex = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 texColor = tex2D(_MainTex, i.uv)*i.color;
return texColor;
}
ENDCG
}
}
}
Получившаяся заготовка.
GrabPass
Теперь наша задача — внести изменения в текущее изображение на экране, а для этого нам необходимо получить изображение. И в этом нам поможет проход GrabPass. Этот проход захватит изображение на экране в текстуру _GrabTexture. Текстура будет содержать только то, что было отрисовано до того, как наш объект, использующий этот шейдер, пошёл на отрисовку.
Кроме самой текстуры нам нужны координаты развертки, чтобы получить из нее цвет пикселя. Для этого в данные фрагментного шейдера добавим дополнительные текстурные координаты. Эти координаты не нормированы (значения не в диапазоне от 0 до 1) и описывают положение точки в пространстве камеры (проекции).
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv :
float4 color : COLOR;
float4 grabPos : TEXCOORD1;
};
А в вершинном шейдере заполним их.
o.grabPos = ComputeGrabScreenPos (o.vertex);
Для того, чтобы получить цвет из _GrabTexture, мы можем воспользоваться следующим методом, если используем не нормированные координаты
tex2Dproj(_GrabTexture, i.grabPos)
Но мы воспользуемся другим методом и нормируем координаты сами, использовав перспективное деление, т.е. разделив на w-компоненту все остальные.
tex2D(_GrabTexture, i.grabPos.xy/i.grabPos.w)
Перспективное деление также можно выполнить в вершинном шейдере, а во фрагментный передавать уже подготовленные данные.
v2f vert (appdata v)
{
v2f o;
o.uv = v.uv;
o.color = v.color;
o.vertex = UnityObjectToClipPos(v.vertex);
o.grabPos = ComputeScreenPos (o.vertex);
o.grabPos /= o.grabPos.w;
return o;
}
Допишем соответственно фрагментный шейдер.
fixed4 frag (v2f i) : SV_Target
{
fixed4 = grabColor = tex2d(_GrabTexture, i.grabPos.xy);
fixed4 texColor = tex2D(_MainTex, i.uv)*i.color;
return grabColor;
}
Отключим указанный режима смешивания, т.к. теперь мы реализуем свой режим смешивания внутри фрагментного шейдера.
//Blend SrcAlpha OneMinusSrcAlpha
Blend Off
И посмотрим на результат работы GrabPass.
Кажется, что ничего не произошло, но это не так. Для наглядности внесём небольшой сдвиг, для этого к текстурным координатам мы прибавим значение переменной. Чтобы мы могли изменять переменную, добавим новое свойство _DisplacementPower.
Properties
{
[PerRendererData]
_MainTex ("Main Texture", 2D) = "white" {}
_Color ("Color" , Color) = (1,1,1,1)
_DisplacementPower ("Displacement Power" , Float) = 0
}
SubShader
{
Pass
{
...
float _DisplacementPower;
...
}
}
И снова внесём изменения во фрагментный шейдер.
fixed4 grabColor = tex2d(_GrabTexture, i.grabPos.xy + _DisplaccementPower);
Оп хоп и результат! Картинка со сдвигом.
После успешного сдвига можно приступать к более сложному искажению. Используем заранее подготовленные текстуры, которые будут хранить силу смещения в указанной точке. Красный цвет для значение смещения по оси x, а зелёный по оси y.
Приступим. Добавим новое свойство для хранения текстуры.
_DisplacementTex ("Displacement Texture", 2D) = "white" {}
И переменную.
sampler2D _DisplacementTex;
Во фрагментном шейдере получим значения смещения из текстуры и добавим их к текстурным координатам.
fixed4 displPos = tex2D(_DisplacementTex, i.uv);
float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a;
fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset);
Теперь, изменяя значения параметра _DisplacementPower, мы не просто смещаем исходное изображение, а искажаем его.
Overlay
Сейчас на экране присутствует только искажение пространства, а спрайт, который мы показывали в самом начале, отсутствует. Вернем его на место. Для этого мы воспользуемся непростым смешиванием цветов. Возьмём что-нибудь другое, например, режим смешивания overlay. Формула его такова:
где S — исходное изображение, С — корректирующее, то есть наш спрайт, R — результат.
Перенесём эту формулу в наш шейдер.
fixed4 color = grabColor < 0.5
? 2*grabColor*texColor
: 1-2*(1-texColor)*(1-grabColor);
Применение условных операторов в шейдере достаточно запутанная тема. Многое зависит от платформы и используемой API для графики. В некоторых случаях условные операторы не повлияют на производительность. Но всегда стоит иметь запасной вариант. Заменить условный оператор можно с помощью математики и имеющихся методов. Воспользуемся следующей конструкцией
c = step ( y, x);
r = c * a + (1 - c) * b;
К примеру, если x = 1, а y = 0.5, то результат c будет равен 1. И следующее выражение будет иметь вид
r = 1 * a + 0 * b
Т.к. умножение на 0 даёт 0, то результатом будет просто значение а.
В ином случае, если с будет равно 0,
r = 0 * a + 1 * b
И конечным результат будет b.
Перепишем получение цвета для режима overlay.
fixed s = step(grabColor, 0.5);
fixed4 color = s * (2 * grabColor * texColor) +
(1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor));
Обязательно нужно учесть прозрачность спрайта. Для этого мы воспользуемся линейной интерполяцией между двух цветов.
color = lerp(grabColor, color ,texColor.a);
Полный код фрагментного шейдера.
fixed4 frag (v2f i) : SV_Target
{
fixed4 displPos = tex2D(_DisplacementTex, i.uv);
float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a;
fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color;
fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset);
fixed s = step(grabColor, 0.5);
fixed4 color = s * (2 * grabColor * texColor) +
(1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor));
color = lerp(grabColor, color ,texColor.a);
return color;
}
И результат нашей работы.
Особенность GrabPass
Выше было упомянуто, что проход GrabPass {} захватывает содержимое экрана в текстуру _GrabTexture . При этом каждый раз, когда будет вызываться данный проход — содержимое текстуры будет обновляться.
Постоянного обновления можно избежать, если указать имя текстуры, в которую будет захватываться содержимое экрана.
GrabPass{"_DisplacementGrabTexture"}
Теперь содержимое текстуры обновиться только при первом вызове прохода GrabPass за кадр. Это экономит ресурсы, если объектов, использующих GrabPass{}много. Но если два объекта будут накладываться друг на друга, то будут заметны артефакты, так как оба объекта будут использовать одно и тоже изображение.
С использованием GrabPass{"_DisplacementGrabTexture"}.
С использованием GrabPass{}.
Анимация
Теперь пора анимировать наш эффект. Мы хотим плавно уменьшать силу искажения по мере разрастания взрывной волны, имитируя её угасание. Для этого нам понадобится изменять свойства материала.
public class Wave : MonoBehaviour
{
private float _elapsedTime;
private SpriteRenderer _renderer;
public float Duration;
[Space]
public AnimationCurve ScaleProgress;
public Vector3 ScalePower;
[Space]
public AnimationCurve PropertyProgress;
public float PropertyPower;
[Space]
public AnimationCurve AlphaProgress;
private void Start()
{
_renderer = GetComponent<SpriteRenderer>();
}
private void OnEnable()
{
_elapsedTime = 0f;
}
void Update()
{
if (_elapsedTime < Duration)
{
var progress = _elapsedTime / Duration;
var scale = ScaleProgress.Evaluate(progress) * ScalePower;
var property = PropertyProgress.Evaluate(progress) * PropertyPower;
var alpha = AlphaProgress.Evaluate(progress);
transform.localScale = scale;
_renderer.material.SetFloat("_DisplacementPower", property);
var color = _renderer.color;
color.a = alpha;
_renderer.color = color;
_elapsedTime += Time.deltaTime;
}
else
{
_elapsedTime = 0;
}
}
}
Результат анимации.
PerRendererData
Обратим внимание на строку ниже.
_renderer.material.SetFloat("_DisplacementPower", property);
Здесь мы не простой меняем одно из свойств материала, а создаём копию исходного материала (только при первом вызове этого метода) и работаем уже с ней. Вполне рабочий вариант, но если на сцене будет больше одного объекта, например тысяча, то создание стольких копий не приведёт ни к чему хорошему. Есть вариант лучше — это использование в шейдер атрибута [PerRendererData], а в скрипте объекта MaterialPropertyBlock.
Для этого в шейдере добавим атрибут свойству _DisplacementPower.
[PerRendererData]
_DisplacementPower ("Displacement Power" , Range(-.1,.1)) = 0
После этого свойство перестанет отображаться в инспекторе, т.к. теперь оно индивидуально для каждого объекта, которые и будут устанавливать значения.
Возвращаемся к скрипту и внесём в него изменения.
private MaterialPropertyBlock _propertyBlock;
private void Start()
{
_renderer = GetComponent<SpriteRenderer>();
_propertyBlock = new MaterialPropertyBlock();
}
void Update()
{
...
//_renderer.material.SetFloat("_DisplacementPower", property);
_renderer.GetPropertyBlock(_propertyBlock);
_propertyBlock.SetFloat("_DisplacementPower", property);
_renderer.SetPropertyBlock(_propertyBlock);
...
}
Теперь, чтобы менять свойство, мы будем обновлять MaterialPropertyBlock у нашего объекта, не создавая копий материала.
[PerRendererData]
_MainTex ("Main Texture", 2D) = "white" {}
SpriteRenderer аналогичным образом работает со спрайтами. Он сам задаёт свойству _MainTex значение, используя MaterialPropertyBlock. Поэтому в инспекторе у материала не отображается свойство _MainTex, а в компоненте SpriteRenderer мы указываем нужную нам текстуру. При этом на сцене может быть много разных спрайтов, но материал для их отрисовки будет использоваться только один (если вы его не поменяете сами).
Особенность PerRendererData
Получить MaterialPropertyBlock можно почти у всех компонентов, связанных с рендером. Например, у SpriteRenderer, ParticleRenderer, MeshRenderer и остальных компонентов Renderer. Но всегда найдётся исключение, это CanvasRenderer. Получить и изменить свойства таким методом у него невозможно. Поэтому, если вы будете писать 2D игру с использованием UI-компонентов, то столкнетесь с этой проблемой при написании шейдеров.
Вращение
Неприятный эффект возникает при вращении изображения. На примере круглой волны это особенно заметно.
Правая волна при повороте (90 градусов) дает другое искажение.
Красным указаны вектора, получаемые из одной и той же точки текстуры, но при разном повороте этой текстуры. Значение смещения остаётся тем же и не учитывает поворот.
Для решения этой проблемы мы воспользуемся матрицей преобразования unity_ObjectToWorld. Она поможет пересчитать наш вектор из локальных координат в мировые.
float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a;
offset = mul( unity_ObjectToWorld, offset);
Но матрица содержит в себе данные и о масштабе объекта, поэтому при указании силы искажения мы должны учитывать масштаб самого объекта.
_propertyBlock.SetFloat("_DisplacementPower", property/transform.localScale.x);
Правая волна все также повернута на 90 градусов, но искажения теперь расчитываются верно.
Clip
Наша текстура имеет достаточно прозрачных пикселей (особенно, если мы используем тип меша Rect). Шейдер обрабатывает их, что в данном случае не имеет смысла. Поэтому попытаемся уменьшить количество лишних вычислений. Обработку прозрачных пикселей мы можем прервать при помощи метода clip(х). Если переданный ей параметр меньше нуля, то работа шейдера завершится. Но так как значение альфа не может быть меньше 0, то мы вычтем из него небольшое значение. Его так же можно вынести в свойства (Cutout) и использовать для отсечения прозрачных частей изображения. В данном случае отдельный параметр нам не нужен, поэтому мы будем использовать просто число 0,01.
Полный код фрагментного шейдера.
fixed4 frag (v2f i) : SV_Target
{
fixed4 displPos = tex2D(_DisplacementTex, i.uv);
float2 offset = (displPos.xy * 2 - 1) * _DisplacementPower * displPos.a;
offset = mul( unity_ObjectToWorld,offset);
fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color;
clip(texColor.a - 0.01);
fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset);
fixed s = step(grabColor, 0.5);
fixed4 color = s * 2 * grabColor * texColor +
(1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor));
color = lerp(grabColor, color ,texColor.a);
return color;
}
P.S.: Исходный код шейдера и скрипта — ссылка на git. В проекте также есть небольшой генератор текстур для искажения. Кристалл с постаментом был взят из ассета — 2D Game Kit.
Автор: ice_storm