Создание шейдера обратного фи-феномена в 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
Пришло время начать все сначала. Мы возвращаемся к самому первому скриншоту, предоставленному автором этой иллюзии. Зная, как в Unity формируется значение цвета с помощью RGB, мы немного дополняем рисунок. В Unity для задания цвета используются значения каждого из цветов R, G и B в диапазоне от 0 до 1 — это будет ось Y, в то время как ось X будет отвечать за время.
Применение значений цвета Unity к спектру автора
И вот здесь наступает момент истинного удовлетворения — момент, когда мы вспоминаем школьную математику и осознаем, что все эти синусы и косинусы изучались не зря! После некоторого размышления мы приходим к следующим формулам:
— значения Green
— значения Red
— значения 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
В правильном спектре синий канал должен быть в противофазе красному, как-то так:
Правильный спектр для создания иллюзии
Меняем функцию для Синего канала на
Снова запускаем программу, и вот он — желаемый эффект начинает работать! Все цвета по значениям 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 через синус:
Где α — это значение угла поворота нашей иллюзии, а 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: Я уже доработал шейдер и в нем теперь можно выбирать режим работы (смещение, увеличение, уменьшение объекта)
Пример работы доработанного шейдера