В цьому 8 туторіалі ми вивчимо як зробити деяке базове (найпростіше) затінення. Це включає:

  • Робити об’єкти трішки яскравішими, коли вони ближче до джерела світла
  • Наявність відблисків під час відбиття світлу (specular lighting - дзеркальне освітлення)
  • Робити об’єкти темнішими, коли світло не направлено безпосередньо на модель (дифузне освітлення)
  • Багато обманювати (навколишнє освітлення)

А наступного не включає:

  • Тіні. Це велика тема, що заслуговує окремого туторіалу.
  • Дзеркально-подібне освітлення (включаючи воду)
  • Будь-яке складне освітлення, таке як “підповерхневе освітлення” (наприклад, віск)
  • Анізотропний матеріал (наприклад, фарбований метал)
  • Затінення, що базується на фізиці та намагається мімікрувати під реальність
  • Ambient Occlusion (темнота в печері)
  • Розтікання кольору (червоні шкарпетки роблять білу підлогу трішки червоною)
  • Прозорість
  • Будь-яке глобальне освітлення (це включає все попереднє)

Одним слово - базове.

Нормалі

Декілька останніх туторіалів ми говорили про нормалі але не повністю розуміли, що це таке.

Нормалі трикутника

Нормаль до площини - це вектор довжини 1, який перпендикулярний до цієї площини.

Нормаль до трикутника - це вектор довжини 1, який перпендикулярний до цього трикутника. Його легко порахувати, якщо взяти векторний добуток двох ребер цього трикутника (векторний добуток a та b дає вектор, яки перпендикулярний до a та b, пам’ятаєте?) і нормалізувати - це зробить його довжину рівною 1. В псевдокоді це буде так:

triangle ( v1, v2, v3 )
edge1 = v2-v1
edge2 = v3-v1
triangle.normal = cross(edge1, edge2).normalize()

Не плутайте нормаль (normal) і функцію normalize(). Normalize() ділить вектор (не обов’язково нормаль) на його довжину, таким чином довжина вектора буде 1. Нормаль - це про назва вектора, яки представляє собою нормаль.

Нормалі вершин

Ми будемо називати нормаллю вершини комбінацію нормалей трикутників навколо неї. Це зручно, тому що в вершинному шейдері ми маємо справу з вершинами, а не з трикутниками, отже краще мати інформацію про вершини. І в будь-якому випадку, ми не маємо змоги мати інформацію про трикутники в OpenGL. В псевдокоді:

vertex v1, v2, v3, ....
triangle tr1, tr2, tr3 // Всі трикутники, що мають вершиною v1
v1.normal = normalize( tr1.normal + tr2.normal + tr3.normal )

Використання нормалей вершин в OpenGL

Використання нормалей в OpenGL дуже просте. Нормаль це просто атрибут вершини, так само як позиція, колір, UV координати. Тому це просто звичайна робота. Наша функція loadOBJ з туторіалу 7 вже читає їх з OBJ файлу.

GLuint normalbuffer;
 glGenBuffers(1, &normalbuffer);
 glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
 glBufferData(GL_ARRAY_BUFFER, normals.size() * sizeof(glm::vec3), &normals[0], GL_STATIC_DRAW);

та

 // 3 буфер атрибутів - нормалі
 glEnableVertexAttribArray(2);
 glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
 glVertexAttribPointer(
     2,                                // атрибут
     3,                                // розмір
     GL_FLOAT,                         // тип
     GL_FALSE,                         // нормалізувати?
     0,                                // stride
     (void*)0                          // зміщення в буфері (де початок даних)
 );

І цього достатньо для початку.

Дифузна частина

Важливість нормалей поверхні

Коли світло падає на об’єкт, велика частина відбивається у всіх напрямках. Це називається “дифузний компонент” (Дуже скоро ми побачимо що трапляється з іншою частиною).

Коли певний потік світла попадає на поверхню, вона світиться по різному в залежності від кута, під яким світло попало на неї.

Якщо світло перпендикулярне до поверхні, воно концентрується на невеликій частині поверхні. Якщо світло попадає під певним кутом до поверхні, то світло поширюється на більшу площу:

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

Це значить, що коли ми розраховуємо колір пікселя, кут між світлом, що приходить і нормаллю поверхні має значення. Отже маємо:

// косинус кута між нормаллю і напрямком світла, зафіксований вище 0
// (примітка перекладача - в наступних двох рядках була помилка, говорили про трикутник, а не нормаль)
//  - світло паралельно нормалі -> 1
//  - світло перпендикулярно нормалі -> 0
float cosTheta = dot( n,l );

color = LightColor * cosTheta;

В цьому коді n це нормаль до поверхні і l- одиничний вектор який направлений від поверхні до джерела світла (і це не помилка, хоча це і контрінтуітивно. Просто це робить математику простіше).

Звертайте увагу на знак

Дещо відсутнє в нашій формулі cosTheta. Якщо світло знаходиться за трикутником, n і l протилежні, тобто n.l < 0. Це означатиме, що colour = якесь число, менше нуля, що не має сенсу. Отже, нам потрібно використати функцію clamp, якою ми прирівняємо всі значення менше нуля до нуля:

// Косинус кута між нормаллю і напрямком світла та обмежене знизу значення нуль.
//  - світло паралельно нормалі -> 1
//  - світло перпендикулярно до нормалі -> 0
//  - світло за трикутником -> 0
float cosTheta = clamp( dot( n,l ), 0,1 );

color = LightColor * cosTheta;

Колір матеріалу

Звичайно, вихідний колір також залежить від кольору матеріалу. В цьому зображенні білий колір складається з зеленого, червоного та синього кольору. Коли він зіштовхується з червоним матеріалом, зелений і синій колір поглинається і тільки червоний залишається.

Ми можемо змоделювати це звичайним множенням:

color = MaterialDiffuseColor * LightColor * cosTheta;

Моделювання світла

Спочатку ми припустили, що у нас є точкове освітлення, що випромінює в всіх напрямках, щось схоже на свічку.

З таким освітленням світловий потік, який наша поверхня буде отримувати залежить від відстані до джерела світла - чим далі, тим менше світла. Насправді, кількість світла буде зменшуватись пропорційно квадрату відстані:

color = MaterialDiffuseColor * LightColor * cosTheta / (distance*distance);

І нарешті нам потрібен ще один параметр для контролю потужності світла. Він буде закодований в LightColor (і буде в наступному туторіалі), але нехай поки буде просто колір (тобто білий) і потужність (тобто 60 ватт).

color = MaterialDiffuseColor * LightColor * LightPower * cosTheta / (distance*distance);

Складаємо все в одну купу

Що б цей код працював, нам потрібна жменька параметрів (різноманітні кольори і потужність) і трішки коду.

MaterialDiffuseColor отримується з текстури.

LightColor та LightPower отримується через uniform змінні в GLSL.

cosTheta залежить від n та l. Ми можемо виразити їх в будь-якому просторі, головне в одному й тому же. Ми обрали простір камери, тому що це в ньому простіше розраховувати положення світла:

// Нормаль до розрахованого фрагменту, в просторі камери
 vec3 n = normalize( Normal_cameraspace );
 // Напрям світла (від фрагмента до світла)
 vec3 l = normalize( LightDirection_cameraspace );

з Normal_cameraspace та LightDirection_cameraspace розрахованих в вершинному шейдері і переданих в фрагментний шейдер :

// Вихідна позиція в вершинному шейдері, в просторі обрізання : MVP * position
gl_Position =  MVP * vec4(vertexPosition_modelspace,1);

// Позиція вершини в світовому просторі : M * position
Position_worldspace = (M * vec4(vertexPosition_modelspace,1)).xyz;

// Вектор, який направлений з вершини до камери, в просторі камери.
// В просторі камери, камера знаходиться в початку координат (0,0,0).
vec3 vertexPosition_cameraspace = ( V * M * vec4(vertexPosition_modelspace,1)).xyz;
EyeDirection_cameraspace = vec3(0,0,0) - vertexPosition_cameraspace;

// Вектор, який направлений з вершини до світла, в просторі камери. M не враховується, тому що це одиничний вектор.
vec3 LightPosition_cameraspace = ( V * vec4(LightPosition_worldspace,1)).xyz;
LightDirection_cameraspace = LightPosition_cameraspace + EyeDirection_cameraspace;

// Нормаль до вершини, в просторі камери
Normal_cameraspace = ( V * M * vec4(vertexNormal_modelspace,0)).xyz; // Коректно тільки якщо `ModelMatrix` не маштабовано на моделі. Використовуйте зворотнє перенесення, якщо це не так.

Цей код може виглядати неймовірно, але тут нема нічого такого, чого би ми не бачили в туторіалі 3 - матриці. Я приділив багато уваги тому, що підписати прості для кожного вектора, що б було легше слідкувати за тим, що відбувається Рекомендую Вам теж так робити.

M та V це матриці моделі та виду, що передаються в шейдери аналогічно MVP.

Час попрацювати

Ви маєте все, що потрібно для кодування дифузійного освітлення. Продовжуйте вчитись :)

Результат

Використовуючи лише дифузійну складову, ми маємо наступний результат (вибачте за ці текстури):

Це краще, ніж було до цього, але все ще багато що відсутнє. Наприклад, спина Сюзанни повністю чорна, так як ми використовуємо clamp().

Навколишнє освітлення (Ambient)

Навколишнє освітлення в графіці це велике шахрайство:)

Ми очікуємо, що спина Сюзани буде отримувати більше світла в реальному житті, тому що лампа буде освітлювати стіну за нею, яка трішки освітить задню частину об’єкта.

Це потребує дуже багато обчислень.

Тому зазвичай просто додають трішки несправжнього освітлення. Тобто, ми робимо так, що 3д модель випромінює трішки світла, що б вона не виглядало повністю чорною.

Це може бути реалізовано наступним чином:

vec3 MaterialAmbientColor = vec3(0.1,0.1,0.1) * MaterialDiffuseColor;
color =
 // навколишнє освітлення - симулюємо непряме освітлення
 MaterialAmbientColor +
 // дифузія : "колір" об'єкту
 MaterialDiffuseColor * LightColor * LightPower * cosTheta / (distance*distance) ;

Давайте подивимось на результат.

Результати

Тепер воно трішки краще. Ви можете відрегулювати (0.1, 0.1, 0.1) для кращого результату.

Дзеркальне відбиття

Інша частина відбитого світла відбивається в основному в напряму “відбиття” від поверхні. Це називається дзеркальним компонентом.

Як видно з зображення, воно має форму “пелюстки” (???). В крайніх випадках, дифузна компонента може бути відсутню, пелюстка відбиття може бути дуже-дуже вузькою (все світло буде відбиватись в одному напрямку) і Ви отримаєте дзеркало. (Ми можемо маніпулювати параметрами для отримання дзеркала, але в нашому випадку, але єдине, що ми побачимо зараз - це наша лампа. Це буде дивне дзеркало)

// Вектор очей (в напрямку камери)
vec3 E = normalize(EyeDirection_cameraspace);
// Напрямок, в якому трикутник відбиває світло
vec3 R = reflect(-l,n);
// косинус кута між вектором очей і вектором відбиття
// обмежений 0 знизу
//  - погляд в відображення -> 1
//  - погляд в сторону -> < 1
float cosAlpha = clamp( dot( E,R ), 0,1 );

color =
    // Навколишнє освітлення
    MaterialAmbientColor +
    // Дифузний колір об'єкту
    MaterialDiffuseColor * LightColor * LightPower * cosTheta / (distance*distance) ;
    // дзеркальне відображення, як дзеркало
    MaterialSpecularColor * LightColor * LightPower * pow(cosAlpha,5) / (distance*distance);

R це напрямок в якому відбивається світло. E - інвертований напрямок очей (погляду) (так само, як ми зробили для l). Якщо цей кут невеликий, це значить, що ми дивимось прямо в відображення.

pow(cosAlpha,5) використовується для контролю ширини “пелюстки” відображення. Збільшуйте 5 для вужчої області (пелюстки).

Фінальний результат

Зверніть увагу на дзеркальні відблиски на носі та бровах.

Ця модель затінення використовувалась роками тому що вона достатньо проста. Вона має безліч недоліків, тому вона була замінена на моделях, що базуються на фізиці, наприклад “microfacet BRDF”, але ми повернемося до цього пізніше.

В наступному туторіалі ми вивчимо як покращити продуктивність Ваших VBO. Це буде перший туторіал середнього рівня!