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

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

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 20

      0
      Terraria напомнило. :)
        0
        В Terraria нет объема, там просто освещение участков.
          +1
          Не совсем просто. :) Но да, там действительно другой метод без объема.
        +2
        Лава — это нечто, напомнило видео.
        Но в 2D такое поведение частиц смотрится так круто!
          0
          Спасибо, клёвая статья. Прочитал на одном дыхании.
            0
            спасибо за статью, интересно
              +1
              Я для похожей задачи, недолго думая, отрендерил дополнительную карту векторов освещения.
                0
                Спасибо. Какие самые быстрые алгоритмы с мягкими тенями существуют? На шейдерах можно сделать тени?
                  +1
                  Пользуясь случаем, прошу разбирающихся людей написать статейку о том, как сделать динамическое 2D освещение, на основе лучей, как здесь
                  www.youtube.com/watch?v=LDYXCOX0rg0
                  www.youtube.com/watch?v=c6YiwaAZBfo
                  • UFO just landed and posted this here
                      0
                      Я в свое время у себя в блоге писал, но читающие говорили что корявенько вышло. Я делал на шейдерах исключительно, выходило красиво.
                      quadengine.blogspot.com/2011/07/2.html
                        0
                        Имхо делать так же как шадоумаппинг, рендерить со стороны источника света, но для 2д нам можно рендерить в текстуру высотой в 1 пиксель (одномерную посути), а значит никакого проседания по филлрейту, ну а геометрия там примитивная.
                        +2
                        О, теперь тренд недели — динамическое освещение :).
                          +1
                          Да, вот тоже хотел написать об этом.
                          Вообще вот у меня есть тоже материал по дин. освещению, но публиковать его после этих двух статей уже желание отпало.
                            +2
                            Наоборот, нужно именно сейчас, пока интерес на пике :)
                              0
                              Только если очень попросят. Не хотелось бы пасть под гневом читателей, решивших, что заполонили все 2д освещением.
                        • UFO just landed and posted this here
                            0
                            Скажите пожалуйста, а эту систему освещения можно прикрутить к движку юнити?
                            Точнее даже к его готовящемуся 2d функционалу.
                              0
                              Можно, конечно. И к 2д и к 3д составляющей. Всего то нужен меш и материал, а дальше шейдер примерно такой же
                              +1
                              Карта векторов направления света это, по сути, результат выполнения оператора градиента (grad) над лайтмэпом. В шейдере можно использовать функции ddx/ddy, которые как раз вычисляют частные производные по x и y пикселя. Скорее всего, это будет быстрее, чем вычисление производных «в лоб», и к тому же немного элегантнее (хотя для двумерной игры скорость исполнения шейдеров не так критична).

                              Only users with full accounts can post comments. Log in, please.