В туторіалі 15 ми вивчили, як створити світлові карти, які охоплюють статичне освітлення. Хоча вони дають гарні тіні, вони не працюють з анімованими моделями.

Карти тіней - це сучасний (станом на 2016 рік) спосіб створити динамічні тіні. І у них є чудова властивість - їх легко запрограмувати. Погана сторона - це те, що їх страшенно важко заставити працювати правильно.

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

Основи карти тіней

Основний алгоритм карти тіней складається з двох кроків. Спочатку, сцена малюється з точки зору світла. Обчислюється тільки глибина кожного фрагмента. Далі, сцена малюється як звичайно, але з додатковою перевіркою, що б побачити чи знаходиться поточний фрагмент в тіні.

Тест “перебування в тіні” насправді достатньо простий. Якщо поточний зразок знаходить далі від світла, ніж карта тіней в одній і тій же точці, це значить, що сцена містить інший об’єкт, який ближче до світла. Іншими словами поточний фрагмент знаходиться в його тіні.

Наступне зображення може допомогти Вам зрозуміти цей принцип:

Малювання карти тіней

В цьому туторіалі ми будемо розглядати направлене світло - світло, яке знаходиться на великій відстані і промені можна вважати паралельними. Тому, малювання карти тіней здійснюється за допомогою ортографічної проекційної матриці. Ортографічна матриця подібна звичайній перспективній проекційній матриці, за виключенням того, що перспектива не враховується - об’єкт буде виглядати одинаково, не залежно від відстані до камери.

Налаштування цілей малювання та матриці MVP

З туторіалу 14 Ви повинні знати, як намалювати сцену в текстуру для того, що б потом мати доступ до неї з шейдеру.

Ми будемо використовувати карту тіней в 16 бітній текстурі 1024x1024. 16 біт зазвичай достатньо для карти тіней. Не бійтесь експериментувати з цими значеннями. Зверніть увагу, що ми використовуємо текстуру глибин, а не рендер буфер глибини, «тому що нам потрібно зробити вибірку пізніше».

// The framebuffer, which regroups 0, 1, or more textures, and 0 or 1 depth buffer.
 GLuint FramebufferName = 0;
 glGenFramebuffers(1, &FramebufferName);
 glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName);

 // Текстура глибин. Повільніше чим буфер глибин, але ви можете робити вибірки пізніше в шейдері
 GLuint depthTexture;
 glGenTextures(1, &depthTexture);
 glBindTexture(GL_TEXTURE_2D, depthTexture);
 glTexImage2D(GL_TEXTURE_2D, 0,GL_DEPTH_COMPONENT16, 1024, 1024, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

 glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture, 0);

 glDrawBuffer(GL_NONE); // Не малюємо в буфер кольору також

 // Завжди перевіряємо, що з нашим буфером фрейму все ок
 if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
 return false;

MVP матриця що використовується для малювання сцени з точки світла обчислюється наступним чином:

  • Матриця проекції є ортографічною матрицею, яка буде охоплювати все в коробці розмірами (-10,10),(-10,10),(-10,20), сторони якої співпадають з осями X, Y та Z відповідно. Ці значення такі, що б вся видима сцена була завжди видимою, більше про в секції “Йдемо далі”.
  • Матриця виду повертає світ так, що в просторі камери світло направлене по -Z (можете перечитати Туторіал 3 ?)
  • Матриця моделі - яка бажаєте.
 glm::vec3 lightInvDir = glm::vec3(0.5f,2,2);
 // Обчислимо матрицю MVP з позиції освітлення
 glm::mat4 depthProjectionMatrix = glm::ortho<float>(-10,10,-10,10,-10,20);
 glm::mat4 depthViewMatrix = glm::lookAt(lightInvDir, glm::vec3(0,0,0), glm::vec3(0,1,0));
 glm::mat4 depthModelMatrix = glm::mat4(1.0);
 glm::mat4 depthMVP = depthProjectionMatrix * depthViewMatrix * depthModelMatrix;

 // Відсилаємо нашу трансформацію до поточного шейдеру,
 // в "MVP" uniform
 glUniformMatrix4fv(depthMatrixID, 1, GL_FALSE, &depthMVP[0][0])

Шейдери

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

#version 330 core

// вхідні данні вершини, різні для всіх запусків цього шейдеру.
layout(location = 0) in vec3 vertexPosition_modelspace;

// значення, які залишаються постійними для всього меша
uniform mat4 depthMVP;

void main(){
 gl_Position =  depthMVP * vec4(vertexPosition_modelspace,1);
}

Фрагментний шейдер теж простий - він просто записую глибину фрагмента в location 0 (тобто в нашу текстуру глибини).

#version 330 core

// Вихідні данні
layout(location = 0) out float fragmentdepth;

void main(){
    // Не дуже потрібно, OpenGL зробить це в будь-якому випадку
    fragmentdepth = gl_FragCoord.z;
}

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

Результат

Вихідна текстура буде виглядати десь так:

Темний колір це невелике значення z, отже правий верхній кут стіни знаходиться близько до камери. І навпаки, білий колір значить z=1 (в однорідних координатах), отже це “дуже далеко”.

Використання карти тіней

Базовий шейдер

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

Для цього ми повинні розрахувати координати фрагменту в тому самому просторі, що ми використовували для створення карти тіней. Отже ми повинні трансформувати їх спочатку за допомогою звичайної MVP матриці, і потім ще за допомогою depthMVP матриці.

Однак тут є одна маленька хитрість. Множення положення вершини на depthMVP дає однорідні координати, які знаходяться в [-1,1], але текстури повинні бути в діапазоні [0,1].

Наприклад, фрагмент в центрі екрану має координати (0,0) в однорідних координатах, але так як це середина текстури, то UV координати будуть (0.5, 0.5).

Це може бути виправлено налаштуванням координат безпосередньо в фрагментному шейдері, але більш ефективно помножити координати на наступну матрицю, яка просто ділить координати на 2 (діагональ: [-1,1]->[-0.5,0.5]) і потім робить перенос (нижній рядок : [-0.5, 0.5] -> [0,1] ).

glm::mat4 biasMatrix(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0
);
glm::mat4 depthBiasMVP = biasMatrix*depthMVP;

Тепер ми маємо змогу написати наш вершинний шейдер. Він такий самий, як і раніше, але на виході має дві позиції, а не одну:

  • gl_Position це позиція вершини з точки зору поточної камери
  • ShadowCoord це позиція вершини з точки зору “останньої камери” (світла)
// Вихідна позиція вершини в просторі ==clip== : MVP * position
gl_Position =  MVP * vec4(vertexPosition_modelspace,1);

// тех саме, але матриця освітлення
ShadowCoord = DepthBiasMVP * vec4(vertexPosition_modelspace,1);

Фрагментний шейдер тепер досить простий:

  • texture( shadowMap, ShadowCoord.xy ).z це відстань між світлом та найближчим ==окклюдером/occluder==
  • ShadowCoord.z це відстань між світом та поточним фрагментом

… отже, якщо поточний фрагмент далі, чим найближчий окклюдер, це значить що ми в тіні (згаданого вище окклюдера) :

float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z){
    visibility = 0.5;
}

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

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

Результат - тіньові вугрі

Ось результат нашого коду. Очевидно, що воно правдоподібно, але якість неприйнятна.

Давайте поглянемо на кожну проблему на цьому зображені. В коді є два проекти shadowmaps та shadowmaps_simple, розпочніть з будь-якого. Другий проект (спрощена версія) така ж страшна, як і зображення вище, але простіша для розуміння.

Проблеми

Тіньові вугрі

Найбільш очевидна проблема називається тіньові вугрі (або прищі/акне)

Цей ефект легко пояснити за допомогою простого зображення:

Типове “виправлення” для цієї проблеми - додати похибку - ми будемо затіняти тільки якщо поточна глибина фрагменту (пам’ятайте, в просторі світла) знаходиться дуже далеко від значення освітлення. Ми зробимо це за допомогою “коефіцієнту упередженості” (bias, можливо краще “допуску”):

float bias = 0.005;
float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z-bias){
    visibility = 0.5;
}

І результат тепер значно краще :

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

Загальний підхід - модифікувати коефіцієнт упередження відповідно до нахилу:

float bias = 0.005*tan(acos(cosTheta)); // cosTheta це dot( n,l ), затиснуте в діапазоні 0 та 1
bias = clamp(bias, 0,0.01);

І тепер вугрі пропали, навіть на вигнутих поверхнях.

Інший трюк, який може працювати (а може і ні, в залежності від Вашої геометрії) це малювання тільки задніх поверхонь на карту тіней. Це вимагає від нас мати спеціальну геометрію (дивіться наступну секцію - “бути Пітером Пеном”) з товстими стінами, але вугрі будуть на поверхнях, які в тіні:

Під час малювання карти тіней, відсікайте передні трикутники:

        // Ми не використовуємо "упередження" в шейдері, а натомість малюємо задні поверхні,
        // які вже відділені від передніх поверхонь невеликою відстанню
        // (Якщо у Вас відповідна геометрія)
        glCullFace(GL_FRONT); // Відсікання передніх трикутників (нормаль направлена до нас) - малювання задніх трикутників

І коли будите малювати сцену, малюйте нормально (відсікання задніх трикутників)

         glCullFace(GL_BACK); // Відсікання задніх трикутників -> малювання передніх

Цей метод використовується в коді, на додаток до методу “упередження”.

Бути Пітером Пеном

Ми позбулись вугрів, але у нас все ще є невірна тінь біля землі (підлоги), і виглядає так, наче наші стіни “літають” (звідси і Пітер Пен). А додавання “упередження” робить все ще гірше.

Це дуже легко виправити - просто не потрібно використовувати тонку геометрію. Це має дві переваги:

  • По перше, це вирішує проблему з “Пітером Пеном” - як тільки Ваша геометрія більш глибша, ніж “упередження”, все добре.
  • По друге, Ви можете ввімкнути відсікання задньої поверхні коли малюєте світлову карту, тому що тепер тут є полігон стіни, який освітлюється та закриває іншу сторону, яка не буде малюватись з ввімкненим відсіканням задньої поверхні.

Мінус цього методу в тому, що потрібно малювати більше трикутників (вдвічі більше на кадр!).

Аліасинг (накладання - Aliasing)

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

PCF

Найпростіший спосіб покращити це - це мінити семплер карти тіней на sampler2DShadow. Як наслідок, коли Ви обробляєте карту тіней, “залізо” буде по факту обробляти і сусідні текселі, робити порівняння їх всіх і повертати дійсне число в діапазоні [0,1], що є білінійною фільтрацією результату порівняння.

Наприклад, 0.5 означає, що дві вибірки в тіні і дві освітлені.

Зверніть увагу на те, що це не те ж саме, як одинока вибірка з фільтрованої карти глибин! Порівняння завжди повертає true (істина) чи false(ні). PCF дає інтерполяцію 4 значень true/false.

Як тепер видно, межі тіней стали гладенькі, але текселі карти тіней все ще видні.

Вибірка Пуассона

Найпростіший спосіб це зробити - це робити вибірки з карти тіней N раз, а не один. Використовуючи це разом з PCF, можна отримати дуже гарні результати, навіть при малому N. Ось приклад коду для N=4:

for (int i=0;i<4;i++){
  if ( texture( shadowMap, ShadowCoord.xy + poissonDisk[i]/700.0 ).z  <  ShadowCoord.z-bias ){
    visibility-=0.2;
  }
}

poissonDisk це масив констант, яки може бути таким:

vec2 poissonDisk[4] = vec2[](
  vec2( -0.94201624, -0.39906216 ),
  vec2( 0.94558609, -0.76890725 ),
  vec2( -0.094184101, -0.92938870 ),
  vec2( 0.34495938, 0.29387760 )
);

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

Константа 700.0 визначає як сильно поширюється вибірка. Якщо трішки, то у Вас знову буде аліасинг. Якщо багато - буде цікавий ефект - бандаж (banding) - дивіться знімок екрана (на цьому знімку PCF спеціально відключений для більш драматичного ефекту, але використовується 16 вибірок).

Стратифікована вибірка Пуассона

Ми можемо прибрати цей ефект, якщо обирати різне значення вибірок для кожного пікселя. Є два основних способи - Статифікований Пуассон чи Обернений (Провернутий) Пуассон. Стратифікований обирає різні вибірки, Обернений завжди використовує ті самі, але з випадковим поворотом, тому виглядає по іншому. В цьому туторіалі я розповім тільки про Стратифіковану версію.

Єдина відмінність з попередньою версією тільки в тому, що береться випадковий індекс в poissonDisk:

    for (int i=0;i<4;i++){
        int index = // Випадкове число в діапазоні 0..15, яке різне для кожного пікселя і кожного i
        visibility -= 0.2*(1.0-texture( shadowMap, vec3(ShadowCoord.xy + poissonDisk[index]/700.0,  (ShadowCoord.z-bias)/ShadowCoord.w) ));
    }

Ми будемо генерувати випадкове число наступним кодом, яке повертає випадкове число в діапазоні [0;1):

    float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
    return fract(sin(dot_product) * 43758.5453);

В нашому випадку seed4 буде комбінацією i (отже ми робимо вибірки з 4 різних місць) і … ще чогось іншого. Ми можемо використовувати gl_FragCoord (позиція пікселя на екрані) або Position_worldspace:

        //  - Випадкова вибірка на основі позиції пікселя на екрані.
        //    Немає "бандажу", але тінь переміщується разом з камерою, виглядає дико.
        int index = int(16.0*random(gl_FragCoord.xyy, i))%16;
        //  - Випадкова вибірка на основі позиції пікселя в просторі світу (world space).
        //    Позиція округлена до міліметрів що б не було великого аліасингу
        //int index = int(16.0*random(floor(Position_worldspace.xyz*1000.0), i))%16;

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

Дивіться tutorial16/ShadowMapping.fragmentshader де є три приклади.

Йдемо далі

Навіть з цими всіма трюками є багато інших способів покращити тіні. Ось основні:

Дострокове звільнення (Early bailing)

Замість того, що б брати 16 вибірок для кожного фрагменту (це ж багато), можна взяти 4 вибірки. Якщо вони всі освітелні чи в тіні, то можна припустити, що всі 16 вибірок будуть мати такий же результат. Якщо деякі відрізняються, то можливо ми на межі тіні і все 16 вибірок нам потрібні.

Прожектор (spot light)

Робота з прожектором потребує невеликих змін. Найбільш очевидним є зміна матриці ортогональної проекції в перспективну:

glm::vec3 lightPos(5, 20, 20);
glm::mat4 depthProjectionMatrix = glm::perspective<float>(glm::radians(45.0f), 1.0f, 2.0f, 50.0f);
glm::mat4 depthViewMatrix = glm::lookAt(lightPos, lightPos-lightInvDir, glm::vec3(0,1,0));

те саме, але з перспективний простір камери, а не ортографічний. Використовуйте texture2Dproj для врахування перспективного поділу (дивіться виноски в 4 туторіалі про матриці).

Другим кроком візьмемо до уваги перспективу в шейдері. (дивіться виноски в 4 туторіалі про матриці. Коротко - метриця перспективної проекції насправді не створює ніякої перспективи. Це робиться апаратно шляхом ділення спроектованих координат на w. Тут ми емулюємо трансформацію в шейдері, отже маємо поділити самостійно. До речі, ортогональна матриця завжди генерує однорідний вектор з w = 1, тому вона не генерує жодної перспективи).

Є два способи зробити це в GLSL. Другий спосіб використовує вбудовану функцію textureProj, але обидва способи дають однаковий результат.

if ( texture( shadowMap, (ShadowCoord.xy/ShadowCoord.w) ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )
if ( textureProj( shadowMap, ShadowCoord.xyw ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )

Точкове освітлення (point light)

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

Глибина зберігається в всіх напрямках в просторі,що робить можливим тіням відкидатись у всіх напрямках відносно точкового освітлення.

Комбінація декількох освітлень

Алгоритм може працювати з декількома джерелами світла, але майте на увазі, що кожне джерело світла вимагає додаткового малювання сцени для створення карти тіней. Це потребує дуже великої кількості пам’яті, коли використовуються тіні і Ви дуже швидко досягнете пропускної здатності “заліза”.

Автоматичне освітлення зрізаної піраміди камери

В цьому туторіалі, “піраміда світла” створена вручну, що б містити всю сцену. Хоча це працює в обмеженому прикладі, цього потрібно уникати. Якщо Ваша карта має розмір 1 км на 1 км, то кожний тексель Вашої 1024х1024 карти тіней буде займати 1 квадратний метр, це дуже погано. Проекційна матриця повинна бути максимально щільною.

Для світильників типу прожектора це легко змінити, налаштувавши їх діапазон.

Направлене світло, таке як сонце, є більш складним - воно дійсно освітлює всю сцену. Ось спосіб розрахувати “піраміду освітлення”:

  1. Потенційні отримувачі світла, або PSR - це об’єкти, які належать до одночасно до однієї піраміди освітлення, піраміди камери та обмеженого простору сцени. Як підказую їх ім’я, ці об’єкти можуть бути затінені - вони видимі для камери і для світла.

  2. Потенційні тіньоутворювачі, або PCF - це Потенційні отримувачі світла та всі об’єкти, що розташовані між ними і світлом (об’єкт може бути невидимим, але все рівно створювати тіть на видимі об’єкти).

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

Точний розрахунок цих наборів вимагає розрахунку перетину опуклих оболонок, але він дуже легкий для реалізації.

Цей метод призводить до “вискакування” об’єктів з піраміди, тому що роздільна здатність карти тіней раптово збільшиться. Каскадні карти тіней не мають цієї проблеми, але вони значно складніші в реалізації і Ви все ще можете компенсувати, використовуючи згладжування.

Експоненціальні карти тіней

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

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

Light-space perspective Shadow Maps

LiSPSM (Light-space perspective Shadow Maps - Світло-просторова перспектива карти тіней) - налаштовує проективну матрицю освітлення для отримання більшої точності біля камери. Це дуже важливо в випадку “марної дуелі” - Ви дивитесь в одному напрямку, а освітлення-прожектор - в протилежному. Біля джерела світла точність карти тіней дуже велика, але це далеко від Вас. Біля Вас (тобто камери) точність нижче, але тут вона потрібна більше.

На щастя, можна використати трюк LiSPM. Дивіться посилання для деталей та реалізації.

Каскадні карти тіней

CSM (каскадні карти тіней) мають справу з такою же проблемою, що і LiSPSM, але в інший спосіб. Вони просто використовують декілька (2-4) стандарті карти тіней, але для різних частин видимої піраміди. Перша карта працює з близькими об’єктами, тут буде гарна роздільна здатність, але для маленької зони. Наступна карта тіней працює для більш далеких об’єктів. Остання карта - для великої частини сцени, але через перспективу, вона не така візуально важлива, як ближчі.

Каскадні карти тіней мають на час написання цього тексту (2012 рік) найкраще співвідношення складність/якість. Це гарне рішення для багатьох випадків.

Висновок

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

На щастя, більшість з наведених методів допускають сумісне використання - реально можна змішувати каскадні карти тіней в LiSPSM, згладжене за допомогою PCF. Пробуйте і експериментуйте з цими техніками.

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