Pull to refresh

Направленное освещение и затенение в 2D-пространстве

Reading time 8 min
Views 48K

Добрый день, Хабравчане!
Хотелось бы рассказать об одном из способов отрисовки освещения и затенения в 2D-пространстве с учетом геометрии сцены. Мне очень нравится реализация освещения в Gish и Super MeatBoy, хотя в митбое его можно разглядеть только на динамичных уровнях с разрушающимися или перемещающимися платформами, а в Гише оно повсеместно. Освещение в таких играх мне кажется таким «тёплым», ламповым, что непременно хотелось нечто подобное реализовать самому. И вот, что из этого вышло.

Тезисно что есть и что требуется сделать:
  • есть некий 2D-мир, в который нужно встроить динамическое освещение+затенение; мир не обязательно тайловый, из любой геометрии;
  • источников света должно быть, в принципе, неограниченное число (ограничиваться только производительностью системы);
  • наличие большого числа источников света в одной точке, либо одного источника света с большим коэффициентом «освещения», должно не просто освещать область на 100%, а должно засветлять её;
  • рассчитываться должно всё, конечно же, в реалтайме;



Для всего этого понадобился OpenGL, GLSL, технология FrameBuffer и немного математики. Ограничился версиями OpenGL 3.3 и GLSL 3.30 т.к. видеокарта одной из моих систем по нынешним меркам весьма устарела (GeForce 310), да и для 2D этого более чем достаточно (а более ранние версии вызывают отторжение из-за несогласованности версий OpenGL и GLSL). Сам по себе алгоритм не сложный и делается в 3 этапа:
  1. Сформировать текстуру размером с область рендера черного цвета и нарисовать в ней освещённые области (так называемая карта освещения), накапливая при этом коэффициент освещённости для всех точек;
  2. Отрендерить сцену в отдельную текстуру;
  3. В контексте рендера вывести квад, полностью его покрывающий, а во фрагментном шейдере свести полученные текстуры. На данном этапе можно «поиграться» с фрагментным шейдером, добавив, например, эффекты преломления от воды/огня, линзы, цветовая коррекция на любой вкус и прочая пост-обработка.


1. Карта освещения


Использовать будем одну из самых распространённых технологий — deferred shading, портировав её в 2D (P.S. прошу прощения, не deferred shading, а shadow map, мой косяк, спасибо за поправку). Суть этого метода — отрендерить сцену перенеся камеру в позицию источника света, заполучив буфер глубины. Нехитрыми операциями с матрицами камеры и источника света в шейдере попиксельно можно узнать о затенённости, переводя координаты пикселя рендерящейся сцены в текстурные координаты буфера. В 3D используется z-buffer, здесь же я решил создать свой одномерный буфер глубины, на CPU.
Совершенно не претендую на разумность и оптимальность сего подхода, алгоритмов освещения хватает и у каждого свои плюсы с минусами. Во время обмозговывания способ казался вполне имеющим право на жизнь, и я приступил к реализации. Замечу, что во время написания статьи обнаружил вот такой способ… ну да ладно, велосипед так велосипед.

1.1 Z-буфер aka буфер глубины


Суть Z-буфера — хранение удалённости элемента сцены от камеры, что позволяет отсекать невидимые за более ближними объектами пиксели. Если в 3D сцене буфер глубины представляет собой плоскость

, то в нашем плоском мире он станет линией или одномерным массивом. Источники света — точечные, излучающий свет от центра по всем направлениям. Соответственно, индекс буфера и значение будут соответствовать полярным координатам расположения ближайшего к источнику объекта. Размер буфера я определял опытным путём, в результате чего остановился на 1024 (конечно, зависит от размера окна). Чем меньше размерность буфера, тем больше будет заметно расхождение границы объекта и освещённой области, особенно при наличии мелких объектов, а местами могут появиться совершенно неприемлемые артефакты:
Скрытый текст


Алгоритм формирования буфера:
  • заполнить значением радиуса источника света (расстояние, на котором сила освещения достигает нуля);
  • по каждому объекту, находящемуся в радиусе источника света, взять те рёбра, что повёрнуты к источнику света лицевой стороной. Если брать рёбра, повёрнутые тыльной стороной, объекты автоматически станут подсвеченными, но появится проблема с рядом стоящими:
    Скрытый текст

  • спроецировать полученный список рёбер, преобразуя их декартовы координаты в полярные источника света. Пересчитываем point(x; y) в (φ; r):
    φ = arccos( xAxis • normalize( point ) )
    где:
    • — скалярное произведение векторов;
    xAxis — единичный вектор, соответствующий оси x (1; 0), т.к. 0 градусов соответствуют правой от центра окружности точки;
    point — вектор, направленного из центра источника света в точку, принадлежащую ребру (координаты точки ребра в системе координат источника света);
    normalize — нормализация вектора;
    r = |point| — расстояние до точки;
    Проецируем две крайние точки ребра и промежуточные. Число точек, необходимых для пересчета, соответствует числу ячеек буфера, которые покрываются проекцией ребра.
    Вычисление индекса буфера, соответствующему углу φ:
    индекс = φ / ( 2*π ) * размерБуфера;
    Таким образом находим два крайних индекса буфера, соответствующих крайним точкам ребра. Для каждого промежуточного индекса, переводим в значение угла:
    φ = index * 2*π / размерБуфера
    , строим вектор отрезок из (0; 0) под этим углом длиной, равной или большей радиуса источника света:
    v = vec2( cos( φ ), sin( φ ) ) * радиус
    и находим точку пересечения полученного отрезка и ребра, например, так:
    • заданы 2 прямые с коэффициентами A1, B1, C1 и A2, B2, C2

    • воспользовавшись методом Крамера для решения этой системы уравнений получим точку пересечения:


    • если знаменатель равняется нулю (в нашем случае — если значение знаменателя по модулю меньше некоторого значения погрешности, ибо float), то решений нет — прямые либо совпадают, либо параллельны;
    • проверить на расположение полученной точки в пределах обоих отрезков.

    И последний шаг — перевести все полученные промежуточные точки в полярные координаты. Если расстояние до точки меньше, чем значение буфера по текущему индексу, то пишем в буфер. Теперь буфер готов к использованию. На этом, в принципе, вся математика и завершается.


1.2 Вертексный каркас


Теперь необходимо по данным в буфере глубины построить полигональную модель, покрывающую всю ту область, что освещает источник света. Для этого удобно использовать метод Triangle Fan

Полигон формируется из первой точки, предыдущей и текущей. Соответственно, первая точка — центр источника света, а координаты остальных точек:
  for( unsigned int index = 0; index < bufferSize; ++index ) {
    float alpha = float( index ) / float( bufferSize ) * Math::TWO_PI;
    float value = buffer[ index ];
    Vec2 point( Math::Cos( alpha ) * value, Math::Sin( alpha ) * value );
    Vec4 pointColor( color.R. color.G, color.B, ( 1.0f - value / range ) * color.A );
    ...
  }
  

и замкнуть цепочку, продублировав нулевой индекс. Цвет у всех точек одинаковый за разницей в значении прозрачности яркости — в центре максимальная яркость, на радиусе источника света (range) 0.0. Значение прозрачности так же может пригодиться во фрагментном шейдере как показатель удалённости точки от центра источника, таким образом можно заменить линейную зависимость освещённости от расстояния на более интересную, вплоть до использования текстур.
На этом этапе так же можно принудительно отдалять полученные точки на некоторое значение, чтобы поверхность, на которую падают лучи, была освещена, создавая видимость объёма.

1.3 Framebuffer


Достаточно одной текстуры, привязанной к фреймбуферу — формата GL_RGBA16F, такой формат позволит хранить значения за пределами [0.0; 1.0] с точностью half-precision floating-point.
Немного 'псевдокода'
    GLuint textureId;
    GLuint frameBufferObject;

    //текстура. width и height - размеры окна
    glGenTextures( 1, &textureId );
    glBindTexture( GL_TEXTURE_2D, textureId );
    glTexImage2D( GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL );
    glTexParameterf( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE );
    glTexParameterf( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE );
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
    glBindTexture( GL_TEXTURE_2D, 0 );

    //фреймбуфер
    glGenFramebuffers( 1, frameBufferObject );
    glBindFramebuffer( GL_FRAMEBUFFER, frameBufferObject );

    //аттач текстуры к буферу
    glFramebufferTexture2D( GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId, 0 );

    //вернуть рендер на место
    glBindFramebuffer( GL_FRAMEBUFFER, 0 );

    //ну и на всякий случай, если что-то пошло не так...
    if( glCheckFramebufferStatus( GL_FRAMEBUFFER_EXT ) != GL_FRAMEBUFFER_COMPLETE ) {
      ...
    }
    ...
    


Биндим буфер, выставляем аддитивный бленд glBlendFunc( GL_ONE, GL_ONE ) и «рисуем» освещённые области. Таким образом альфа-канал будет накапливать степень освещённости. Так же можно добавить глобальное освещение, нарисовав квад во всё окно.

1.4 Шейдеры


Вертексные шейдеры отрисовки лучей от источников света стандартные, с учетом положения камеры, а во фрагментном шейдере накапливаем цвет с учетом яркости:
    layout(location = 0) out vec4 fragData;
    in vec4 vColor;
    ...
    void main() {
      fragData = vColor * vColor.a;
    }
  

В итоге мы должны получить нечто подобное:


2. Рендер сцены в текстуру


Необходимо сцену отрендерить в отдельную текстуру, для чего создаём ещё один фреймбуфер, аттачим обычную GL_RGBA-текстуру и производим рендер обычным способом.
Допустим, есть такая сцена из небезызвестного платформера:


3. Совмещение карты освещения со сценой


Фрагментный шейдер должен быть приблизительно таким:
    uniform sampler2D texture0;
    uniform sampler2D texture1;
    ...
    vec4 color0 = texture( texture0, texCoords ); //чтение из текстуры отрендеренной сцены
    vec4 color1 = texture( texture1, texCoords ); //чтение из карты освещения
    fragData0 = color0 * color1;
  

Проще некуда. Здесь к цвету сцены color0 перед перемножением можно прибавить некоторый коэффициент на случай, если сеттинг игры крайне тёмный и необходимо видеть лучи света.
Скрытый текст
fragData0 = ( color0 + vec4( 0.05, 0.05, 0.05, 0.0 ) ) * color1;

И тут…

Если персонаж не описать простой геометрией, то тень от него будет весьма и весьма неправильной. Тени у нас строятся от геометрии, соответственно, тени от спрайтового персонажа получаются как от квадрата (хм, а Митбой, интересно, из каких соображений квадратный?). Значит текстуры спрайтов должны рисоваться максимально «квадратными», оставляя как можно меньше прозрачных областей по краям? Это один из вариантов. Можно геометрию персонажа описать более подробно, сгладив углы, но не описывать же геометрию для каждого кадра анимации? Допустим, сгладили углы, теперь персонаж почти эллипс. Если сцену делать полностью тёмной, то такая тень сильно бросается в глаза. Добавив сглаживание карты освещения и глобальное освещение картинка получается более приемлемая:
    vec2 offset = oneByWindowCoeff.xy * 1.5f; //степень размытости
    fragData = (
      texture( texture1, texCoords )
      + texture( texture1, vec2( texCoords.x - offset.x, texCoords.y - offset.y ) ).r
      + texture( texture1, vec2( texCoords.x, texCoords.y - offset.y ) ).r
      + texture( texture1, vec2( texCoords.x + offset.x, texCoords.y - offset.y ) ).r
      + texture( texture1, vec2( texCoords.x - offset.x, texCoords.y ) ).r
      + texture( texture1, vec2( texCoords.x + offset.x, texCoords.y ) ).r
      + texture( texture1, vec2( texCoords.x - offset.x, texCoords.y + offset.y ) ).r
      + texture( texture1, vec2( texCoords.x, texCoords.y + offset.y ) ).r
      + texture( texture1, vec2( texCoords.x + offset.x, texCoords.y + offset.y ) ).r
      ) / 9.0;
  

, где oneByWindowCoeff — коэффициент для пересчета пиксельных координат в тексельные.
При отсутствии глобального освещения, возможно, лучше отключать тени подобным «персонажам» либо их самих делать светящимися (идеальный, на мой взгляд, вариант), ну или заморочиться и описать геометрию объекта для всех анимаций.

Записал небольшую демонстрацию, что из всех этих размышлений и допиливаний вышло:


4. Оптимизация


Как говорится, «Сначала напиши, а потом уже оптимизируй». Первоначальный код был набросан быстро и грубо, поэтому мест для оптимизации хватило. Первое, что пришло в голову — избавиться от излишнего числа полигонов, отрисовывающих освещённые области. Если в радиусе источника света нет препятствий, то нет смысла рисовать 1000+ полигонов, настолько идеальная окружность нам не нужна, глаз просто не воспринимает разницы (или может это монитор у меня слишком грязный).
Например, для буфера глубины размерностью 1024 без оптимизации:
Скрытый текст

и с оптимизацией:
Скрытый текст

Для сцен с обилием статических объектов можно кэшировать результаты расчетов проецирования объектов в буфер, что даёт неплохой прирост, благо сокращается число косинусов/корней и прочей затратной математики. Соответственно, для каждого буфера заводим список указателей на объекты, проверяем на изменение их параметров, влияющих на положение или форму, и далее либо заливаем кэш прямиком в буфер, либо пересчитываем объект полностью.

5. Заключение


Такая техника освещения не претендует на оптимальность, быстродействие и точность, целью был сам факт реализации. Есть разные методики, как, например, построение одних теней (освещение же, как понимаю, допиливается дополнительно), зато мягких, с обилием вычислений или даже такой крайне занятный, найденный уже в процессе написания статьи (в целом логика похожа на ту, что и я использовал).
В общем, что было в планах — то реализовано, объекты отбрасывают тени, необходимая гнетущая атмосфера в игре создана, и картинка стала более приятной на мой взгляд.

6. Ссылки


Tags:
Hubs:
+93
Comments 39
Comments Comments 39

Articles