Создание шейдера с эффектом дрожания вершин для Unity в стиле PlayStation 1

В предыдущей статье мы реализовали довольно неплохой эффект старого рендера из ранних 90х, однако, ощущения от него будут неполными, если мы не введем другие характерные для того времени ограничения. Нет-нет, 10 фпс и имитацию слабых компьютеров вводить не будем, но постараемся ввести один значимый для ретро-рендера эффект. Возможно он не являются частью общей парадигмы, особенно в области рендеринга на РС, но добавить особую атмосферу. Речь идет, конечно же, об эффекте дрожания вершин, характерный для Playstation 1.

Эффект «дрожания вершин» в PlayStation 1, был следствием ограниченной точности расчетов, использовавшихся на этом железе. Поскольку PlayStation 1 использовала фиксированную точку для вычисления координат вершин, это приводило к визуальным артефактам, когда позиции вершин «дрожали» при перемещении камеры или объектов. Мы, как и в прошлый раз, попробуем воссоздать данный эффект только с помощью шейдера в Unity, без использования дополнительных скриптов на C#.

Основная идея простая — нужно будет обойти все доступные шейдеру вершины и округлить их позиции относительно проекционных координат .

Основы шейдера

Напишем заготовку для шейдера, в которой укажем будущие параметры для материалы и объявим структуры и переменные

Shader "rikovmike/PS1VertexJitter"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _JitterAmount ("Jitter Amount", Range (0.0, 0.1)) = 0.05
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            sampler2D _MainTex;
            float _JitterAmount;

            ENDCG
        }
    }
    FallBack "Diffuse"
}

Здесь мы задаём два параметра для будущего материала: _MainTex — текстура и _JitterAmount — слайдер для точной настройки силы эффекта дрожания.

Еще нам, конечно же, понадобятся две структуры:

  • appdata — данные вершин, передаваемые в вершинный шейдер из Unity
  • v2f — данные, передаваемые из вершинного шейдера в пиксельный шейдер и, в конечном итоге, в графический конвейер

Добавим функцию округления позиций вершин. Мы применяем округление только к clipPos.xy, то есть к координатам, которые уже находятся в проекционных координатах (после всех трансформаций объекта). Это даст эффект «дрожания», как на PlayStation 1, когда камера или объект движется. Параметр clipPos будет передаваться в функцию, как и параметр jitterAmount, который позволяет контролировать, насколько сильным будет эффект дрожания.

            // Функция для округления координат вершин в clip space
            float4 JitterVertex(float4 clipPos, float jitterAmount)
            {
                // Применяем квантование только на позиции в clip space (экранные координаты)
                clipPos.xy = round(clipPos.xy / jitterAmount) * jitterAmount;
                return clipPos;
            }

Округление в clip space означает, что каждая вершина будет «привязана» к определенным фиксированным позициям на экране. При движении камеры или объекта из-за этого квантования вершины будут перескакивать с одного положения на другое, создавая желаемый эффект «дрожания».

Начинаем колдовать

Пишем «вершинный» блок шейдера.

            v2f vert(appdata v)
            {
                v2f o;
                float4 clipPos = UnityObjectToClipPos(v.vertex);
                // Вызываем функцию для "дрожания" вершин
                o.pos = JitterVertex(clipPos, _JitterAmount);
                o.uv = v.uv;
                return o;
            }

Здесь мы сначала получаем clipPos функцией UnityObjectToClipPos для нашей вершины. Затем получаем новую позицию вершины через вызов написанной ранее функции JitterVertex, которой передаем полученный clipPos и наш параметр _JitterAmount из инспектора.
Возвращаем новые данные вершины. UV-координаты оставляем без изменений.

Параметр _JitterAmount нужно держать в пределах от 0.01 до 0.1, этого достаточно для заметного эффекта. Однако, никто не запрещает эксперименты — все ваших руках.

Осталось только добавить фрагментную (пиксельную) часть, в которой мы просто отдаем текстуру, как есть.

            fixed4 frag(v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv);
            }

Полный код шейдера

Shader "rikovmike/PS1VertexJitter"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _JitterAmount ("Jitter Amount", Range (0.0, 0.1)) = 0.05
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            sampler2D _MainTex;
            float _JitterAmount;

            // Функция для округления координат до целого
            float4 JitterVertex(float4 clipPos, float jitterAmount)
            {
                // Применяем квантование только на позиции в clip space (экранные координаты)
                clipPos.xy = round(clipPos.xy / jitterAmount) * jitterAmount;
                return clipPos;
            }

            v2f vert(appdata v)
            {
                v2f o;
                float4 clipPos = UnityObjectToClipPos(v.vertex);
                // Вызываем функцию для "дрожания" вершин
                o.pos = JitterVertex(clipPos, _JitterAmount);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}

Настройка текстуры

Шейдер использует стандартную текстуру, которую можно задать в Unity через инспектор. Текстура рендерится через стандартную функцию tex2D, которая отображает текстуру по UV-координатам, переданным из вершинного шейдера. С помощью бегунка _JitterAmount плавно регулируем эффект дрожания. В зависимости от проекта, свойства самой текстуры настраиваются индивидуально, но стилистически, конечно же, желательно выключить фильтрацию и использовать текстуры с небольшим разрешением, вроде 64х64 или 128х128.

На будущее

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

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