Creating an inverse phi-phenomenon shader in Unity: my experience

Creating an inverse phi-phenomenon shader in Unity: my experience

The visual component plays a key role in game development. One of the most unique and underrated techniques is the use of optical illusions.

In the context of game development, optical illusions can be used to create unique visual effects and add depth and complexity to a game world. They can help create a unique atmosphere, improve the visual perception of the player, or even become a key element of gameplay.

An example of using optical illusions in the game (all objects, except one mouse, are completely static)

Accidentally coming across an article a year ago about optical illusions (based on the inverse phi phenomenon), I set out to replicate this effect. But at that time I did not have enough experience and knowledge (then there was no access to ChatGPT), so I started the implementation only now, and I got a fully working prototype, which is what this article will be about.

Reverse phi phenomenon – this is the illusion of movement, which is achieved due to the rapid change in color and contrast of image elements (in this case, the contour).

In the same article Jagarykin, kindly reveals the secrets of his creative process, providing materials for study. From these materials it became clear that the basis of his work is the color palette that he applies to duplicated images with an offset. These images are then tinted with colors equidistant from each other on a given color spectrum.

At first glance, the task may seem very simple. However, as it turned out, this is a task with an asterisk, which requires not only knowledge, but also a creative approach.

Explanation Jagarykin works of his optical illusions

When I saw that the solution was already presented on a silver platter and only its software implementation was needed, I decided to complicate my life and create a shader for Unity. This shader should reproduce this effect on any sprite, which would make it easier to use in real projects.

Shaders in Unity are small programs written in a special programming language called GLSL (or HLSL for DirectX). They run on the graphics processing unit (GPU) and are used to determine the appearance and display of objects on the screen.

Next will be a detailed shader development process, starting with the idea and ending with the final implementation. It seems to me that there are not enough articles describing the full development cycle today. I chose this illusion as the starting material because it seemed the easiest to implement.

An optical illusion I chose as a reference

So, my first mistake was that I immediately rejected the idea of ​​using the author’s color spectrum and decided to go with Unity’s standard HSV palette. I asked ChatGPT 4 to help me create a shader template that would duplicate a sprite and set the coordinates for the duplicated element. The second element simply took those values ​​with the opposite sign to end up on the opposite side. Color values ​​were also set separately for each element of the color variable.

Shader code
// Шейдер для дублирования спрайта с разными цветами
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
        }
    }
}

Having received a clean and, it should be noted, carefully commented code (I can’t judge its quality, because I’m not an expert in the field of shaders, but if you know, I’ll be glad to hear your opinion in the comments), I decided to implement the rest of the logic in my usual C#. The simplest way to choose colors from the standard spectrum is something like this: we take the current time, multiply it by the speed variable, and use the resulting number as a hue in the HSV color system.

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;
        }
    }
}

Well, for duplicated sprites, we take values ​​= hue – coloroffset and hue + coloroffset (This is not indicated in the code above) “Easy”, I thought, and started the program.

The first attempt to run the created shader

Yes, the circles are – there are, the colors change – they change, the effect is similar – well, as it were, but as if it was assembled by Chinese children in the garage) And then I realized that this task is not for one evening (I opened Photoshop, downloaded the original gif and decided to check the first frame: Judging by the color spectrum of HSB in Photoshop (which is similar to HSV in Unity, everything is correct, the colors are at equal removal of the spectrum and everything is correct.

Comparison of color deviation by the spectrum of the first frame of the reference GIF

However, as I progressed to the next frame, I began to realize that this spectrum was not quite right. The colors of the sprites were already located on an uneven removal of the spectrum and the values ​​​​of brightness and saturation of different colors varied from frame to frame completely unpredictably, not obeying any obvious logic.

Comparison of color deviation by the spectrum of the second frame of the reference GIF

It’s time to start over. We return to the first screenshot provided by the author of this illusion. Knowing how the color value is formed in Unity using RGB, we add a little to the drawing. In Unity, the color assignment uses values ​​of each of the R, G, and B colors in the range 0 to 1 – this will be the Y-axis, while the X-axis will be time.

Applying Unity color values ​​to the author’s spectrum

And here comes the moment of real satisfaction – the moment when we remember school mathematics and realize that all those sines and cosines were studied for a reason! After some thought, we arrive at the following formulas:

– the value of Green
– the value of Red
– the value of Blue

And add the following functions to the code:

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;
	    }

And we will set the color accordingly via RGB:

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

Well, now something will definitely work! Starting…

The second run of the generated shader

WTF! Well, here’s what can go wrong! I even took the correct spectrum, experimented with the speed of color change, the value of the deviation along the spectrum, everything should be correct!

Let’s go back to Photoshop. We take the first color on the first frame and see that the R and G values ​​match, but the B value is taken incorrectly! That is, according to this schedule, it was impossible to repeat the effect!

At this point, the task finally earned its difficulty star, and I lost a good night’s sleep. What could have gone wrong?

Back to Photoshop… I selected the first color in the first frame and found that the R and G values ​​matched, but the B value was selected incorrectly! Thus, it was impossible to reproduce the effect on this schedule! (PS the other shots had the same picture)

Comparison of color on the spectrum and on the reference Gif

In the correct spectrum, the blue channel should be in antiphase with the red one, something like this:

The right spectrum to create an illusion

We change the function for the Blue channel to

We launch the program again, and here it is – the desired effect begins to work! All RGB colors now match the source file exactly. All that remains is to more carefully select the speed of color change and the value of the spectrum deviation.

The result of the program after the corrected color

Wow! But as I mentioned at the very beginning, my goal is to create a shader, not a C# script. Of course, the results are already impressive, but let’s finally put it all together! In the couple of days spent in the company with ChatGPT, I’ve made a lot of progress in understanding shaders and now I’m ready to assemble this frankenstein.

Let’s start with the definition of variables. We need in the Unity material to be able to control the speed of the color change, the deflection radius of the sprite duplicates, the deflection angle, the spectrum difference for the duplicates:

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
    }

In our shader, we will draw everything in three passes – the main sprite and its two duplicates separately. Let’s consider an example of drawing one of the passes:

First, we define the coordinates to reproduce the left duplicate. Since we decided to simplify our task by specifying the sprite’s deviation in terms of radius rather than x and y coordinates, we need to solve a simple school problem of finding the coordinates of a point on a circle.

First, let’s take the variable float4 _Offset. This is a four-valued floating-point vector that we’ll use as storage for the X and Y coordinates.

We find the X coordinate through the cosine, and Y through the sine:

Where α is the value of the angle of rotation of our illusion, and R is the radius of deviation of duplicate sprites.

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, который затем передается во фрагментный шейдер.

Next, we set the color and coordinates for our sprite

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), которые затем будут использоваться во фрагментном шейдере.

Here we set the color through the RGB channels using the same formulas that were already implemented in 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;
}

That’s it, the children’s task with an asterisk has been successfully solved! I congratulate you, and, of course, I congratulate myself!

After the shader was written, I spent a lot of time testing and tweaking it to make sure the illusion worked correctly. This involved changing various parameters such as colors, light intensity and animation speed to achieve the desired effect.

I think this shader can fit perfectly into shmup style games or top-down shooters like The Binding of Isaac. It can also add complexity to games inspired by “Geometry Dash”. But as an additional element that adds complexity to the gameplay, it can certainly find its use.

What are your thoughts on this? In which games do you think such optical illusions would be appropriate? And are you ready to accept the challenge and dive into the game with such elements?

I will soon upload the full code of the shader to my Telegram channel, there will soon be a lot more interesting and useful content on game development, programming and modeling, subscribe!

PS: I have already finished the shader and it is now possible to choose the mode of operation (displacement, increase, decrease of the object)

An example of the improved shader

Related posts