Programming
Algorithms
11 November 2013

Динамическое освещение и неограниченное количество источников произвольной формы в 2D

Продолжая тему велосипедостроения, хочу поделится тем, как я делал освещение в пиксель-арт игрушке.
Особенность этого метода заключается в том, что эти источники света не ограничиваются ни количеством ни формой.



Условно алгоритм можно разделить на две составляющие: освещение 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); //складываем результирующий и избыточный цвета.
}

Видео с демонстрацией эффекта: источник света — спрайт произвольной формы, каждая частица лавы — источник света.

+72
37.2k 339
Support the author
Comments 20