- PVSM.RU - https://www.pvsm.ru -
Добрый день, читатели!
Хотелось бы рассказать об одном из способов отрисовки освещения и затенения в 2D-пространстве с учетом геометрии сцены. Мне очень нравится реализация освещения в Gish и Super MeatBoy, хотя в митбое его можно разглядеть только на динамичных уровнях с разрушающимися или перемещающимися платформами, а в Гише оно повсеместно. Освещение в таких играх мне кажется таким «тёплым», ламповым, что непременно хотелось нечто подобное реализовать самому. И вот, что из этого вышло.
Тезисно что есть и что требуется сделать:
Для всего этого понадобился OpenGL, GLSL, технология FrameBuffer и немного математики. Ограничился версиями OpenGL 3.3 и GLSL 3.30 т.к. видеокарта одной из моих систем по нынешним меркам весьма устарела (GeForce 310), да и для 2D этого более чем достаточно (а более ранние версии вызывают отторжение из-за несогласованности версий OpenGL и GLSL). Сам по себе алгоритм не сложный и делается в 3 этапа:
Использовать будем одну из самых распространённых технологий — deferred shading [1], портировав её в 2D. Суть этого метода — отрендерить сцену перенеся камеру в позицию источника света, заполучив буфер глубины. Нехитрыми операциями с матрицами камеры и источника света в шейдере попиксельно можно узнать о затенённости, переводя координаты пикселя рендерящейся сцены в текстурные координаты буфера. В 3D используется z-buffer, здесь же я решил создать свой одномерный буфер глубины, на CPU.
Совершенно не претендую на разумность и оптимальность сего подхода, алгоритмов освещения хватает и у каждого свои плюсы с минусами. Во время обмозговывания способ казался вполне имеющим право на жизнь, и я приступил к реализации. Замечу, что во время написания статьи обнаружил вот такой способ [2]… ну да ладно, велосипед так велосипед.
Суть Z-буфера — хранение удалённости элемента сцены от камеры, что позволяет отсекать невидимые за более ближними объектами пиксели. Если в 3D сцене буфер глубины представляет собой плоскость
, то в нашем плоском мире он станет линией или одномерным массивом. Источники света — точечные, излучающий свет от центра по всем направлениям. Соответственно, индекс буфера и значение будут соответствовать полярным координатам расположения ближайшего к источнику объекта. Размер буфера я определял опытным путём, в результате чего остановился на 1024 (конечно, зависит от размера окна). Чем меньше размерность буфера, тем больше будет заметно расхождение границы объекта и освещённой области, особенно при наличии мелких объектов, а местами могут появиться совершенно неприемлемые артефакты:
Алгоритм формирования буфера:
И последний шаг — перевести все полученные промежуточные точки в полярные координаты. Если расстояние до точки меньше, чем значение буфера по текущему индексу, то пишем в буфер. Теперь буфер готов к использованию. На этом, в принципе, вся математика и завершается.
Теперь необходимо по данным в буфере глубины построить полигональную модель, покрывающую всю ту область, что освещает источник света. Для этого удобно использовать метод Triangle Fan [3]
Полигон формируется из первой точки, предыдущей и текущей. Соответственно, первая точка — центр источника света, а координаты остальных точек:
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. Значение прозрачности так же может пригодиться во фрагментном шейдере как показатель удалённости точки от центра источника, таким образом можно заменить линейную зависимость освещённости от расстояния на более интересную, вплоть до использования текстур.
На этом этапе так же можно принудительно отдалять полученные точки на некоторое значение, чтобы поверхность, на которую падают лучи, была освещена, создавая видимость объёма.
Достаточно одной текстуры, привязанной к фреймбуферу — формата 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 ) и «рисуем» освещённые области. Таким образом альфа-канал будет накапливать степень освещённости. Так же можно добавить глобальное освещение, нарисовав квад во всё окно.
Вертексные шейдеры отрисовки лучей от источников света стандартные, с учетом положения камеры, а во фрагментном шейдере накапливаем цвет с учетом яркости:
layout(location = 0) out vec4 fragData;
in vec4 vColor;
...
void main() {
fragData = vColor * vColor.a;
}
В итоге мы должны получить нечто подобное:
Необходимо сцену отрендерить в отдельную текстуру, для чего создаём ещё один фреймбуфер, аттачим обычную GL_RGBA-текстуру и производим рендер обычным способом.
Допустим, есть такая сцена из небезызвестного платформера:
Фрагментный шейдер должен быть приблизительно таким:
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 — коэффициент для пересчета пиксельных координат в тексельные.
При отсутствии глобального освещения, возможно, лучше отключать тени подобным «персонажам» либо их самих делать светящимися (идеальный, на мой взгляд, вариант), ну или заморочиться и описать геометрию объекта для всех анимаций.
Записал небольшую демонстрацию, что из всех этих размышлений и допиливаний вышло:
Как говорится, «Сначала напиши, а потом уже оптимизируй». Первоначальный код был набросан быстро и грубо, поэтому мест для оптимизации хватило. Первое, что пришло в голову — избавиться от излишнего числа полигонов, отрисовывающих освещённые области. Если в радиусе источника света нет препятствий, то нет смысла рисовать 1000+ полигонов, настолько идеальная окружность нам не нужна, глаз просто не воспринимает разницы (или может это монитор у меня слишком грязный).
Например, для буфера глубины размерностью 1024 без оптимизации:
и с оптимизацией:
Для сцен с обилием статических объектов можно кэшировать результаты расчетов проецирования объектов в буфер, что даёт неплохой прирост, благо сокращается число косинусов/корней и прочей затратной математики. Соответственно, для каждого буфера заводим список указателей на объекты, проверяем на изменение их параметров, влияющих на положение или форму, и далее либо заливаем кэш прямиком в буфер, либо пересчитываем объект полностью.
Такая техника освещения не претендует на оптимальность, быстродействие и точность, целью был сам факт реализации. Есть разные методики, как, например, построение одних теней (освещение же, как понимаю, допиливается дополнительно), зато мягких [4], с обилием вычислений [5] или даже такой крайне занятный [2], найденный уже в процессе написания статьи (в целом логика похожа на ту, что и я использовал).
В общем, что было в планах — то реализовано, объекты отбрасывают тени, необходимая гнетущая атмосфера в игре создана, и картинка стала более приятной на мой взгляд.
Автор: KoMaTo3
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/opengl/49952
Ссылки в тексте:
[1] deferred shading: http://ru.wikipedia.org/wiki/%D0%9E%D1%82%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%BD%D0%BE%D0%B5_%D0%BE%D1%81%D0%B2%D0%B5%D1%89%D0%B5%D0%BD%D0%B8%D0%B5_%D0%B8_%D0%B7%D0%B0%D1%82%D0%B5%D0%BD%D0%B5%D0%BD%D0%B8%D0%B5
[2] вот такой способ: https://github.com/mattdesl/lwjgl-basics/wiki/2D-Pixel-Perfect-Shadows
[3] Triangle Fan: http://en.wikipedia.org/wiki/Triangle_fan
[4] зато мягких: http://archive.gamedev.net/archive/reference/programming/features/2dsoftshadow/
[5] обилием вычислений: http://www.gamedev.net/page/resources/_/technical/graphics-programming-and-theory/walls-and-shadows-in-2d-r2711
[6] Видео-демонстрация результата: http://www.youtube.com/watch?v=WBQxB4DqY7Q
[7] исходный код (github) буфера глубины: https://github.com/KoMaTo3/lbuffer
[8] исходный код (github): https://github.com/KoMaTo3/LightTest
[9] Win32-сборка мини-демки: https://dl.dropboxusercontent.com/u/71135358/lighttest.zip
[10] OpenGL 3.3 спецификация: http://www.opengl.org/registry/doc/glspec33.core.20100311.withchanges.pdf
[11] GLSL 3.3 спецификация: http://www.opengl.org/registry/doc/GLSLangSpec.3.30.6.pdf
[12] Источник: http://habrahabr.ru/post/204782/
Нажмите здесь для печати.