Будем разбираться в шейдерах

Всем привет! Благодарен всем за замечания и комментарии к предыдущей статье. Благодаря всем нам мы наполняем интернет доступными знаниями и это действительно круто.
Сегодня продолжаем разбираться с шейдерами, а именно с работой с освещением. Рассмотрим тип освещения Ламберта, познакомимся с диффузным шейдингом, и, как обычно, напишем и разберём AD шейдер (Ambient Diffuse).

Теория

Итак, начнём наше знакомство с типом освещения Ламберта (Lambertian reflectance).

Ламбертово освещение - освещение, отражение которого определяет идеальную зеркальную поверхность. Для наблюдателя явная яркость ламбертового отражения одинакова вне зависимости от угла зрения наблюдателя. Говоря технически, яркость поверхности является изотропной, а интенсивность освещения подчиняется закону Ламберта. Ламбертово освещение названо в честь Иоганна Генриха Ламберта, который представил и описал концепцию идеального диффузного освещения в своей книге «Фотометрия» 1760.

-- Википедия

Круто, классно, но не понятно. Что, ж рассмотрим на примерах. Начнём с того, что в окружающем нас мире мы не видим сами объекты, мы видим свет, исходящий от объектов. Объекты вокруг нас могут как сами испускать свет, так и отражать его. В основном, мы имеем дело с белым светом. Свет в нашем случае мы будем представлять как электромагнитную волну, имеющая цветовую гамму. Когда белый свет попадает на объект, часть света будет поглощаться, а часть - отражаться. То, что мы видим как цвет объекта - это оставшийся отраженный цвет белого света.

Поглощение и отражение света
Поглощение и отражение света

Перенося это всё на Unity, данный физический процесс можно разбить на следующие составляющие:

  1. Источник света излучает свет

  2. Свет падает на объект, отражаясь и рассеиваясь.

  3. Отражённый свет попадает в камеру и мы получаем картинку.

В Unity источник света описывается точкой в пространстве, а мощность (или количественная величина света) описывается параметром освещённости (Intensity). Поскольку в модели освещения Ламберта угол обзора не имеет значения, мы будем видеть один и тот же цвет со всех сторон.

Возвращяясь к реальности, рассмотрим пример освещения участка с криволинейной поверхностью. Когда луч света падает на поверхность гладкого объекта, свет отражается. Так как лучи света парралельны, то в случае с гладким объектом отражённые лучи будут также параллельны. Это регулярное отражение называется однонаправленным отражением или зеркальным отражением света. Но гладкость объектов относительна, и, как правило, представляет собой шероховатую и неровную поверхность. В таком случае падающие лучи являются параллельными, а отраженные лучи будут рассеиваться во всех направлениях. Это явление является диффузным отражением света.

Отражение света на криволинейной поверхности
Отражение света на криволинейной поверхности

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

Характеристики диффузного освещения:

  1. Интенсивность освещённости пикселя не зависит от угла обзора.

  2. Интенсивность освещённости зависит от угла падения света на поверхность.

Итак, нам нужно понять, что нужно для вычисления цвета каждого пикселя в каждой точке. В модели Ламберта поверхность объекта описывается как поверхность с идеальным отражением( возникает только диффузное отражение). Рассмотрим формулу для расчёта окружающего освещения:

intensityIndirectionDiffuse = rc * ia; 
  • rc(reflection coefficient) - коэффициент отражения материала;

  • ia(intensity ambient) - интенсивность окружающего света.

Формула для расчёта направленного света:

intensityDirectionDiffuse = rc * id * max(0,dot(N, L));
  • rc(reflection coefficient) - коэффициент отражения материала;

  • id(intensity direct) - интенсивность направленного света;

  • N - единичный вектор нормали к вершине;

  • L - единичный вектор нормали падающего света;

  • dot - скалярное произведение векторов.

Формула вычисления направленного света несколько отличается от вычисления окружающего света. При вычислении отражения окружающего света не имеет значение его направление, так как окружающий свет не имеет направления (точнее направлен на все стороны). Поэтому окружающий свет одинаков для каждой вершины. В случае вычисления направленного света его направление имеет значение. Рассеивание направленого света зависит от расстояния до нормали. Чем ближе к нормали, тем сильнее рассеивание. Интенсивность диффузного отражения направленного света соответствует закону косинуса Ламберта. Таким образом имеем формулу:

intensityDiffuse = rc * ia + kf * id * max(0,dot(N, L))

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

Практика

Итак, немного разобравшись в том, как работает свет в Unity, и в том, как нам его применять в шейдере, перейдём к написанию шейдера. Напомню, мы будем писать шейдер AD (Ambient diffuse)

Полный код шейдера
Shader "Chernov/Diffuse"
    {
    Properties
    {
        _MainTex ("Main Texture", 2D) = "white" {}
 
        [Header(Ambient)]
        _Ambient ("Intensity", Range(0., 1.)) = 0.1
        _AmbColor ("Color", color) = (1., 1., 1., 1.)
 
        [Header(Diffuse)]
        _Diffuse ("Val", Range(0., 1.)) = 1.
        _DifColor ("Color", color) = (1., 1., 1., 1.)
     }
 
    SubShader
    {
        Pass
        {
            Tags { "RenderType"="Transparent" "Queue"="Geometry" "LightMode"="ForwardBase" }
 
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
  
            #include "UnityCG.cginc"
 
            struct v2f {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                fixed4 light : COLOR0;
            };
 
            fixed4 _LightColor0;
            fixed _Diffuse;
            fixed4 _DifColor;
            fixed _Ambient;
            fixed4 _AmbColor;
 
            v2f vert(appdata_base v)
            {
                v2f o;
 
                // Clip position
                o.pos = UnityObjectToClipPos(v.vertex);
 
                // Light direction
                float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
 
                // Normal in WorldSpace
                float3 worldNormal = UnityObjectToWorldNormal(v.normal.xyz);
 
                 // World position
                float4 worldPos = mul(unity_ObjectToWorld, v.vertex);

                // Camera direction
                float3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos.xyz));
 
                // Compute ambient lighting
                fixed4 amb = _Ambient * _AmbColor;
 
                // Compute the diffuse lighting
                fixed4 lightTemp = max(0., dot(worldNormal, lightDir) * _LightColor0);
                fixed4 diffuse = lightTemp * _Diffuse * _LightColor0 * _DifColor;
 
                o.light = dif + amb;
               
                o.uv = v.texcoord;
                return o;
            }
 
            sampler2D _MainTex;

            fixed4 frag(v2f i) : SV_Target
            {
                fixed4 c = tex2D(_MainTex, i.uv);
                c.rgb *= i.light;
                return c;
            }
 
            ENDCG
        }
    }
}

Как обычно, будем разбирать код построчно с комментариями. Согласно синтаксису шейдеров Unity, шейдер начинается с объявления названия, в нашем случае это Shader "Chernov/Diffuse". После следует блок свойств шейдера.

  • _MainTex ("Main Texture", 2D) = "white" {} - основная текстура шейдера (по умолчанию текстура белого цвета);

  • _Ambient ("Intensity", Range(0., 1.)) = 0.1 - интенсивность окружающего света (в диапазоне 0 - 1);

  • _AmbColor ("Color", color) = (1., 1., 1., 1.) - цвет окружающего света (по умолчанию - белый);

  • _Diffuse ("Val", Range(0., 1.)) = 1. - интенсивность диффузного освещения (в диапазоне 0 - 1);

  • _DifColor ("Color", color) = (1., 1., 1., 1.) - цвет диффузного света (по умолчанию - белый);

Далее переходим к саб-шейдеру. В нашем случае он будет один. Первым делом объявляем теги шейдера -
Tags { "Queue"="Geometry" "LightMode"="ForwardBase" }.
Теги - наборы пар данных "ключ-значение" для определения каких-либо свойств шейдера. Можно использовать как предопределённые теги, так и добавлять собственные. К тегам можно получить доступ из кода на C#. В нашем случае мы будем использовать несколько тегов:

  • "Queue"="Geometry" - встроенный тег, определяет очередь отрисовки Geometry;

  • "LightMode"="ForwardBase" - встроенный тег, определяет модель освещения, применяет окружающий, основной направленный свет, вершинные источники света и лайтмапы; Больше про теги можно прочитать тут, здесь, и вот тут.

Далее при помощи ключевого слова CGPROGRAM мы объявляем раздел шейдера на языке GLSL. Мы будем использовать такие же наименования, как и в предыдущей главе, а это значит, что #pragma vertex vert объявляет в качестве вершинного шейдера функцию с именем vert, а #pragma fragment frag в качестве фрагментного шейдера функцию с именем frag. Строчкой #include "UnityCG.cginc" мы подключаем библиотеку со вспомогательными и наиболее полезными функциями. Кто хочут узнать больше про эту библиотеку, можно прочесть про неё вот тут.

Движемся по коду дальше и определяем скруктуру данных для фрагментного шейдера:

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

Имя v2f - общепринятое, используется как в примерах от Unity, так и во многих книгах и статьях. Расшифровывается как Vector to fragment. В этой структуре мы определяем три переменные которые нам будут необходимы:

  • float4 pos : SV_POSITION - позиция вершины в пространстве, SV_POSITION - семантика переменной. Указание семантики для входных и выходных данных - стандартная практика при написании шейдеров. Подробнее об этом в документации Unity;

  • float2 uv : TEXCOORD0 - текстурные координаты. TEXCOORD0 - обозначает, что шейдеру необоходимо использовать первый набор координат (их может быть несколько, например в uv1 обычно записывают лайтмапы);

  • fixed4 light : COLOR0 - рассчитаное освещение. COLOR0 - соответственный слот, из которого необходимо брать значение;

Приступим к разбору вершинного шейдера. Рассмотрим сигнатуру вершинного шейдера: v2f vert(appdata_base v). Как можно заметить, возвращаемый тип- v2f - структуа, определённая ранее. appdata_base v - предопределённый тип входных данных для вершинного шейдера. Предоставляет координаты вершины, нормаль и один набор текстурных координат. Помимо appdata_base есть также appdata_tan и appdata_full.

  • appdata_tan - предоставляет координаты вершины, тангенс, нормаль и один набор текстурных координат;

  • appdata_full - предоставляет координаты вершины, тангенс, нормаль, четыре набора текстурных координат и цвет;

Более подробно расписано в документации. Далее следует объявить блок переменных, которые будут использоваться в шейдере. В нашем случае это:

fixed4 _LightColor0;
fixed _Diffuse;
fixed4 _DifColor;
fixed _Ambient;
fixed4 _AmbColor;

Как говорилось ранее, переменные должны иметь те же названия, что и в блоке Properties. Отдельно стоит обратить внимание на переменную fixed4 _LightColor0. Так как мы определили тег шейдера "LightMode"="ForwardBase", то нам стали доступны некоторые встроенные переменные освещения. _LightColor0 - как раз одна из них. Приступим к разбору вершинного шейдера. Прежде всего, преобразуем точку из пространства объекта в просторанство отсечения камеры
o.pos = UnityObjectToClipPos(v.vertex);. Далее необходимо рассчитать интенсивность света, как было описано в теоретическом блоке.

Для вычисления нам необходимо будет вычислить следующие данные:

  • единичный вектор направление освещения;

  • нормаль в мировых координатах;

  • позицию в мировых координатах;

  • направление камеры.

Получаем соответствующие вычисления:

// Light direction
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);

// Normal in WorldSpace
float3 worldNormal = UnityObjectToWorldNormal(v.normal.xyz);
 
// World position
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);

// Camera direction
float3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos.xyz));

Переменная _WorldSpaceLightPos0 - встроенная переменная, доступная при инициализации LightMode значением ForwardBase или ForwardAdd.

Функция UnityObjectToWorldNormal - встроенная функция, преобразует нормаль из координат объекта в мировые координаты.

Функция mul - встроенная функция в языках GLSL и HLSL. Сигнатура - mul(matrixA, matrixB). Результат работы функции - умножение матрицы A на матрицу B.

Для рассчёта мировых координат произведём перемножение текущей матрицы модели на координаты вершины. unity_ObjectToWorld - встроенная переменная, возвращает текущую матрицу модели.

Для рассчёта направления камеры используем встроенную функцию UnityWorldSpaceViewDir. Входной параметр - координаты объекта. Результат работы функции нормализируем стандартным средством языка GLSL - фунцией normalize.

Итак, все необходимые для вычисления данные получены, приступаем к рассчёту освещения. В первую очередь рассчитаем окружающее освещение:

fixed4 amb = _Ambient * _AmbColor;

Здесь для рассчёта окружающего света мы перемножаем интенсивность освещения и цвет. Далее рассчитываем диффузное освещение:

fixed4 lightTemp = max(0., dot(worldNormal, lightDir) * _LightColor0);
fixed4 diffuse = lightTemp * _Diffuse * _LightColor0 * _DifColor;

Здесь мы рассчитываем диффузное освещение по формулам которые были описаны выше в теории. Далее нам осталось сложить окрущающий и диффузный цвет:

o.light = dif + amb;

Есть. Рассчёт освещения окончен. Для окончательного формирования выходной структуры данных нам осталось лишь передать uv координаты.

o.uv = v.texcoord;

Работа вершинного шейдера завершена. Теперь перейдём к фрагментному шейдеру. Во фрагментном шейдере у нас не будет огромного числа вычислений. Всё, что нам осталось - взять кусочек текстуры согласно uv координатам и применить к нему освещение. Код фрагментного шейдера выглядить следующим образом:

fixed4 frag(v2f i) : SV_Target
{
    fixed4 c = tex2D(_MainTex, i.uv);
    c.rgb *= i.light;
    return c;
}

Есть. Шейдер написан. Осталось проверить его в Unity.

Походу что-то сломалось, не могу вставить гиф, посмотреть можно тут

Как можно видеть, шейдер отрабатывает корректно, интенсивность и цвет можно менять через переменные шейдера.
Также проверим работу шейдера с освещением:

Походу что-то сломалось, не могу вставить гиф, посмотреть можно тут


Как можно видеть, всё работает корректно, освещённость объекта меняется в зависимости от изменения параметров источника освещения.

На этом пока что всё. Спасибо за внимание. Знаю, статьи выходят не часто, но постараюсь исправиться и писать больше. Читаю все ваши комментарии, рад объективной критике и замечаниям.

Алексей Чернов

Team Lead at Program-Ace

Комментарии (4)


  1. UrsusDominatus
    12.11.2021 00:47
    +4

    В юнити не GLSL, а HLSL


    1. KpoKec
      16.11.2021 11:43

      Да, но внутри CGPROGRAM GLSL же


      1. UrsusDominatus
        17.11.2021 17:42

        Нет: https://docs.unity3d.com/Manual/SL-ShaderPrograms.html

        Note: Unity originally used the Cg language, hence the name of some of Unity’s keywords (CGPROGRAM) and file extensions (.cginc). Unity no longer uses Cg, but these names are still in use.


  1. osharper
    16.11.2021 20:27

    спасибо за то, что постарались учесть замечания, в том числе мои, из комментариев к первому посту. Теперь все очень подробно и понятно описано, спасибо большое!