Короткий курс комп'ютерної графіки, адендум: ambient occlusion

В короткому курсі комп'ютерної графіки, що я надав вашого розгляду пару тижнів тому, ми користувалися методами локального освітлення. Що це означає? Це значить, що інтенсивність освітлення кожної точки ми вибирали незалежно від її сусідів.

Модель освітлення Фонга — класичний приклад локального вибору:



Фінальна інтенсивність складається з трьох доданків: навколишнє освітлення, постійне значення для всіх точок сцени. Дифузне освітлення і відблиски залежать від вектора нормалі до даної точки і напрямку світла, але не залежать від геометрії решті частини сцени. Давайте подумаємо, а чому, власне, навколишнє освітлення було обрано постійним для всієї сцени?

Другий підхід до глобального освітлення: ambient occlusion
Ну, насправді, я трохи був неправий, коли сказав, що в тому курсі у нас освітлення було локальним. У шостій частині ми розглянули побудова тіні, що відкидається нашим об'єктом. І це є одним з прийомів глобального освітлення. У даній статті я пропоную розглянути кілька простих підходів до прорахунку навколишнього світу. Ось приклад моделі, в якій я використовував тільки навколишнє (ambient) компоненту моделі освітлення Фонга, ніякого дифузного світла, ніяких відблисків.



Отже, задача ставиться наступним чином: порахувати значення навколишнього освітлення (ambient lighting) для кожної точки видимої частини нашої сцени.

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

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

Йдемо в лоб
Супутні исходники брати тут.

Найпростіший спосіб — це вибрати випадковим чином, скажімо, тисячу різних точок на півсфері навколо об'єкта, отрендерить сцену тисячу разів з камерою, поставленої в ці точки, і порахувати, які частини моделі ми бачили. Отут доречно задати кілька питань.

Питання 1: а чи вмієте з ходу ви вибрати з рівномірним розподілом тисячу точок на сфері без акумулювання навколо полюсів?

Якось так:

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

Питання 2: а де зберігати інформацію про те, який шматок світиться півсфери видно з даної точки об'єкта? Оскільки ми йдемо в лоб, то відповідь практично очевидний: в текстурі об'єкта!

Отже, пишемо два шейдерів і робимо рендер два рази для кожної точки. Ось перший (фрагментний) шейдер і результат його роботи:

virtual bool fragment(Vec3f gl_FragCoord, Vec3f bar, TGAColor &color) {
color = TGAColor(255, 255, 255)*((gl_FragCoord.z+1.f)/2.f);
return false;
}

Прихований текст
Безпосередньо картинка нас не особливо цікавить, нам цікавий z-буфер в результаті роботи цього шейдера.

Потім робимо другий прохід з таким фрагментным шейдером:

virtual bool fragment(Vec3f gl_FragCoord, Vec3f bar, TGAColor &color) {
Vec2f uv = varying_uv*bar;
if (std::abs(shadowbuffer[int(gl_FragCoord.x+gl_FragCoord.y*width)]-gl_FragCoord.z<1e-2)) {
occl.set(uv.x*1024, uv.y*1024, TGAColor(255));
}
color = TGAColor(255, 0, 0);
return false;
}

Нам абсолютно не важливо, що він видасть червону картинку. Тут цікава ось цей рядок:

occl.set(uv.x*1024, uv.y*1024, TGAColor(255));

occl — це спочатку залита чорним картинка розміром 1024x1024. І цей рядок говорить, що якщо ми бачимо даний фрагмент, то відзначимо його в текстурної карти occl. Ось так виглядає картинка occl після закінчення роботи фонового:

Прихований текст

Вправа на розуміння того, що відбувається: чому у нас явно видимі трикутники з дірками? Точніше, чому відмічені лише окремі точки нічим не прихованих трикутників?

Вправа 2: чому в одних трикутників щільність покриття точками більше, ніж у інших?

Загалом, повторюємо процедуру тисячу разів, вважаємо середнє з тисячі одержані картинок occl і отримуємо ось таку структуру:

Прихований текст

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

virtual bool fragment(Vec3f gl_FragCoord, Vec3f bar, TGAColor &color) {
Vec2f uv = varying_uv*bar;
int t = aoimage.get(uv.x*1024, uv.y*1024)[0];
color = TGAColor(t, t, t);
return false;
}


aoimage тут — це тільки що порахована текстура. Ось результат роботи цього шейдера:

Прихований текст
Вправа 3: ой, а чого це він похмуріше хмари?

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

Підіб'ємо підсумок
Цей метод дозволяє (перед-)порахувати текстуру ambient occlusion для сцен, де геометрія статична. Час прорахунку залежить від кількості точок, яке ви оберете, але зазвичай час нас цікавить слабо, оскільки це практично створення сцени, а не безпосередньо процес гри, така текстура вважається один раз, і потім використовується. Перевага в тому, що використовувати таку текстуру дешево, вона може бути порахована з умовами більш складного освітлення, ніж просто рівномірно світиться півсфера. Недолік — якщо у нас є накладання в текстурних просторі, то відбудеться ніжний облом.

Куди, куди накладати скотч, щоб воно запрацювало?
Оскільки текстурне простір для диаблы не підходить, можна використовувати звичайний фреймбуфер. Рендер вийде в кілька проходів: рендерим просто z-буфер із звичайної позиції камери, потім висвітлюємо модель з (припустимо) тисячі різних джерел світла, вважаючи тисячу разів shadow mapping, і вважаємо середнє для кожного пікселя. Все б було добре, ми позбулися проблеми накладання інформації на себе, але натомість отримали велику кількість рендерного часу. Якщо в попередньому підході ми прораховували текстуру один раз на все життя моделі, то тепер вона залежить від положення камери в просторі…

Screen-space ambient occlusion
Отже, ми приходимо до висновку, що глобальне освітлення — дорога штука, нам потрібно багато дорогих обчислень про видимості поверхні з різних місць. Давайте спробуємо знайти компроміс між швидкістю і якістю результату. Ось відразу картинка, яку ми будемо вважати, що:



Малювання в один прохід, ось використаний шейдер:

struct ZShader : public IShader {
mat<4,3,float> varying_tri;

virtual Vec4f vertex(int iface, int nthvert) {
Vec4f gl_Vertex = Projection*ModelView*embed<4>(model->vert(iface, nthvert));
varying_tri.set_col(nthvert, gl_Vertex);
return gl_Vertex;
}

віртуальний bool fragment(Vec3f gl_FragCoord, Vec3f bar, TGAColor &color) {
color = TGAColor(0, 0, 0);
return false;
}
};


Ээээ… color = TGAColor(0, 0, 0); ?! Правильно, ми зараз вважаємо тільки ambient освітлення, і реально нам з цього шейдера цікавий тільки буфер глибини, нічого, що фреймбуфер залишиться повністю чорним після закінчення роботи шейдера, модель проявиться в результаті постпроцесингу сцени.

Ось використаний код відтворення з нашим порожнім шейдером і постпроцесинг картинки:

ZShader zshader;
for (int i=0; i<model->nfaces(); i++) {
for (int j=0; j < 3; j++) {
zshader.vertex(i, j);
}
triangle(zshader.varying_tri, zshader, frame, zbuffer);
}

for (int x=0; x<width; x++) {
for (int y=0; y<height; y++) {
if (zbuffer[x+y*width] < -1e5) continue;
float total = 0;
for (float a=0; a<M_PI*2-1e-4; a += M_PI/4) {
total += M_PI/2 - max_elevation_angle(zbuffer, Vec2f(x, y), Vec2f(cos(a) sin(a)));
}
total /= (M_PI/2)*8;
total = pow(total, 100.f);
frame.set(x, y, TGAColor(total*255, total*255, total*255));
}
}

Побудова порожнім шейдером нам дає заповнений буфер глибини. Постпроцесинг виглядає наступним чином: для кожного пікселя на екрані ми зробімо місце для радісного деяку кількість (тут вісім) променів у різні сторони. Буфер глибини для нас можна представити як горбисту місцевість. Що нас цікавить, так це як сильно ми будемо підніматися, якщо підемо в напрямку кожного променя. Функція max_elevation_angle і дає максимальний підйом, який ми зустрінемо на шляху поточного променя.

Якщо у всіх восьми променів кут підйому нульовий, то це означає, що дана точка (x,y) добре видно звідусіль. Якщо ж кут приблизно 90°, то точка дуже слабо видно з навколишнього небесної сфери, і, як наслідок, має бути слабо освітлена.

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

Ось що виходить на голові старого негра:



Як правило, код доступний тут.

Прихований текст
Спілкування поза хабра
Якщо у вас є питання, і ви не хочете задавати їх у коментарях, або просто не маєте можливості писати в коментарі, приєднуйтесь до jabber-конференції xmpp: 3d@conference.sudouser.ru

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.