Создание шейдера обратного фи-феномена в 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: Я уже доработал шейдер и в нем теперь можно выбирать режим работы (смещение, увеличение, уменьшение объекта)

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/751796/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *