Всем привет! Мы небольшой командой уже несколько лет разрабатываем 2D стратегию Norland — симулятор средневекового королевства.
Игра двухмерная, разрабатывается на Game Maker Studio 2 и во время работы я столкнулся с множеством задач а-ля «должно быть красиво». Где-то пришлось придумать свой велосипед, где-то повезло наткнуться на описание решения похожих задач.
В свое время меня очень вдохновила статья про рендер в Graveyard Keeper — это очень классный материал для разработчика 2D игр, в сети подобного довольно мало. Поэтому надеюсь, что моя статья тоже послужит для кого-то источником вдохновения.
Тени
Для того, чтобы сделать «трехмерные» тени для 2д здания, нужно сперва разметить это здание какими-то примитивами – кубами, цилиндрами, призмами и т.д. Но эти примитивы не будут «отбрасывать тень» в привычном понимании этого слова – они сами будут тенями.
Рассмотрим, к примеру, куб (так-то это параллелепипед, но слово куб короче). Он состоит из восьми вершин. В х-компоненту нормали каждой вершины записывается 0.0, если это основание и 1.0, если это верхняя часть куба. В текстурные координаты в x-компоненту записывается высота фигуры, которая будет отбрасывать тень.
Вся остальная работа происходит в вершинном шейдере. В него передаются параметры солнца (угол над горизонтом и длина теней). Дальше элементарно – вершины, у которых normal.x равно единице, сдвигаются на указанное расстояние на нужный угол, а остальные вершины остаются на месте.
Все преобразования будут считаться афинными, т.е. параллельные грани куба останутся параллельными после преобразований.
Упрощенный вид вершинного шейдера (GLSL):
void main() {
float move_amount = u_vSun.x;
float shadow_length = u_vSun.y * 1.3;
float height = in_TextureCoord0.x;
vec4 object_space_pos = vec4(0.0);
if (in_Normal.x > 0.5) {
object_space_pos = vec4(
in_Position.x + sin(move_amount) * (shadow_length * height),
in_Position.y + cos(move_amount) * (shadow_length * height),
in_Position.z,
1.0
);
} else {
object_space_pos = vec4(
in_Position.x,
in_Position.y,
in_Position.z,
1.0
);
}
gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
}
Подобным образом создаются и другие примитивы – цилиндр и призмы для крыш двух видов (горизонтальная и вертикальная). Результат:
В итоге куб будет состоять из 8 вертексов и 10 треугольников (2 треугольника из основания куба можно выбросить – они ни на что не влияют, т.к. их все равно не видно после заливки).
Чтобы тени по итогу были полупрозрачными, но при этом в местах соприкосновения теней, не было наслоения, нужно отрисовать все тени с альфой равной единице на поверхность (surface). А уже поверхность рисовать с нужной альфой. Также поверхность можно размыть шейдером gaussian blur, чтобы сгладить несовершенство низкополигональных теней.
Z-сортировка и карта высот
Наша игра двухмерна (за исключением лопастей мельницы – использовать спрайтовую анимацию в этом случае было бы крайне расточительно из-за больших размеров каждого кадра анимационного стрипа, поэтому пришлось использовать 3d модель), но персонажи должны иметь возможность оказываться как перед зданиями, так и за ними. В простых случаях это решается как-то так:
depth = -y;
/*
Стандартная конструкция в Game Maker для автоматической
сортировки по глубине. Уже несколько лет признана устаревшей
из-за внедрения в движок системы слоев. Но до сих пор работает.
*/
Однако нам нужно, чтобы персонаж корректно отображался еще и внутри здания – а ведь в нем может быть много объектов.
В этом нам поможет автоматическая сортировка на GPU с помощью Z-Buffer.
Для тех, кто не в курсе – Z-Buffer, это такая структура данных, в которой описывается глубина каждого пикселя дробным числом от 0 до 1. Если подставить вместо чисел цвет, то получится черно-белая картинка.
В GMS 2 с буфером глубины можно работать с некоторыми ограничениями – его нельзя получить в виде текстуры, его нельзя считывать и модифицировать в шейдерах (по умолчанию так, но есть пути обхода). Но зато все еще можно просто использовать Z-сортировку! А это то, что нам нужно.
Z-сортировка позволяет отбрасывать пиксели, которые имеют бОльшую глубину (это поведение по умолчанию, но его можно поменять), чем те, которые уже нарисованы на экран (и в Z-Buffer соответственно).
Т.е. если нарисовать здание по кускам, указав каждому куску его глубину, а потом нарисовать персонажа (с глубиной равной его -y), то с помощью Z-сортировки, те пиксели персонажа, которые находятся за условным столом, просто не будут рисоваться и получится так, что персонаж будет корректно перекрываться этим столом.
Но для этого нужно разметить здание. Поверх спрайта, я расставляю квады, у которых можно настроить, будет ли этот квад вертикальной стенкой (в этом случае верхние вершины квада будут иметь ту же глубину, что и нижние вершины) или горизонтальной поверхностью (верхние и нижние вершины квада будут иметь разную глубину, в зависимости от их позиции по оси y).
На скриншоте стенки отображаются как заполненные оранжевые прямоугольники, а поверхности как пустые белые прямоугольники. У квада также можно указать высоту над землей, но это уже нюансы.
Потом эти размеченные квады преобразуются в полигоны и записываются в вершинный буфер ассета. Помимо Z-уровня, вершины несут с собой информацию о UV-координатах текстуры здания (а также UV текстуры карты светящихся ночью окон, нормалей и другие вспомогательные параметры для всяких эффектов).
Минус этого подхода очевиден для тех, кто уже работал с Z-Buffer. Ведь он не поддерживает полупрозрачность. Т.е. если в буфер сперва было нарисовано полупрозрачное стекло, то потом, если мы попытаемся нарисовать что-то за этим стеклом, у нас ничего не получится, ведь стекло ближе, чем новые рисуемые пиксели. Чтобы обойти этот момент, нужно сперва отсортировать на CPU рисуемые ассеты от дальнего к ближнему и только потом рисовать их на экран (и в буфер). Но мы подошли к этому проще – в наших зданиях нет полупрозрачных элементов 🙂
Помимо Z-Quads (так я назвал эти полигоны для указания Z-уровня), важной частью разметки ассета, являются H-Quads (квады высоты). Эти квады нужны чтобы добавить в здания перепады высот (например ступеньки и помост кафедры в храме). Тут все просто — это прямоугольник с указанием высоты. Потом в рантайме находим пересечение нижней точки персонажа с H-Quad под ним и перемещаем персонажа на указанную высоту вверх или вниз.
Карты нормалей и окон
Есть такая технология Normal mapping. Если по простому, то это текстура, в которой в r, g, b-каналы каждого пикселя записываются x, y, z-компоненты вектора нормали для этого пикселя. Т.е. «куда направлен» этот пиксель относительно мировой системы координат.
Из-за особенностей записи вектора в rgb эквиваленте, на выходе обычно получается фиолетово-красно-зеленая карта. И если передать ее в нехитрый шейдер, то двигая виртуальную лампочку со светом, можно на 2D спрайте изобразить игру теней. А это то, что нам нужно.
Осталось понять, где взять эту карту нормалей. С 3D моделью все просто — современные пакеты трехмерного моделирования позволяют сгенерировать нормаль мапу в четыре клика, ведь для этого у модели есть вся необходимая информация.
С 2D спрайтом все иначе — это просто картинка. И только смотрящий на нее человек (и наверное современные нейросети) может определить, что вот это вот скат крыши, а вот это подоконник.
Но выход есть. Для ручного или полу-автоматического (с ожидаемым средне-плохим результатом) создания карт нормалей из 2D изображений существует какое-то количество софта. Например SpriteLamp, SpriteIlluminator, Laigter, Плагины для Gimp и т.п. Можно даже написать свою небольшую софтину с одним инструментом-кисточкой и трекболом указания требуемого угла рисуемой нормали.
Чтобы обрисовать в подобной программе несложное здание, не нужно много усилий. Вот здесь вертикальная стенка, вот здесь крыша, а здесь выступающая часть, которая может подсвечиваться более интенсивно. Зачастую хватает нескольких цветов. Можно быть не супер точным в этом деле, т.к. мы храним карту нормалей в 0.25x размере от спрайта здания, так что недостатки скроются интерполяцией.
Упрощенный вертексный шейдер GLSL:
void main() {
vec4 normal_raw = texture2D(gm_BaseTexture, v_vNormalUV);
vec3 normal = normalize(normal_raw.rgb * 2.0 - 1.0);
vec3 light_dir = normalize(u_vLightDir);
// Base color
vec4 color = v_vColour * texture2D(gm_BaseTexture, v_vTexcoord);
vec3 light_color = vec3(1.0, 1.0, 0.98);
vec3 shadow_color = vec3(0.76, 0.76, 1.0);
float light_factor = smoothstep(0.2, 0.8, dot(normal, light_dir));
// Light and shadow
vec3 lighting_color = mix(shadow_color * 0.5, 1.1 * light_color, light_factor);
// Base color with light and shadow
vec4 result_color = mix(color, vec4(color.rgb * lighting_color, color.a), normal_raw.a * u_fLightStrength);
if (result_color.a < u_fAlphaDiscardValue) discard;
gl_FragColor = result_color;
}
Помимо карты нормалей используется также карта окон. При наступлении сумерек, пиксели рисуемого здания, которые соответствуют белым пикселям карты окон, должны получить оранжевый цвет.
Разумеется это не все эффекты, которые мы используем в своем рендере. На гифке из начала статьи еще есть свет вокруг окон, LUT-цветокоррекция для имитации смены времени суток (для реализации подобной штуки, могу посоветовать вот эту статью), частицы дыма из труб и т.д. А если приблизить камеру к зданию, то крыша будет сниматься с эффектом dissolve. Но это уже совсем другая история.
Внимательные читатели могли заметить, что тень не совсем совпадает (точнее совсем не совпадает) с освещением здания от карты нормалей. Мол, тень снизу, значит свет падает на заднюю, не видимую игроку, стену здания. Но при этом все равно освещается фасад.
Если бы тень от здания была сверху (т.е. солнце освещало фасад), то этого казуса можно было бы избежать, но я намеренно поместил тень именно снизу, т.к. так просто-напросто красивее. Особенно в полдень.
Надеюсь, статья сможет кому-нибудь помочь, ведь 2D игры умирать пока не собираются, а красивости все любят.