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