Написання шейдерів у Unity. GrabPass, PerRendererData

Привіт! Я хотів би поділитися досвідом написання шейдерів у 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)

w-компонентаПоділ на w-компоненту необхідно тільки при використанні перспективи, в ортографической проекції вона завжди буде дорівнювати 1. За фактом w зберігає значення відстані точки до камери. Але вона не є глибиною — z, значення якої повинно бути в межах від 0 до 1. Робота з глибиною гідна окремої теми, тому ми повернемося до нашого шейдеру.
Перспективне поділ також можна виконати в вершинном шейдере, а під фрагментний передавати вже підготовлені дані.

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;

Функція stepФункція step поверне 1, якщо x більше або дорівнює y. І 0, якщо x менше y.

Наприклад, якщо x = 1, y = 0.5, то результат c буде дорівнювати 1. І наступне вираз буде мати вигляд
r = 1 * a + 0 * b
Т. к. множення на 0 дає 0, то результатом буде просто значення а.
В іншому випадку, якщо з дорівнюватиме 0,
r = 0 * a + 1 * b
І кінцевий результат буде b.

Читайте також  Записки IoT-провайдера. Трохи про частоти

Перепишемо отримання кольору для режиму 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 у нашого об’єкта, не створюючи копій матеріалу.

Читайте також  6 способів заховати дані в Android-додатку

Про SpriteRendererПодивимося на цю рядок у шейдере.

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

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *