Думается мне, что ты уже начал писать свой мега-кульный движок, который вскоре затмит все существующие творения 🙂 что ж, это есть хорошо. Говоришь, зрелищности не хватает? Тогда не будем тратить время зря и приступим к украшательству твоего движка. Сегодня мы поговорим об освещении.
Немного теории
Теория проста как рокет-джамп. Есть лампочка, которая светит с определенной интенсивностью и освещает некоторое пространство вокруг себя. Интенсивность света (по закону Бугера-Ламберта) уменьшается с расстоянием по экспоненциальному закону, но для игр это будет крутовато, поэтому обычно используется либо линейная зависимость (часто), либо квадратичная (редко). Существуют четыре основных типа источников света: фоновый источник (ambient light), точечный источник (omni light), направленный (spot light) и параллельный свет (direct light).
Фоновый источник, даже лампочкой не назовешь. Просто его интенсивность является как бы нулевой отметкой при расчете освещения, т.е. при полном отсутствии посторонних источников света все объекты уровня или 3D-сцены будут равномерно, со всех сторон освещены фоновым источником. Ни каких тебе бликов, ни теней.
Точечный источник - самый простой в моделировании. Это просто светящаяся точка бесконечно малого объема, которая светит одинаково во все стороны.
Направленный источник - это то же самое, что и точечный, только ограниченный некоторым конусом, ну это, вроде как свет из кинопроектора. Главными характеристиками направленного источника, кроме интенсивности и цвета как у остальных, являются направление и ширина свето-конуса, задаваемая обычно в градусах (математики называют это "телесным" углом). Обычно для направленного источника задаются два свето-конуса: внешний и внутренний. В пределах внутреннего свето-конуса, источник светит в полную силу, а в пространстве между внутренним и внешним свето-конусом, интенсивность постепенно спадает до нуля.
Параллельный свет - это вроде как обычная лампочка, только удаленная на очень большое, а в идеале бесконечное, расстояние. Хорошим примером может служить Солнце, известная такая звезда-карлик. Все лучи от такого источника параллельны друг другу, что несколько облегчает расчет освещения и теней для объектов.
То, как выглядит озаренный светом объект, во многом определяется свойствами поверхности данного объекта. Этих свойств существует огромное количество (взгляни хотя бы на окошко Materials в 3D MAX'е), но в реале они никому не нужны (надеюсь, ты помнишь, что мы говорим про игры, а не про крэш-тесты для третьего ДжиФорса). Все, что нам нужно знать для расчета освещенности объекта в какой-то точке - это нормаль к поверхности в этом месте.
По закону Ламберта освещенность точки пропорциональна косинусу угла между нормалью и вектором идущим из данной точки к точечному источнику света.
Чтобы быстро найти косинус угла между двумя векторами (а это главный тормоз при расчете освещения) пользуются свойством скалярного произведения векторов:
(v1*v2) = |v1|*|v2|*cos(v1^v2) = v1.x*v2.x + v1.y*v2.y + v1.z*v2.z
Здесь |v1| и |v2| - это длины векторов v1 и v2 соответственно. Если эти векторы заранее нормализовать, то их длины будут равны 1, и мы тогда получим простое уравнение:
cos(v1^v2) = v1.x*v2.x + v1.y*v2.y + v1.z*v2.z
А здесь всего лишь три умножения и три сложения, никаких квадратных корней и делений - убийц быстрого кода 🙂
Нормаль для полигона (треугольника) можно найти, воспользовавшись векторным произведением.
/*
Найдем нормаль к полигону заданному тремя вершинами: p1, p2 и p3.
Подразумевается, что точки заданы в обходе против часовой стрелки (как на рисунке).
*/
void BuildNormal (vertex p1, vertex p2, vertex p3, vertex *normal)
{
vertex v1, v2;
float length, ilength;
// найдем вектор v1 задающий одно ребро треугольника (v1 = p2 - p1)
v1.x = p2.x - p1.x;
v1.y = p2.y - p1.y;
v1.z = p2.z - p1.z;
// найдем вектор v2 задающий другое ребро треугольника (v2 = p3 - p1)
v2.x = p3.x - p1.x;
v2.y = p3.y - p1.y;
v2.z = p3.z - p1.z;
// векторное произведение [v1*v2]
normal->x = v1.y*v2.z - v1.z*v2.y;
normal->y = v1.z*v2.x - v1.x*v2.z;
normal->z = v1.x*v2.y - v1.y*v2.x
//
// нормализуем вектор
//
// найдем длину вектора
length = normal->x*normal->x + normal->y*normal->y + normal->z*normal->z;
length = sqrt(length); // извлечение корня - это очень плохо
// возьмем обратную величину, чтоб не делать потом лишних делений
ilength = 1/length;
normal->x *= ilength;
normal->y *= ilength;
normal->z *= ilength;
}
Для рендеринга освещенных полигонов применяются различные модели затенения, которые мы сейчас и рассмотрим.
Плоское затенение
Плоское затенение (flat shading) - это самая простая модель. Берется полигон, строится нормаль и рассчитывается (по известной формуле) освещенность всего полигона, а затем весь полигон заливается сплошным цветом, полученным из первоначального умножением на результирующую освещенность.
Несмотря на свою простоту, плоское затенение используется до сих пор. Если например, в игре нужно нарисовать военную технику с жесткими четкими гранями, то используют именно flat
shading.
Затенение по Гуро
Затенение по Гуро (Gouraud shading или smooth shading) используется в основном при рендеринге небольших по своим размерам полигонов, например, на 3D моделях. Для всех вершин модели строятся средние нормали, рассчитывается освещение, и полученные цветовые значения интерполируются в пределах каждого полигона, без учета коррекции перспективы. К счастью, закраска полигонов методом Гуро поддерживается во всех современных видеокарточках аппаратно.
Затенение по Фонгу
Метод затенения по Фонгу (Phong shading) состоит в следующем: для каждой вершины модели строится средняя нормаль, и потом эти нормали интерполируются в пределах каждого полигона, позволяя вплотную приблизиться к попиксельному освещению. Этот способ позволяет добиться очень высокого качества картинки, но в силу своей чрезвычайной ресурсоемкости, в играх используется очень редко.
Карты освещения
Карты освещения (light map) используются для представления освещения статичного внутриигрового мира. Они позволяют отобразить сколь угодно реалистичные тени, реализовать всякие сопутствующие эффекты, типа дифракции света и т.п. Вся эта красотища, само собой, рассчитывается не в реальном времени, а заранее, например, на этапе компиляции уровня.
Идея такова. Для КАЖДОГО полигона, представляющего геометрию уровня, в памяти создается дополнительная текстура, на которой рисуются все тени и световые блики, которые волею судеб (а точнее, волею левел-дизайнера) пали на данный полигон. А затем полученный лайтмэп накладывается на полигон вместе с остальными текстурными слоями, благо поддержка мультитекстурирования позволяет сделать это довольно быстро. А чтобы карты освещения не занимали слишком много памяти, рассчитывают их в разрешениях намного меньших, чем, например, накладываемые текстуры.
Строятся лайтмэпы на удивление легко. Для каждого полигона создается сетка из точек расположенных на расстоянии 8-16 текселей друг от друга и потом для каждой этой точки рассчитывается честное освещение, с серьезной трассировкой лучей от каждого источника света, в результате мы получаем картинку с изображением всех теней павших на полигон. А чтобы края теней были менее ступенчатыми, лайтмэпы рассчитываются в высоком разрешении, например с шагом в 4 текселя, а потом уменьшаются с применением фильтрации.
/*
Расчет освещения для одной точки лайтмэпа и одного точечного источника света с учетом линейного уменьшения интенсивности света по расстоянию. Чтобы рассчитать освещение от нескольких источников, просто суммируем все результирующие цветовые значения.
*/
#define SCALECOS 0.7 // фактор учитывающий рассеяние света
vertex p; // точка для которой будем считать освещение
vertex normal; // нормаль к поверхности в данной точке
color c; // результирующий цвет данной точки
vertex l; // положение точечного источника света
float intensity; // интенсивность (яркость) источника
color lcolor; // цвет источника света
...
vertex rel; // вектор идущий из точки p в точку l
float dist; // расстояние от точки до источника
float idist, angle, add;
rel.x = l.x - p.x;
rel.y = l.y - p.y;
rel.z = l.z - p.z;
// найдем расстояние от точки до источника
dist = sqrt (rel.x*rel.x + rel.y*rel.y + rel.z*rel.z);
// если расстояние до источника больше его интенсивности, то свет не достигнет точки
if (dist > intensity) {
c.r = c.g = c.b = 0;
return;
}
/*
Здесь можно и нужно воткнуть трассировку лучей.
Если луч rel пересечет какой-нибудь полигон, то это значит, что свет от данного источника не достигнет точки.
*/
// нормализуем вектор rel
idist = 1/dist;
rel.x *= idist;
rel.y *= idist;
rel.z *= idist;
// получаем косинус угла
// normal должен быть уже приведен к единичной длине
angle = rel.x*normal.x + rel.y*normal.y + rel.z*normal.z;
// учтем рассеяние света
angle = (1.0 - SCALECOS) + angle* SCALECOS;
// считаем результирующую интенсивность с учетом линейного затухания света
add = (intensity - dist)/intensity;
add *= angle;
// получаем результирующий цвет
c.r = lcolor.r * add;
c.g = lcolor.g * add;
c.b = lcolor.b * add;
Динамическое освещение
С появлением и развитием блока T&L в современных видеокартах, зародилась надежда, что вскоре, ВСЁ освещение в играх будет рассчитываться аппаратно, но пока об этом говорить рано и поэтому, чаще всего, динамическое освещение в играх до сих пор рассчитывается программно какими-нибудь нечестными способами.
Самый простой способ состоит в том, что все пространство игрового уровня разбивается на некоторые зоны, для которых задаются определенные цветовые значения, которые и определяют освещенность модели в том или ином месте.
Есть и другой способ, который дает более хорошие и правдивые результаты, и не требует больших вычислений. Просто мы рассчитываем честное освещение от всех источников света для шести ортогональных векторов, например, нормалей к граням bounding box'а модели, а затем эти значения интерполируем для всех остальных нормалей модели. Причем весовые коэффициенты, которые показывают, как влияет каждый из шести векторов на ту или иную нормаль, можно рассчитать заранее.
Это все. С праздничком тебя!