Продолжая тему велосипедостроения, хочу поделится тем, как я делал освещение в пиксель-арт игрушке.
Особенность этого метода заключается в том, что эти источники света не ограничиваются ни количеством ни формой.
Условно алгоритм можно разделить на две составляющие: освещение 2D объектов и форма представления источников света.
Освещение
Отчасти об освещении написано в этом посте.
Для того что бы определить интенсивность освещения каждого пикселя, необходимо знать нормаль этого пикселя и вектор направления к источнику света. Собственно здесь и происходит разделение моего поста на две части: откуда брать нормаль пикселя (текущего объекта) и как вычислять вектор направления освещения.
Как правило нормаль пикселя текущего объекта берется из карты нормалей.
Получить карту нормалей можно разными способами (один из них описан в приведенном выше посте), я создаю ее так:
рисуется спрайт:
Далее для него рисуется карта высот. В моем случае сам по себе спрайт можно интерпретировать как карту высот. О том что такое карта высот и вообще о бамп маппинге в целом можно почитать тут.
По карте высот уже можно построить карту нормалей. Существует несколько утилит, которые умеют это делать. Я использовал плагин для GIMP'a (вот сорцы, но вроде есть в стандартных репозиториях убунты).
Итак, у нас есть оба спрайта для создания эффекта объемного объекта. Рассмотрим шейдер, который используя эти два спрайта и направление источника света определяет интенсивность пикселя, на данном этапе он точно такой же, как и в моем предыдущем посте.
//вершинный
varying vec4 texCoord;
void main(){
gl_Position = gl_ModelViewProjectionMatrix*gl_Vertex;
texCoord = gl_MultiTexCoord0;
}
//фрагментный
uniform sampler2D colorMap;
uniform sampler2D normalMap;
varying vec4 texCoord;
uniform vec2 light;
uniform vec2 screen;
uniform float dist;
void main() {
vec3 normal = texture2D(normalMap, texCoord.st).rgb;
normal = 2.0*normal-1.0;
vec3 n = normalize(normal);
vec3 l = normalize(vec3((gl_FragCoord.xy-light.xy)/screen, dist));
float a = dot(n, l);
gl_FragColor = a*texture2D(colorMap, texCoord.st);
}
Источники света
Эта технология отдаленно напоминает Deferred Shading.
Основная идея заключается в создании отдельного буфера для освещения, где каждый пиксель хранит значение интенсивности освещения для соответствующего пикселя в кадре. Другими словами — это обычный лайтмап для 2D сцены.
Для того, что бы сделать лайтмап, нужно просто отрендерить в него все источники света. Преимущества такого подхода:
- количество источников света ограничена только железом. К примеру 1000 источников света — это 1000 спрайтов. Отрендерить 1000 спрайтов не составит труда даже для мобильного гпу, да и нужно ли в 2D сцене 1000 источников?
- источники света могут быть разного цвета и разной степени прозрачности — ведь это обычная текстура
- форма источников света может быть любой
Вот, к примеру, лайтмап сцены с лавой:
Это не новая техника освещения и у нее есть минус — отсутствие вектора направления света. Однако можно придумать такой алгоритм, который бы определял этот вектор.
Для начала определим что из себя представляет источник света и какие у него есть свойства. Я не буду приводить сложные формулы и цитаты из учебника по физике — все это скучно и не интересно. Попробую объяснить так, как объяснил бы маме.
Итак чем дальше исходят лучи света — тем слабее их интенсивность. Это наблюдение можно использовать для определения вектора направления лучей света. То есть, если у нас есть два соседних пикселя и в первом из них значение света равно 0.5, а во втором 0.25, то можно сделать вывод, что вектор луча света направлен из первого пикселя во второй.
В данном случае простая формула вычисления вектора освещенности выглядит так:
v[cx][cy].x = p[cx][cy].x — p[cx+1][cy].x
v[cx][cy].y = p[cx][cy].y — p[cx][cy+1].y
где cx, cy — координаты рассматриваемого пикселя
Однако разница между двумя соседними пикселями может быть крайне мала, соответственно длина вектора так же может быть маленькой и не точной, поэтому в данном случае освещение может показаться «плоским». Я нашел два варианта решения этой проблемы: домножать результат на некоторый коэффициент или брать пиксели отстоящие друг от друга на 1 или более пикселя. Во втором случае мы жертвуем детализацией освещения. В итоге я скомбинировал оба этих метода и итоговая формула выглядит так:
v[cx][cy].x = (p[cx-d/2][cy].x — p[cx+d/2][cy].x) * k
v[cx][cy].y = (p[cx][cy-d/2].y — p[cx][cy+d/2].y) * k
где k — коэффициент усиления вектора направления света, d — расстояние между пикселями в выбрке.
Эти новые значения можно либо записывать в отдельную карту нормалей освещения либо вычислять «на лету» во время рендера результирующего кадра просто используя лайтмап. Я выбрал второй вариант.
//вершинный
varying vec4 texCoord;
varying vec4 nmTexCoord;
varying vec2 lightMapTexCoord; //координаты среднего пикселя лайтмапа
varying vec2 lightMapTexCoordX1; //координаты левого пикселя лайтмапа
varying vec2 lightMapTexCoordX2; //координаты правого пикселя лайтмапа
varying vec2 lightMapTexCoordY1; //координаты верхнего пикселя лайтмапа
varying vec2 lightMapTexCoordY2; //координаты нижнего пикселя лайтмапа
//да, я знаю, что можно было использовать массив. Но так нагляднее
uniform vec2 fieldSize; // размер игровой карты
const float spriteSize = 16.0; //размер зазора между соседними пикселями лайтмапа
void main() {
gl_Position = gl_ModelViewProjectionMatrix*gl_Vertex;
texCoord = gl_MultiTexCoord0;
nmTexCoord = gl_MultiTexCoord1;
//вычисляем текстурные координаты выборочных пикселей лайтмапа.
lightMapTexCoordX1 = vec2(gl_Vertex.x/(fieldSize.x-1.0/spriteSize), gl_Vertex.y/fieldSize.y);
lightMapTexCoordX2 = vec2(gl_Vertex.x/(fieldSize.x+1.0/spriteSize), gl_Vertex.y/fieldSize.y);
lightMapTexCoordY1 = vec2(gl_Vertex.x/fieldSize.x, gl_Vertex.y/(fieldSize.y-1.0/spriteSize));
lightMapTexCoordY2 = vec2(gl_Vertex.x/fieldSize.x, gl_Vertex.y/(fieldSize.y+1.0/spriteSize));
lightMapTexCoord = vec2(gl_Vertex.x/fieldSize.x, gl_Vertex.y/fieldSize.y);
}
//---------------------------------------------------------------------------------------------------------------
//фрагментный
varying vec4 texCoord;
varying vec4 nmTexCoord;
varying vec2 lightMapTexCoord;
varying vec2 lightMapTexCoordX1;
varying vec2 lightMapTexCoordX2;
varying vec2 lightMapTexCoordY1;
varying vec2 lightMapTexCoordY2;
uniform sampler2D colorMap; //в этом атласе и диффузная карта и карта нормалей
uniform sampler2D lightMap;
uniform float ambientIntensity; //рассеянное освещение
uniform float lightIntensity; //коэффициент усиление интенсивности света
const float shadowIntensity = 8.0; //коэффициент усиления вектора направления света
const vec3 av = vec3(0.33333); //константа для вычисления среднего арифмитического
void main() {
vec4 lmc = texture2D(lightMap, lightMapTexCoord)*2,0; //текущий пиксель из лайтмапа. Он умножается на два, потому что в проекте максимальное значение компоненты цвета равно 0.5, а не 1.0 (условно). В таком случае цвет можно разбить на две части, обработать, а потом сложить их. Это нужно для того, что бы сверхяркий свет в итоге переходил в белый.
// x и y - разница между соседними пикселями лайтмапа
float x = (dot(texture2D(lightMap, lightMapTexCoordX1).rgb, av)-
dot(texture2D(lightMap, lightMapTexCoordX2).rgb, av))*shadowIntensity;
float y = (dot(texture2D(lightMap, lightMapTexCoordY2).rgb, av)-
dot(texture2D(lightMap, lightMapTexCoordY1).rgb, av))*shadowIntensity;
float br = dot(lmc.rgb, av); //среднее арифмитическое всех трех компонент лайтмапа - яркость пикселя
vec3 l = vec3(x, y, br); //создаем вектор из полученых значений, по z позиции устанавливаем яркость пикселя, для того что бы при нормализации получить вектор, характеризующий не только направление, но и яркость пикселя
l = normalize(l)*br; //нормализуем и еще дополнительно умножаем на яркость
vec3 normal = 2.0*texture2D(colorMap, nmTexCoord.st).rgb-1.0;
float a = dot(normal, l)*lightIntensity;
a = max(a, 0.0);
vec4 c = texture2D(colorMap, texCoord.st);
c = a*min(c, lmc)+ambientIntensity*c; //вычисляем цвет пикселя на основе рассеянного и направленного света
float m = 0.0; //теперь находим максимальное значение из трех компонент результирующего пикселя, это нужно для того, что бы сверхяркий свет в итоге переходил в белый (см. на видео или gif в шапке). Назовем его избыточным цветом.
m = max(m, c.r);
m = max(m, c.g);
m = max(m, c.b);
gl_FragColor = c+max(0.0, m-1.0); //складываем результирующий и избыточный цвета.
}
Видео с демонстрацией эффекта: источник света — спрайт произвольной формы, каждая частица лавы — источник света.
Автор: Torvald3d