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

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

ссылка на оригинал статьи http://habrahabr.ru/post/183534/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *