Базовые модели освещения: Ламберт, Фонг, Блинн-Фонг

Введение

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

В русскоязычном сегменте интернета эти модели называются моделями освещения, что не вполне корректно, так как во-первых в английском языке они называются "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:

А вот и итоговый результат: