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

Все кто страдает фоточувствительной эпилепсией или боится программного кода или математических формул эта статья не для вас!

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

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

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

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

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

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

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

Объяснение <a href="https://api.dtf.ru/v2.8/redirect?to=https%3A%2F%2Ftwitter.com%2Fjagarikin&postId=2001039" rel="nofollow noreferrer noopener" target="_blank">じゃがりきん</a> работы его оптических иллюзий
Объяснение じゃがりきん работы его оптических иллюзий

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

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

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

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

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

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

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

Пример работы доработанного шейдера
77
3 комментария

Посмотрел гифки

1
Ответить

Круто!

Ответить

Супер, очень круто!

Ответить