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

Привет! Я хотел бы поделиться опытом написания шейдеров в Unity. Начнем с шейдера искажения пространства (Displacement/Refraction) в 2D, рассмотрим функционал, используемый для его написания (GrabPass, PerRendererData), а также уделим внимание проблемам, которые обязательно возникнут.

Информация пригодится тем, кто имеет общее представление о шейдерах и пробовал их создавать, но мало знаком с возможностями, которые предоставляет Unity, и не знает с какой стороны подступиться. Загляните, возможно, мой опыт поможет вам разобраться.



Вот такого результата мы хотим добиться.

image

Подготовка


Для начала создадим шейдер, который будет просто отрисовывать указанный спрайт. Он будет нашей основой для дальнейших манипуляций. Что-то будет в него добавляться, что-то наоборот удаляться. От стандартного “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
        }
    }
}

Спрайт для отображения
Фон на самом деле прозрачный, затемнил намеренно.

image

Получившаяся заготовка.

image

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.

image

Кажется, что ничего не произошло, но это не так. Для наглядности внесём небольшой сдвиг, для этого к текстурным координатам мы прибавим значение переменной. Чтобы мы могли изменять переменную, добавим новое свойство _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.

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

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

P.S.: Исходный код шейдера и скрипта — ссылка на git. В проекте также есть небольшой генератор текстур для искажения. Кристалл с постаментом был взят из ассета — 2D Game Kit.
  • +53
  • 16.6k
  • 8
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 8

    +5
    Результат — отличный и статья очень хорошая. Особенно для статьи из песочницы, спасибо.
      +1
      А какая разница, ставим мы PropertyBlock или меняем данные в свойстве уникального материала? Ведь оно внутри все-равно будет раздельно рендерить эти спрайты / меши. Нужно либо шить данные в меш (в вертексы напрямую, дополнительный vertex stream тоже не батчится), либо использовать инстансинг.

      Про clip (который на самом деле макрос поверх discard) тоже стоило сказать, что применять можно только на десктопе — на мобильных гпу это вызывает сильное падение производительности.
        0
        Да, динамический бачинг не будет работать, хоть в результате мы и используем один материал, но MaterialPropertyBlock и [PerRendererData] позволяет нам тратить меньше ресурсов на изменение свойств. При том, что нужно всего пару строк кода.
        Результаты профайлера

        В левой части результат работы с MaterialPropertyBlock и [PerRendererData], а в правой без них. На тестовой сцене использовалось 2000 объектов (3D)

        Я использовал clip в версиях игры для android/ios и не замечал проблем с производительностью. Возможно, объектов, использовавших этот шейдер было недостаточно, но теперь мне интересно это проверить:)
          0
          В случае clip — это discard + бранчинг. По поводу графика — что-то странное творится в коде, потому что кода не может быть больше — просто щупаешь material вместо sharedMaterial и ставишь свойство. Внутри происходит неконтролируемая ленивая инициализация уникального материала на первом вызове и все — это не часть пользовательского кода, которая на графике почему-то увеличила вычислительную нагрузку в 3 раза как минимум.
        +1
        Уточню — клип ломает блочную отрисовку кадра (забыл как правильно по-английски).
        Типа он мог бы рисовать кадр кусками сразу по сколько пикселей, если бы поведение каждого пикселя было предсказуемым. А клип и бранчинг это дело ломает.
        0
        В целом статья понравилась, аккуратно и подробно все расписано.
        Не хватило упоминания о том, что GrabPass на мобильных устройствах очень тяжелая операция, потому и постэффекты на мобилках стараются не использовать — большая нагрузка на ширину шины оперативной памяти.
          0
          Спасибо, классная статья.

          Only users with full accounts can post comments. Log in, please.