Базовые модели освещения

Введение

Эта статья - вступительная на этом сайте в разделе "компьютерная графика". Сегодня мы рассмотрим основные принципы затенения полигональных моделей в современной компьютерной графике на примере трёх базовых моделей затенения.

В русскоязычном сегменте интернета эти модели называются моделями освещения, что не вполне корректно, так как во-первых в английском языке они называются "shading model", что буквально переводится как "модель затенения", а во-вторых, в понятие "модель освещения" разумно включать все компоненты освещения, из которых складывается итоговая картинка: непрямое освещение (indirect lighting), рассеянное освещение (ambient occlusion), тени, отражения и т.д.

Модели затенения можно грубо разделить на два типа: физически достоверные и эмпирические.

Физически обоснованные модели ставят перед собой задачу выдать результат, максимально приближенный к тому, который бы увидел пользователь в такой же ситуации в реальном мире. Для достижения этой задачи учитываются не только базовые законы распространения света, но и компоненты, которые не так часто моделируются при написании, скажем, компьютерных игр. Примерами подобных эффектов, может быть, например, плёнка на поверхности металла, распространение света в воске или коже, моделирование сложносоставного материала (например, моделирование поверхности автомобиля типа "металлик") и т.д.

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

В этой статье мы рассмотрим три базовых эмпирических модели затенения: модель Ламберта, модель Фонга, и модель Блинна-Фонга. Именно от этих моделей отталкиваются разработчики компьютерных игр, усложняя их до необходимого уровня. Кроме того, разобрав эти модели мы поймём принципы, которыми руководствуются разработчики при расчёте освещённости полигональных моделей.

Инфраструктура

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

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

Компоненты затенения

В стандартных моделях затенения итоговая освещённость складывается, обычно, из трёх компонент:

  1. Фоновая составляющая (ambient)
  2. Рассеянная составляющая (diffuse)
  3. Зеркальная составляющая (specular)

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

Рассеянная составляющая: эта компонента рассчитывается по закону Ламберта, исходя из предположения, что при попадании на поверхность свет рассеивается равномерно во все стороны.

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

Итоговая формула освещённости в точке выглядит примерно следующим образом (I - intencity):

Визуально, вклад каждой из компонент в итоговый результат, выглядит примерно следующим образом:

Первые шаги: выводим нормали

Для начала напишем очень простой разминочный шейдер, в котором просто выведем нормали нашего объекта:

Развернуть
Shader "karonator/JustNormals"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float3 normal: NORMAL0;
            };

            fragment_data vert(vertex_data v)
            {
                fragment_data o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.normal = v.normal;
                return o;
            }

            fixed4 frag(fragment_data i): COLOR
            {
                return float4(i.normal, 1.0);
            }

            ENDCG
        }
    }
}

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

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

После это интерполяции нормаль может быть не нормализованной (длина вектора будет не равна 1).

Для расчётов это важно, поэтому после передачи во фрагментный шейдер данные нужно заново нормализовывать.

Модель Ламберта

Модель Ламберта - модель затенения, описывающая идеальное диффузное освещение. Считается, что свет при попадании на поверхность рассеивается равномерно во все стороны.

Для расчёта освещённости в точке сцены нам понадобится нормаль в этой точке, и направление на источник света:

Освещение по закону Ламберта считается по косинусу между нормалью N и направлением на источник света L: чем угол между этими векторами больше, тем значение косинуса ближе к единице (свет падает прямо и точка освещена хорошо).

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

Итоговая формула освещённости по модели Ламберта:

Обратите внимание, между векторами берётся скалярное произведение: оно будет равно косинусу между векторами, если вектора будут нормализованными (мы об этом позаботимся).

Настало время написать реализующий эту формулу шейдер:

Развернуть
Shader "karonator/Shading/Lambert"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;
                float3 normal: NORMAL0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                
                // Переводим нормаль и координату точки в мировое пространство
                // и отправляем в фрагментный шейдер
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);
                o.normal = mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz;

                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
            	// Рассчитываем и нормализуем направление на источник света
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz);

                // После передачи во фрагментный шейдер нормаль тоже надо нормализовать
                float3 N = normalize(i.normal);

                float result = max(dot(L, N), 0.0);
                return result;
            }

            ENDCG
        }
    }

    Fallback "VertexLit" 
}

В шейдере для получения координаты источника света мы используем константу _WorldSpaceLightPos0.

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

Область значения косинуса находится в интервале от -1 до 1. Освещённость же мы считаем в интервале от 0 до 1. Чтобы наш шейдер не вернул отрицательное значение, мы сравниваем значение косинуса с нулём (функция max).

Результат работы шейдера:

Модель Фонга

Модель Фонга базируется на модели Ламберта, но добавляет обработку зеркальной составляющей у визуализируемого материала. Давайте рассмотрим следующую схему:

Как вы видите, в отличие от приведённой схемы для модели Ламберта, в модели Фонга у нас добавилось два новых вектора: направление из точки сцены на наблюдателя (камеру) V (view) и отражённый от точки луч света R (reflection).

Модель Фонга работает следующим образом: чем меньше угол между векторами R и V, тем ярче должна быть освещена визуализируемая точка. Неформально эту логику можно объяснить следующим образом: маленький угол между этими векторами означает, что отражаясь от точки на сцене луч из источника света попадает в камеру, "ослепляя её", что визуально видно как блик.

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

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

Давайте теперь реализуем это в виде шейдера:

Развернуть
Shader "karonator/Shading/Phong"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
                float2 uv: TEXCOORD0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;

                float3 normal: NORMAL0;
                float2 uv : TEXCOORD0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);
                o.normal = mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz;
                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz);
                float3 N = normalize(i.normal);

                float3 V = normalize(_WorldSpaceCameraPos.xyz - i.pos_world.xyz); // направление на камеру
                float3 R = reflect(-L, N); // отраженный относительно нормали вектор направления на источник света

                float diff = max(dot(L, N), 0.0); // диффузная составляющая считается по модели Ламберта
                float spec = pow(saturate(dot(R, V)), 8); // saturate ограничивает значение в пределах 0, 1

                // при желании можно добавить ambient компоненту
                return diff + spec;
            }

            ENDCG
        }
    }
}

Давайте визуализируем бликовую составляющую для n равному 8, 16, 64:

А вот и итоговый результат (диффузная + бликовая составляющая):

Модель Блинна-Фонга

Как уже описывалось выше, бликовая составляющая в модели Фонга базируется на косинусе между отражённым вектором (R) и вектором взгляда (V). При таком подходе возникает проблема, связанная с тем, что если угол между векторами R и V больше 90 градусов, косинус будет отрицательным, что приведёт к полному отсутствию бликовой составляющей. Смоделировать этот пример легко, просто подведя источнико света близко к плоскости:

На изображении виден резкий переход, как раз когда угол становится больше 90 градусов. Для решения этой проблемы в 1977 году Джеймсом Блинном было предложено улучшение модели Фонга (которое в итоге и стало моделью Блинна-Фонга). Идея заключается во введении понятия т.н. медианного вектора (halfway vector). Как и в модели Фонга, в модели Блинна-Фонга используется модель Ламберта для расчёта диффузной составляющей, но бликовая составляющая считается иначе.

Вместо отражённого вектора (R) предлагается использовать вектор, который находится строго между вектором взгляда (V) и вектором освещённости (L). Чем меньше угол между этим вектором и нормалью, тем более яркая будет бликовая составляющая в точке. При этом, этот угол никогда не будет больше 90 градусов, что автоматически решает проблему, присутствующую в исходной модели Фонга.

Рассмотрим изображения ниже:

В общем, понятно, что в любом из вариантов расположения камеры и источника света, угол между нормалью и медианным вектором не будет больше 90 градусов. при этом он очень просто находится (достаточно сложить вектор взгляда V и вектор освещённости L и нормализовать получившийся результат). Ниже приведён шейдер с комментариями, реализующий модель Блинна-Фонга:

Развернуть
Shader "karonator/Shading/BlinnPhong"
{
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
                float2 uv: TEXCOORD0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;

                float3 normal: NORMAL0;
                float2 uv : TEXCOORD0;
            };

            fragment_data vert (vertex_data v)
            {
                fragment_data o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);

                o.uv = v.uv;
                o.normal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz);
                return o;
            }

            fixed4 frag (fragment_data i):COLOR
            {
                float3 L = normalize(_WorldSpaceLightPos0.xyz - i.pos_world.xyz); // направление на источник света
                float3 N = normalize(i.normal);
                float3 V = normalize(_WorldSpaceCameraPos.xyz - i.pos_world.xyz); // направление на камеру

                float3 M = normalize(L + V); // медианный ветктор (между углом падения света и направлением на камеру)

                float diff = max(dot(L, N), 0.0);
                float spec = pow(saturate(dot(M, N)), 16); // saturate ограничивает значение в пределах 0, 1

                // при желании можно добавить ambient компоненту
                return diff + spec;
            }

            ENDCG
        }
    }

    Fallback "VertexLit" 
}

Давайте сравним краевые ситуации для модели Фонга и модели Блинна-Фонга:

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

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