Создание шейдера обратного фи-феномена в Unity: мой опыт

Визуальная составляющая играет ключевую роль в разработке игр. Один из наиболее уникальных и недооцененных приемов — использование оптических иллюзий.

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

Пример использования оптических иллюзий в игре (все объекты кроме одной мыши полностью статичны)

Пример использования оптических иллюзий в игре (все объекты кроме одной мыши полностью статичны)

Случайно наткнувшись год назад на статью об оптических иллюзиях (основанных на обратном фи-феномене), я загорелся повторить этот эффект. Но на тот момент я не обладал достаточным опытом и знаниями (тогда еще не было доступа к ChatGPT), поэтому к реализации я приступил только сейчас, и у меня вышел вполне рабочий прототип, о чем и будет данная статья.

Обратный фи-феномен — это иллюзия движения, которая достигается благодаря быстрому изменению цвета и контрастности элементов изображения (в данном случае контура).

В той же статье じゃがりきん, любезно раскрывает тайны своего творческого процесса, предоставляя материалы для изучения. Из этих материалов стало ясно, что основа его работы — это цветовая палитра, которую он применяет к дублированным изображениям со смещением. Эти изображения затем окрашиваются в цвета, равноудаленные друг от друга на заданном цветовом спектре.

На первый взгляд, задачка может показаться очень простой. Однако, как оказалось, это задача «со звездочкой», требующая не только знаний, но и творческого подхода.

Объяснение じゃがりきん  работы его оптических иллюзий

Объяснение じゃがりきん работы его оптических иллюзий

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

Шейдеры в Unity — это небольшие программы, написанные на специальном языке программирования, называемом GLSL (или HLSL для DirectX). Они выполняются на графическом процессоре (GPU) и используются для определения внешнего вида и отображения объектов на экране.

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

Оптическая иллюзия которую я выбрал в качестве референса

Оптическая иллюзия которую я выбрал в качестве референса

Итак, моя первая ошибка заключалась в том, что я сразу же отбросил идею использования цветового спектра автора и решил обратиться к стандартной HSV-палитре Unity. Я обратился к ChatGPT 4 с просьбой помочь мне создать шаблон шейдера, который бы дублировал спрайт и устанавливал координаты для дублированного элемента. Второй элемент просто брал эти значения с противоположным знаком, чтобы оказаться на противоположной стороне. Значения цвета также задавались отдельно для каждого элемента в переменной цвета.

Код шейдера

// Шейдер для дублирования спрайта с разными цветами
Shader "Custom/SpriteDuplicate" 
{

// Описание свойств шейдера, доступных из инспектора Unity
Properties
{
  // Текстура спрайта
  [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
  
  // Цвет центрального спрайта
  _Color ("Tint", Color) = (1,1,1,1) 
  
  // Цвет левого дубликата
  _Color1 ("Tint1", Color) = (1,1,1,1)

  // Цвет правого дубликата
  _Color2 ("Tint2", Color) = (1,1,1,1)

  // Включение пиксельной привязки    
  [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
  
  // Смещение для дубликатов
  _Offset ("Offset", Vector) = (0,0,0,0) 
}

SubShader
{
  // Настройки отрисовки и смешивания
  Tags
  {
    "Queue"="Transparent"
    "IgnoreProjector"="True"
    "RenderType"="Transparent"
    "PreviewType"="Plane" 
    "CanUseSpriteAtlas"="True"
  }

  Cull Off
  Lighting Off
  ZWrite Off
  Fog { Mode Off }
  Blend One OneMinusSrcAlpha

  // Проход для основного спрайта
  Pass
  {
    // Настройка шейдеров вершин и пикселей
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile DUMMY PIXELSNAP_ON
    #include "UnityCG.cginc"

    // Структура атрибутов вершины
    struct appdata_t 
    {
      float4 vertex   : POSITION;
      float4 color    : COLOR;
      float2 texcoord : TEXCOORD0;
    };
    
    // Структура передачи данных в пиксельный шейдер
    struct v2f
    {
      float4 vertex   : SV_POSITION;
      fixed4 color    : COLOR;
      half2 texcoord  : TEXCOORD0;
    };

    // Переменная для основного цвета
    fixed4 _Color;

    // Вершинный шейдер
    v2f vert(appdata_t IN)
    {
      v2f OUT;
      // Преобразование в пространство экрана
      OUT.vertex = UnityObjectToClipPos(IN.vertex);
      
      OUT.texcoord = IN.texcoord;
      
      // Умножаем цвет на основной
      OUT.color = IN.color * _Color;

      #ifdef PIXELSNAP_ON
        // Привязка к пикселю
        OUT.vertex = UnityPixelSnap (OUT.vertex); 
      #endif

      return OUT;
    }

    // Текстура спрайта
    sampler2D _MainTex;

    // Пиксельный шейдер	
    fixed4 frag(v2f IN) : SV_Target
    {
      fixed4 c = tex2D(_MainTex, IN.texcoord) * IN.color;
      c.rgb *= c.a;
      return c;
    }
    
    ENDCG
  }
  
  // Аналогично для дубликатов

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

            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                half2 texcoord  : TEXCOORD0;
            };

            fixed4 _Color2;
            float4 _Offset;

            v2f vert(appdata_t IN)
            {
                v2f OUT;
                OUT.vertex = UnityObjectToClipPos(IN.vertex - _Offset);
                OUT.texcoord = IN.texcoord;
                OUT.color = IN.color * _Color2;
                #ifdef PIXELSNAP_ON
                OUT.vertex = UnityPixelSnap (OUT.vertex);
                #endif

                return OUT;
            }

            sampler2D _MainTex;

            fixed4 frag(v2f IN) : SV_Target
            {
                fixed4 c = tex2D(_MainTex, IN.texcoord) * IN.color;
                c.rgb *= c.a;
                return c;
            }
            ENDCG
        }

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

            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                half2 texcoord  : TEXCOORD0;
            };

            fixed4 _Color;

            v2f vert(appdata_t IN)
            {
                v2f OUT;
                OUT.vertex = UnityObjectToClipPos(IN.vertex);
                OUT.texcoord = IN.texcoord;
                OUT.color = IN.color * _Color;
                #ifdef PIXELSNAP_ON
                OUT.vertex = UnityPixelSnap (OUT.vertex);
                #endif

                return OUT;
            }

            sampler2D _MainTex;

            fixed4 frag(v2f IN) : SV_Target
            {
                fixed4 c = tex2D(_MainTex, IN.texcoord) * IN.color;
                c.rgb *= c.a;
                return c;
            }
            ENDCG
        }
    }
}

Получив чистый и, что стоит отметить, тщательно прокомментированный код (я не могу оценить его качество, так как я не специалист в области шейдеров, но если вы разбираетесь, буду рад услышать ваше мнение в комментариях), я решил реализовать остальную логику на привычном мне C#. Самый простой способ выбора цветов по стандартному спектру выглядит примерно так: мы берем текущее время, умножаем его на переменную скорости и полученное число используем в качестве оттенка в системе цветов HSV.

using UnityEngine;

public class SpectrumColorChanger : MonoBehaviour
{
    public Material targetMaterial; // Материал для изменения цвета
    public float colorChangeSpeed = 1.0f; // Скорость изменения цвета

    private float hue = 0.0f; // Текущий оттенок в цветовом пространстве HSV

    void Update()
    {
        // Проверяем, что материал задан
        if (targetMaterial != null)
        {
            // Увеличиваем оттенок
            hue += Time.deltaTime * colorChangeSpeed;

            // Если оттенок превышает 1, обнуляем его
            if (hue > 1.0f)
            {
                hue -= 1.0f;
            }

            // Преобразуем оттенок в цвет RGB и обновляем цвет материала
            Color newColor = Color.HSVToRGB(hue, 1, 1);
            targetMaterial.color = newColor;
        }
    }
}

Ну и для дублируемых спрайтов берем значения = hue — coloroffset и hue + coloroffset (В коде выше это не указано) «Изи», — подумал я, и запустил программу…

Первая попытка запуска созданного шейдера

Первая попытка запуска созданного шейдера

Так, кружочки есть — есть, цвета меняются — меняются, эффект похож — ну как бы да, но как будто его собрали китайские дети в гараже) И тут я понял, что это задачка не на один вечер (Я открыл Photoshop, загрузил исходную гифку и решил проверить первый кадр. Судя по цветовому спектру HSB в Photoshop (который аналогичен HSV в Unity все верно, цвета находятся на равном удалении спектра и все верно.

Сравнение отклонение цветов по спектру первого кадра референсной GIF

Сравнение отклонение цветов по спектру первого кадра референсной GIF

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

Сравнение отклонение цветов по спектру второго кадра референсной GIF

Сравнение отклонение цветов по спектру второго кадра референсной GIF

Пришло время начать все сначала. Мы возвращаемся к самому первому скриншоту, предоставленному автором этой иллюзии. Зная, как в Unity формируется значение цвета с помощью RGB, мы немного дополняем рисунок. В Unity для задания цвета используются значения каждого из цветов R, G и B в диапазоне от 0 до 1 — это будет ось Y, в то время как ось X будет отвечать за время.

Применение значений цвета Unity к спектру автора

Применение значений цвета Unity к спектру автора

И вот здесь наступает момент истинного удовлетворения — момент, когда мы вспоминаем школьную математику и осознаем, что все эти синусы и косинусы изучались не зря! После некоторого размышления мы приходим к следующим формулам:


y = 0.5 cos(0.5π*x)+0.5 — значения Green
y = 0.5 cos(0.5π*(x+1))+0.5 — значения Red
y = 0.5 cos(0.5π*(x-2))+0.5 — значения Blue

И дописываем в код следующие функции:

float RColor(float x)
	    {
	        return 0.5f * Mathf.Cos(0.5f * Mathf.PI * (x + 1)) + 0.5f;
	    }
	
    float GColor(float x)
	    {
	        return 0.5f * Mathf.Cos(0.5f * Mathf.PI * (x)) + 0.5f;
	    }

    float BColor(float x)
	    {
	        return 0.5f * Mathf.Cos(0.5f * Mathf.PI * (x - 1)) + 0.5f;
	    }

И задавать цвет будем соответственно через RGB:

Color mainColor = new Color(RColor(hue), GColor(hue), BColor(hue), 1);

Ну все, теперь то точно заработает! Пуск…

Второй запуск созданного шейдера

Второй запуск созданного шейдера

WTF! Ну вот что может быть не так! Я даже спектр взял правильный, поэкспериментировал со скоростью смены цвета, значением отклонения по спектру, ну все должно быть верно!

Лезем обратно в Фотошоп. Берем первый цвет на первом кадре и видим, что значения R и G совпадают, а вот значение B взято неправильно! То есть по этому графику и невозможно было повторить эффект!

В этот момент задача окончательно заслужила свою звездочку сложности, а я лишился спокойного сна (Что могло пойти не так? Я даже выбрал правильный спектр, настроил скорость смены цвета, значение отклонения по спектру… все должно было быть верно!

Возвращаемся обратно в Фотошоп… Я выбрал первый цвет на первом кадре и обнаружил, что значения R и G совпадают, но значение B было выбрано неправильно! Таким образом, по этому графику было невозможно воспроизвести эффект! (P.S на остальных кадрах была такая же картина)

Сравнение цвета на спектре и на референсном Gif

Сравнение цвета на спектре и на референсном Gif

В правильном спектре синий канал должен быть в противофазе красному, как-то так:

Правильный спектр для создания иллюзии

Правильный спектр для создания иллюзии

Меняем функцию для Синего канала на y = 0.5 cos(0.5π*(x-1))+0.5

Снова запускаем программу, и вот он — желаемый эффект начинает работать! Все цвета по значениям RGB теперь точно соответствуют исходному файлу. Осталось лишь более тщательно подобрать скорость смены цвета и значение отклонения по спектру.

Результат работы программы после исправленного цветового спектра

Результат работы программы после исправленного цветового спектра

Вау! Но, как я упоминал в самом начале, моя цель — создать шейдер, а не C# скрипт. Конечно, результаты уже впечатляют, но давайте наконец объединим все это вместе! За эти пару дней, проведенных в компании с ChatGPT, я значительно продвинулся в понимании шейдеров и теперь готов собрать этого франкенштейна.

Начнем с определения переменных. Нам нужно, чтобы в материале Unity мы могли контролировать скорость смены цвета, радиус отклонения дубликатов спрайта, угол отклонения, разницу в спектре для дубликатов:

Properties
    {
        [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
        _Speed ("Speed", Range(0,20)) = 8.5
        _Radius ("Radius", Range(0,5)) = 0.02
        _Angle ("Angle", Range(0,360)) = 0.0
        _ColorOffset ("Color Offset", Range(0,1)) = 0.5
    }

В нашем шейдере мы будем рисовать все за три прохода (пасса) — основной спрайт и два его дубликата по отдельности. Давайте рассмотрим пример отрисовки одного из пассов:

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

Для начала возьмем переменную float4 _Offset. Это вектор из четырех значений с плавающей запятой, который мы будем использовать как хранилище для координат X и Y.

Находим координату X через косинус, а Y через синус:

X=cos(α)∗RX=cos(α)∗R

Y=sin(α)∗RY=sin(α)∗R

Где α — это значение угла поворота нашей иллюзии, а R — радиус отклонения дубликатов спрайта.

v2f OUT; // Объявляем структуру v2f, которая будет использоваться для передачи данных из вершинного шейдера во фрагментный шейдер.

_Offset = float4(cos(radians(_Angle))*_Radius, sin(radians(_Angle))*_Radius,0,0); 
// Вычисляем смещение для каждой вершины на основе заданного угла (_Angle) и радиуса (_Radius). 
// Это делается путем преобразования угла из градусов в радианы и применения функций cos и sin для получения x и y компонентов смещения. 
// Результат сохраняется в переменной _Offset.

OUT.vertex = UnityObjectToClipPos(IN.vertex + _Offset); 
// Добавляем вычисленное смещение к позиции каждой вершины (IN.vertex) и преобразуем ее из пространства объекта в пространство отсечения с помощью функции UnityObjectToClipPos. 
// Пространство отсечения - это координатное пространство, в котором производится окончательное отсечение геометрии перед растеризацией. 
// Результат сохраняется в OUT.vertex, который затем передается во фрагментный шейдер.

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

OUT.texcoord = IN.texcoord; 
// Копируем текстурные координаты из входных данных вершины (IN.texcoord) в выходные данные вершины (OUT.texcoord). 
// Текстурные координаты используются для определения, как текстура должна быть отображена на геометрии.

OUT.color = IN.color * fixed4(_Red, _Green, _Blue, 1.0); 
// Вычисляем цвет каждой вершины, умножая входной цвет (IN.color) на вектор цвета (fixed4(_Red, _Green, _Blue, 1.0)). 
// Это позволяет нам контролировать интенсивность каждого из каналов цвета (красного, зеленого и синего) независимо.

#ifdef PIXELSNAP_ON
OUT.vertex = UnityPixelSnap (OUT.vertex);
#endif
// Если определено PIXELSNAP_ON, мы применяем функцию UnityPixelSnap к позиции вершины (OUT.vertex). 
// Это обеспечивает, что вершины будут выровнены по пикселям, что может помочь предотвратить артефакты рендеринга, особенно при работе с 2D-графикой.

return OUT; 
// Возвращаем выходные данные вершины (OUT), которые затем будут использоваться во фрагментном шейдере.

Здесь мы задаем цвет через каналы RGB по тем же самым формулам, которые уже реализовывали в C#

fixed4 frag(v2f IN) : SV_Target
{   
    // Получаем цвет пикселя из текстуры (_MainTex) в соответствии с текстурными координатами (IN.texcoord) и умножаем его на цвет вершины (IN.color)
    fixed4 c = tex2D(_MainTex, IN.texcoord) * IN.color;

    // Меняем красный (r), зеленый (g) и синий (b) каналы цвета пикселя, используя функцию косинуса для создания эффекта цветового смещения
    c.r = 0.5f * cos(0.5f * 3.14159f * (_ColorOffset + _Speed *_Time.y + 1)) + 0.5f;
    c.g = 0.5f * cos(0.5f * 3.14159f * (_ColorOffset + _Speed * _Time.y)) + 0.5f;
    c.b = 0.5f * cos(0.5f * 3.14159f * (_ColorOffset +_Speed * _Time.y - 1)) + 0.5f;

    // Умножаем RGB-каналы на альфа-канал, чтобы учесть прозрачность пикселя
    c.rgb *= c.a;

    // Возвращаем итоговый цвет пикселя
    return c;
}

Вот и все, детская задачака со звездочкой успешно решена! Поздравляю вас, и, конечно, поздравляю себя!

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

Я считаю, что этот шейдер может прекрасно вписаться в игры в стиле shmup или топ-даун шутеры, например «The Binding of Isaac». Он также может добавить сложности играм, вдохновленным «Geometry Dash». Но в качестве дополнительного элемента, добавляющего сложности игровому процессу, он, безусловно, может найти свое применение.

Какие у вас мысли на этот счет? В каких играх, по вашему мнению, такие оптические иллюзии будут уместны? И готовы ли вы принять вызов и окунуться в игру с такими элементами?

Полный код шейдера я скоро скину в свой телеграмм канал там же в скором времени будет еще много интересного и полезного контента по разработке игр, программированию и моделированию, подписывайтесь!

P.S: Я уже доработал шейдер и в нем теперь можно выбирать режим работы (смещение, увеличение, уменьшение объекта)

Пример работы доработанного шейдера

Пример работы доработанного шейдера

© Habrahabr.ru