Как стать автором
Обновить

Делаем крутые эффекты с помощью Animation Curve

Время на прочтение6 мин
Количество просмотров4.2K

Всем привет. Меня зовут Гриша Дядиченко, и я технический продюсер. Сегодня мы поговорим о том, как жить без математики или почему можно делать интересные визуальные эффекты и шейдеры с нулевыми познаниями в построении сложных 2д кривых. Так же разберём Unity Animation Curve. Если вам интересна тема генерации текстур, кастомных редакторов, шейдеров и визуальных эффектов — добро пожаловать под кат!

Математика довольно бессердечна. Чтобы начать саму историю начнём с того, какую типовую задачу мы решаем. При разработке различных шейдеров, визуальных эффектов и т.п. часто пригождается писать разные уравнения кривых, чтобы получить необходимый визуальный эффект. Синусы, косинусы, сложные периодические функции через модальное деление. Обычно берётся какой-нить desmos, там строится нужная кривая собрав какое-нибудь сложное уравнение. Для примера у меня была статья про кривые и там тоже самое делалось через вольфрам математику. Но есть путь проще. Особенно удобный для сложных периодических кривых. Сгенерировать текстуру на основе Unity Animation Curve. Так вы в простом формате сможете регулировать нужные вам параметры.

Для чего оно нужно?

Для примера в одном из недавних проектов мне нужно было сделать что-то вроде отделения пользователя от фона. Так как по умолчанию наложение depth texture arkit выглядит как-то так.

То было принято решение это как-то улучшать. Чтобы выглядело как минимум приличнее в подобном случае пришла в голову идея размывать карту глубины и регулировать альфу через кривую, чтобы получить максимально приемлемую обтравку. Подбирать кривую в шейдере очень долго, так как нужно подобрать не только саму кривую, но и характер кривой. Поэтому проще было написать инструмент, который превращает Unity Animation Curve в текстуру и отрегулировать округление альфа канала через неё.

Но это лишь один пример использования подобных кривых. Чтобы задать характер движения текстуры или же альфы например для подобного класса эффектов.

В общем применений данного подхода при желании масса. Чтож, зачем вроде обсудили. Перейдём к реализации.

Начнём с самой генерации

Генерировать текстуры в Unity довольно просто. Класс Texture2D имеет в себе все нужные для этого метода. Если текстура небольшая, то это даже и не так дорого по перфомансу, хотя пере генерировать какие-то текстуры я бы не стал. В методе OnEnable самое то, чтобы грамотно контролировать в какой момент работы нашей сцены или игры мы дадим нагрузку.

С точки зрения генерации нам понадобится градиент высотой в 1px. И тут стоит отметить одну удобную настройку TextureWrapMode. Для того чтобы повторить тоже самое с кривыми нужно знать математику. А тут можно просто выбрать нужный принцип.

Если эффект у нас повторяется в зависимости от скажем UV координат нашего меша, то:

Режим Clamp

Текстура повторится один раз, а потом вне зависимости от UV будет того значения, который был по краям нашей кривой. Для наглядности я везде поставлю тайлинг 2 и оставлю только красный канал из трёх каналов цвета в кривых.

Режим Repeat

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

Режим Mirror

Почти тоже самое, что и Repeat, только каждый раз проходя через кратное единице значение в UV отражает кривую. Удобно для "пинпонг" эффектов.

Режим Mirror Once

Отражает кривую один раз, а дальше работает как Clamp. Важно: отражение происходит в значениях uv (-1, 1), а не (0,2). Поэтому на скриншоте можно заметить, что я изменил offset, чтобы было видно что происходит.

Безотходное производство

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

using UnityEngine;
namespace Nox7atra
{
    public class AnimCurveToTex : MonoBehaviour
    {
        [SerializeField] private AnimationCurve _animationCurveR;
        [SerializeField] private AnimationCurve _animationCurveG;
        [SerializeField] private AnimationCurve _animationCurveB;
        [SerializeField] private AnimationCurve _animationCurveA;
        [SerializeField] private TextureWrapMode _wrapMode = TextureWrapMode.Clamp;
        [SerializeField] private TextureFormat _textureFormat = TextureFormat.RGBA32;
        [SerializeField] private FilterMode _filterMode = FilterMode.Point;
        [Range(2, 256)]
        [SerializeField] private int _textureResoluton = 128;
        [SerializeField] private Material _targetMaterial;
        [HideInInspector]
        [SerializeField] private string _texturePropertyName;
        
        private Texture2D _texture;
        private void OnValidate()
        {
            UpdateCurveTex();
        }

        private void OnEnable()
        {
            UpdateCurveTex();
        }
    
        private void UpdateCurveTex()
        {
            if(_targetMaterial == null) return;
            _texture = new Texture2D(_textureResoluton, 1, _textureFormat, false)
            {
                wrapMode = _wrapMode,
                filterMode = _filterMode,
            };
            for (int i = 0; i < _texture.width; i++)
            {
                var phase = (float) i / (_texture.width - 1);
                _texture.SetPixel(i, 0, new Color(
                    _animationCurveR.Evaluate(phase), 
                    _animationCurveG.Evaluate(phase),
                    _animationCurveB.Evaluate(phase),
                    _animationCurveA.Evaluate(phase)
                    ));
            }
            _texture.Apply();
            _targetMaterial.SetTexture(_texturePropertyName, _texture);
        }
        
        private void OnDestroy()
        {
            Destroy(_texture);
        }
    }
}

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

Сам по себе класс не делает ничего особенного. В методе UpdateCurveTex создаём текстуру шириной _textureResoluton и высотой в одну единицу с нужным нам форматом и без mipmap (вряд ли они нам понадобятся). Дальше в зависимости от индекса пикселя берём значения из наших кривых и передаём эту текстуру в нужное нам поле материала. Но если _texturePropertyName скрыт в инспекторе, то как он задаётся?

Кастомный инспектор для шейдерных проперти

Писать руками каждый раз нужное проперти в _texturePropertyName как минимум неудобно. Поэтому хочется выпадающий список с пропертями, которые доступны в нашем шейдере. Давайте напишем такой кастомный инспектор. Он тоже довольно небольшой.

using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

namespace Nox7atra
{
    [CustomEditor(typeof(AnimCurveToTex))]
    public class AnimCurveToTex_Editor : Editor
    {
        private AnimCurveToTex _target;
        private SerializedProperty _material;
        private SerializedProperty _texturePropertyName;
        
        public void OnEnable()
        {
            _material = serializedObject.FindProperty("_targetMaterial");
            _texturePropertyName = serializedObject.FindProperty("_texturePropertyName");
        }

        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();
            var matNames = GetMaterialPropertyNames();
            if (matNames != null)
            {
                var index = matNames.IndexOf(_texturePropertyName.stringValue);
                if (index < 0)
                {
                    index = 0;
                }
                _texturePropertyName.stringValue = matNames[EditorGUILayout.Popup(index, matNames.ToArray())];
                _texturePropertyName.serializedObject.ApplyModifiedProperties();
            }
        }

        public List<string> GetMaterialPropertyNames()
        {
            var shader = (_material.objectReferenceValue as Material)?.shader;
            if (shader == null)
                return null;
            var count = shader.GetPropertyCount();
            List<string> materialPropertyNames = new List<string>();

            for (int i = 0; i < count; i++)
            {
                if (shader.GetPropertyType(i) == ShaderPropertyType.Texture)
                {
                    var property = shader.GetPropertyName(i);
                    if (!materialPropertyNames.Contains(property))
                    {
                        materialPropertyNames.Add(property);
                    }
                }
            }
            return materialPropertyNames;
        }
    }
}

SerializedProperty даёт нам доступ к нужным проперти нашего класса не делая лишних public полей, чтобы в билде соблюдалась инкапсуляция. Таким образом в момент отображения нашего инспектора мы забираем нужные нам проперти в наш кастомный инспектор.

Далее стоит обратить внимание на метод GetMaterialPropertyNames. Данный метод смотрит в наш шейдер и ищет там проперти типа ShaderPropertyType.Texture. Собирает их и возвращает нам нужный список для отрисовки в инспекторе. Важно: для того, чтобы изменения в SerializedProperty применились необходимо вызвать метод serializedObject.ApplyModifiedProperties().

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

И ещё один простой пример

Настройки

Шейдер

Shader "Unlit/HologramShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _MaskTex ("MaskTex", 2D) = "white" {}
        _Speed("Speed", float) = 1
    }
    SubShader
    {
        Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
        LOD 100

        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha 

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float2 uv2 : TEXCOORD1;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float2 uv2 : TEXCOORD1;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            sampler2D _MaskTex;
            float4 _MainTex_ST;
            float4 _MaskTex_ST;
            float _Speed;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.uv2 = TRANSFORM_TEX(v.uv2, _MaskTex);
                float x = o.uv2.x;
                o.uv2.x = o.uv2.y;
                o.uv2.y = x;
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                fixed4 mask = tex2D(_MaskTex, i.uv2 + _Time.x * _Speed);
                col = mask + col;
                col.a = mask.a;
                return col;
            }
            ENDCG
        }
    }
}

Как видно в примере благодаря регулировке разрешения можно ещё делать прикольные эффекты вроде голографических полос и шума. Важно: чтобы в данном случае Filter Mode текстур был включен в режим Point, так как другие сгладят этот артефакт.

В заключении

Спасибо за внимание! Надеюсь данный подход пригодится вам в ваших проектах. Для подключения скрипта, как пакета или ознакомления с исходниками можете перейти в этот репозиторий.

Теги:
Хабы:
+4
Комментарии1

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн