sunLoadingImage
whowedImag
decoration left 1
decoration left 2
transhome
transprojects
transgallery
transarticles
decoration rigth
English
Українська
Show/Hide search bar
black cat logo variable logo
[17 Жов 2012]

Shadow Mapping в OpenGL та GLSL

У цьому уроці розглянуто метод мапування тіней (Shadow mapping) реалізований з допомогою OpenGL та GLSL. Метод shadow mapping є одним з найпростіших в реалізації та підтримці серед методів створення інтерактивних тіней в комп'ютерній графіці.
Метод Shadow Mapping потребує два проходи рендерінгу. Тобто сцену (чи частину сцени) необхідно відрендерити два рази. Перший прохід зберігає в текстуру глибину сцени з точки зору джерела світла. Другий прохід візуалізує сцену з точки зору камери, і додатково для кожного фрагмента перевіряє чи фрагмент освітлений, чи лежить в тіні. На зображенні показано результат роботи Shadow Mapping і методів по покращенню результатів отриманих після Shadow Mapping.
Результат роботи метода Shadow Mapping
Перший прохід рендерінгу зберігає глибину кожного фрагмента з точки зору джерела світла в текстуру. Якщо подивитися з точки зору джерела світла на сцену (розмістити камеру в позиції джерела світла і орієнтувати вздовж напряму джерела світла), то не буде видно тіней, які виникли в результаті роботи джерела світла. Відстань (глибина) від джерела світла до найближчих до джерела світла об'єктів зберігається в текстурі - карті тіней (shadow map). Тож кожен тексель shadow map містить відстань до найближчої до джерела світла точки, яка є освітленою, і точно не знаходиться у тіні. Відстані до точок, які лежать далі від джерела світла, не записуються, так як відкидаються тестом глибини. Немає значення, як виглядає сцена з точки зору джерела світла. Ці дані не будуть представлені користувачу, а будуть використані для розрахунку затіненості кожного фрагмента в другому проході рендерінгу.
На зображені показано об'єкти сцени (зелені фігури), джерело світла, об'єм простору для якого будуть записані дані в shadow map, а також показані позиції, відстань до яких буде записана у shadow map (блакитні позначки). Можна побачити, що простір за колом не записується у shadow map і невидимий з точки зору світла (закритий передньою частиною кола). Це частини сцени, які повинні бути у тіні.
Перший прохід візуалізації в Shadow Mapping
Другий прохід візуалізує сцену з точки зору камери (так як зазвичай бачить сцену користувач). Проводиться стандартна проекція вершини на екранні координати і вивід кольору у фреймбуфер. Додатково у вершинному шейдері потрібно спроектувати вершину у простір, який використовувався в першому проході для запису глибини кожної точки у shadow map (простір з точки зору джерела світла). Позиція точки в просторі світла передається у фрагментний шейдер. У фрагментний шейдер також передається shadow map (результат першого проходу рендерінгу). З допомогою позиції точки в просторі світла можна зробити вибірку з shadow map, і дістати найближчу до джерела світла відстань, яка є освітленою в напрямку поточного фрагмента. Якщо відстань з shadow map менша ніж відстань від джерела світла до поточного фрагмента, то фрагмент знаходиться у тіні (так як лежить за іншою поверхнею, відстань до якої записалася у shadow map у першому проході). Інакше - фрагмент освітлений.
Далі наведено попереднє зображення з камерою, та простором, що візуалізується камерою. Білими позначками показано частини сцени, які бачить камера, і які є освітленими. Чорними позначками показано видимі для камери частини сцени, які розміщені в тіні.
Розглянемо плоску поверхню за колом. Більша частина цієї поверхні є в тіні. Точка D0 також в тіні. Ця точка перетворюється в простір джерела світла, і розраховується її відстань до джерела світла. З shadow map вибирається значення глибини, яке відповідає поточному фрагменту. Значення з shadow map рівне Dref. Dref < D0, тож фрагмент у тіні.
Також на зображені показано частину сцени, яку видно в камеру, але яка не потрапила в частину простору, яку видно з точки зору світла (позначено знаком ?). Для цієї частини простору в shadow map дані невизначені. Для правильної роботи методу Shadow Mapping, простір, який потрапляє в shadow map повинен повністю містити простір, який видно в камеру.
Другий прохід візуалізації в Shadow Mapping
Shadow Map
Для збереження глибини сцени з точки зору джерела світла потрібна текстура, яка здатна зберегти глибину з необхідною точністю. Такою текстурою може бути однокомпонентна текстура з форматом даних GL_DEPTH_COMPONENT32. Для рендерінгу в shadow map також потрібно створити фреймбуфер. Shadow map приєднюється до фреймбуфера як місце для збереження глибини (слот GL_DEPTH_ATTACHMENT). В першому проході рендерінгу відстань від світла до кожного фрагмента запишеться у цю текстуру.
OpenGL дозволяє налаштувати текстуру таким чином, щоб операція вибірки з текстури повертала не колір текселя, а порівнювала значення в текселі з іншим значенням, яке передається разом з текстурними координатами. Це якраз те, що потрібно в шейдері другого проходу shadow mapping. Відстань до фрагмента повинна порівнюватися з відстанню в shadow map. Режим порівняння вмикається через встановлення параметра GL_TEXTURE_COMPARE_MODE в GL_COMPARE_REF_TO_TEXTURE. Також можна задати функцію порівняння через встановлення параметра GL_TEXTURE_COMPARE_FUNC. Для shadow mapping можна використати функцію порівняння GL_LEQUAL. При вибірці, якщо значення в текстурі менше за значення, що передається з текстурними координатами, то функція вибірки повертає 0 (фрагмент в тіні), інакше - 1 (фрагмент освітлений). Це порівняння можна виконувати і вручну, але можливо, що GPU проведе перевірку з правильно налаштованою текстурою швидше.
В наступному фрагменті коду наведено, як створити shadow map і фреймбуфер для рендерінгу в текстуру глибини:
// розмір shadow map
GLuint shadowMapSize = 1024;

// створеня shadow map
GLint shadowMap;
glGenTextures(1, &shadowMap);
glBindTexture(GL_TEXTURE_2D, shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32, shadowMapSize, shadowMapSize,
                  0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

// GL_CLAMP_TO_EDGE дозволяє для фрагментів, що потрапили за межі
// shadow map, вибирати найближчу розраховану глибину
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

// налаштування режиму порівняння текстури
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

////////////////////////////////////////////////////

// створити фреймбуфер
GLint shadowFramebuffer;
glGenFramebuffers(1, &shadowFramebuffer);

// приєднати shadow map до буфера
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, shadowFramebuffer);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                          GL_TEXTURE_2D, shadowMap, 0);

// глибина записується в z-buffer, до якого під'єднана shadow map,
// жодні кольори не записуються
glDrawBuffer(GL_NONE);
Матриці виду і проекції з позиції джерела світла
В шейдер як першого проходу, так і другого, потрібно передати матриці виду і проекції для перетворення в простір, який видно з позиції джерела світла.
Матриця виду shadowViewMat будується так само, як для стандартної камери - по позиції, напрямку і по вектору, що позначає напрям вгору. Позиція - позиція світла, а напрямок - напрямок джерела світла (для напрямленого джерела світла чи для джерела світла типу прожектор). Точкове джерело світла немає напрямку. Цей випадок розглянутий в наступному уроці про використання shadow mapping з точковими джерелами світла.
Матриця проекції shadowProjectionMat повинна бути такою, щоб захоплювати весь видимий з позиції світла простір, що видимий через камеру. Для зменшення кількості артефактів під час виконання методу shadow mapping, ближню площину відтинання проекції потрібно розмістити чим далі від камери, а дальню чим ближче до камери. Матриця проекції може бути як перспективною, так і ортографічною. Розрахунок матриць аналогічний, як для матриць проекції для камери.
Точність shadow map
Під час порівняння відстаней в другому проході може вийти так, що фрагмент лежить на освітленій частині сцени, але перевірка відстаней покаже, що фрагмент є у тіні (z-fighting). Це може статися через те, що позиція, яка відповідає поточному фрагменту і позиція, яка була використана для запису відстані в shadow map відрізняються на невелику величину. Для того, щоб цього не відбувалося необхідно зсунути полігони під час першого проходу рендерінгу на невелике значення від джерела світла. Відстань, яка запишеться у shadow map буде несуттєво відрізнятися від початкової, але це дозволить уникнути помилок при порівнянні відстаней у другому проході рендерінгу. Зробити зсув можна вручну, або використавши функцію glPolygonOffset. Перед другим проходом необхідно вимкнути зсув.
// активувати зсув для полігонів
glEnable(GL_POLYGON_OFFSET_FILL);
// зсув на дві найменші можливі одиниці в shadow map
// і зсув на дві одиниці залежно від нахилу полігона до напряму погляду
glPolygonOffset(2.0f, 2.0f);
Рендерінг в shadow map
Після того як створена shadow map і фреймбуфер, розраховано матриці виду та проекції з позиції джерела світла, і встановлений зсув для полігонів, можна провести рендерінг в shadow map. Розмір viewport повинен відповідати розміру shadow map, щоб кожен фрагмент в розрахунках записувався в окремий тексель в shadow map. Shadow map очищається (заповнюється) значенням 1.0f - максимальною нормалізованою відстанню від джерела світла до будь-якої точки. Далі проводиться рендерінг усіх об'єктів. Після рендерінгу необхідно вимкнути зсув, встановити фреймбуфер по замовчуванню і відновити вьюпорт до попередніх розмірів. На зображені показано приклад shadow map. Чим біліший тексель, тим більша відстань.
Приклад Shadow Map
// розмір вьюпорта повинен відповідати розміру shadow map
glViewport(0, 0, shadowMapSize, shadowMapSize);
// встановити поточний фреймбуфер
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, shadowFramebuffer);
// заповнити shadow map найбільшою можливою відстанню
glClearDepth(1.0);
glClear(GL_DEPTH_BUFFER_BIT);

// встановити шейдер, матриці та відрендерити об'єкти
glUseProgram(pass1Shader->id);
glUniformMatrix4fv(pass1Shader->shadowViewMat, 1, GL_FALSE, shadowViewMat);
glUniformMatrix4fv(pass1Shader->shadowProjMat, 1, GL_FALSE, shadowProjectionMat);
foreach(auto obj : renderables)
{
   glUniformMatrix4fv(pass1Shader->objModelMat, 1, GL_FALSE, obj->modelMat());
   obj->draw();
}

glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glDisable(GL_POLYGON_OFFSET_FILL);
glViewport(0, 0, windowWidth, windowHeight);
Шейдера для заповнення shadow map
Вершинний шейдер для збереження відстані від світла до фрагмента в shadow map:
#version 330

// атрибути
layout(location = 0) in vec3 i_position; // xyz - position

// матриці
uniform mat4 u_modelMat;
uniform mat4 u_shadowViewMat;
uniform mat4 u_shadowProjMat;

void main(void)
{
   // перетворення вершини в екранний простір з позиції джерела світла
   gl_Position = u_shadowProjMat * u_shadowViewMat * u_modelMat * vec4(i_position, 1);
}
Фрагментний шейдер для збереження відстані від світла до фрагмента в shadow map:
#version 330

// колір у фреймбуфер
layout (location = 0) out vec4 resultingColor;

void main(void)
{
   // фрагментний шейдер по замовчуванню повинен вивести колір
   resultingColor = vec4(0, 0, 0, 1);
}
Використання shadow map для візуалізації тіней
Після першого проходу рендерінгу shadow map містить необхідні для Shadow Mapping відстані в кожному текселі. Shadow map передається в фрагментиний шейдер другого проходу рендерінгу.
Візуалізація проходить з точки зору камери. Тому у вершинний шейдер потрібно передати матрицю виду і проекції, які використовуються камерою.
Також в вершиний шейдер потрібно передати матрицю виду і проекції, які використовувалися у першому проході. Цими матрицями вершина перетворюється в екранний простір з позиції джерела світла. Екранний простір є в межах [-1, 1] і використовується для визначення місця в фреймбуфері, куди записуються дані фрагмента. Ця екранна позиція використовувалася в першому проході для заповнення shadow map. Тепер екранна позиція потрібна для того, щоб розрахувати текстурні координати, через які можна зробити вибірку відстані з shadow map, що відповідає поточному фрагменту. Для цього позицію фрагмента з екранних координат [-1, 1] потрібно перевести в текстурні [0, 1]. Це можна зробити з допомогою матрицю зсуву offsetMat. Додатково матриця зсуву комбінується в одну матрицю разом з матрицями виду і проекції з позиції джерела світла - shadowMat. Перетворивши вершину через shadowMat, отримаємо текстурні координати через які можна зробити вибірку з shadow map. В наступному коді показано як розрахувати shadowMat:
glm::mat4 shadowViewMat = ... // матриця виду з позиції дежерела світла
glm::mat4 shadowProjMat = ... // матриця проекції з позиції джерела світла

// матриця зсуву переводить координати з меж [-1, 1] в межі [0, 1]
glm::mat4 offsetMat = glm::mat4(
      glm::vec4(0.5f, 0.0f, 0.0f, 0.0f),
      glm::vec4(0.0f, 0.5f, 0.0f, 0.0f),
      glm::vec4(0.0f, 0.0f, 0.5f, 0.0f),
      glm::vec4(0.5f, 0.5f, 0.5f, 1.0f)
   );

// комбінація матриць виду, проекції і зсуву
shadowMat = offsetMat * shadowProjMat * shadowViewMat;
Також варто зазначити, що екранна координата передається у форматі vec4, тобто містить чотири компоненти. Перші два (xy) визначають позицію на екрані, третій (z) - відстань від джерела світла, четвертий (w) - необхідний для правильного афінного перетворення. Якщо w не рівне 1, то всі інші компоненти необхідно розділити на w. Це можна зробити автоматично через використання в шейдері функції для вибірки textureProj() і типу семплера - sampler2DShadow. Функція textureProj() поводиться як звичайна функція вибірки texture(), але додатково перед вибіркою проводить ділення на w.
Розрахована позиція вершини в екраних координатах з точки зору джерела світла передається у фрагментний шейдер. З допомогою цієї позиції у фрагментному шейдері проводиться вибірка з shadow map. Так як текстура налаштована на порівняння, то вибірка поверне 1, якщо поточний фрагмент освітлений, і 0, якщо фрагмент у тіні. Освітлення можна домножити на це значення. Тобто, якщо фрагмент у тіні, то інтенсивність освітлення дифузного і відбитого освітлення домножується на 0.
Шейдера для Shadow Mapping
Вершинний шейдер для перетворення позиції вершини в текстурні координати для доступу до shadow map:
#version 330

// атрибути
layout(location = 0) in vec3 i_position; // xyz - position

// матриці
uniform mat4 u_modelMat;
uniform mat4 u_viewProjectionMat;
uniform mat4 u_shadowMat;

// текстурні координати в фрагментиний шейдер
// для доступу в shadow map
out vec4 o_shadowCoord;

void main(void)
{
   // позиція вершини в сцені
   vec4 worldPos = u_modelMat * vec4(i_position, 1);

   // позиція в екранних координатах відносно джерела світла
   // і додатково перетворена через shadowMat з меж [-1, 1]
   // в межі [0, 1], для можливості використання в ролі
   // текстурних координат

   o_shadowCoord = u_shadowMat * worldPos;

   // позиція в екранних координатах відносно камери
   gl_Position = u_viewProjectionMat * worldPos;
}
Фрагментний шейдер, який використовує shadow map та текстурні координати відносно джерела світла для розрахунку того, чи фрагмент освітлений, чи в тіні:
#version 330

// текстура shadow map з першого проходу рендерінгу
layout(location = 0) uniform sampler2DShadow u_shadowMap;

// текстурні координати для доступу до shadow map
in vec4 o_shadowCoord;

// колір у фреймбуфер
layout(location = 0) out vec4 resultingColor;

void main(void)
{
   // вибірка з shadow map
   float shadowFactor = textureProj(u_shadowMap, o_shadowCoord);

   // запис в фреймбуфер білого чи чорного кольору (світло/тінь)
   resultingColor.rgb = vec3(shadowFactor);
   resultingColor.a = 1;
}
Результати роботи shadow mapping
На зображеннях паказано результат роботи метода Shadow Mapping. Не схоже на зображення наведене на початку уроку, правда? Це тому, що без додаткової обробки метод Shadow Mapping не дає хороших результатів. Покращення результатів метода Shadow Mapping розглянуто в наступному уроці.
Результат роботи метода Shadow Mapping з розміром shadow map = 1024 текселі
Результат роботи метода Shadow Mapping з розміром shadow map = 256 текселі
На першому зображені наведено результат роботи метода Shadow Mapping з розміром shadow map в 1024 текселі. Без приближення в тінях майже не помітно аліасінгу, але на сфері і на моделі кота помітні артефакти. На другому зображені показано результат роботи метода Shadow Mapping зі зменшеним до 256 текселів розміром shadow map. Стали помітними зубчастості на текстурі. Ці артефакти виникають по причині того, що shadow map є дискретною текстурою, і зберігає скінченну кількість відстаней від джерела світла до об'єктів у сцені. Коли проводиться другий прохід рендерінгу, виходить так, що одному текселю в shadow map відповідає багато фрагментів, які бачить камера.
Кодування floating point значення в RGBA8 значення
Якщо floating point текстури недоступні, то значення відстані [0, 1] можна закодувати і зберегти в стандартній RGBA текстурі, де кожен компонент має 8 біт. Щоб використати значення відстані з такої текстури, потрібно зкомбінувати усі чотири RGBA значення. В наступному фрагменті коду наведено функції, які кодують floating point значення в RGBA8, і навпаки.
// функція, що переводить float в RBGA
vec4 encode(float val)
{
   vec4 o;
   val *= 255;
   o.r = floor(val);
   val = (val - o.r) * 255;
   o.g = floor(val);
   val = (val - o.g) * 255;
   o.b = floor(val);
   val = (val - o.b) * 255;
   o.a = floor(val);
   return o;
}

// функція, що переводить RGBA в float
float decode(vec4 val)
{
   return val.r/255.0
      + val.g/(255.0*255.0)
      + val.b/(255.0*255.0*255.0)
      + val.a/(255.0*255.0*255.0*255.0);
}



Sun and Black Cat- Ігор Дихта (igor dykhta email) © 2007-2014