Official translation (with a bit of polishing) is available here.
В кратком курсе компьютерной графики, что я предоставил вашему рассмотрению пару недель назад, мы пользовались методами локального освещения. Что это значит? Это значит, что интенсивность освещения каждой точки мы выбирали независимо от её соседей.
Модель освещения Фонга — классический пример локального выбора:
Финальная интенсивность складывается из трёх слагаемых: окружающее освещение, постоянное значение для всех точек сцены. Диффузное освещение и блики зависят от вектора нормали к данной точке и направления света, но не зависят от геометрии остальной части сцены. Давайте подумаем, а почему, собственно, окружающее освещение было выбрано постоянным для всей сцены?
Второй подход к глобальному освещению: 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;
}
virtual 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