Всем привет! Мы небольшой командой уже несколько лет разрабатываем 2D стратегию Norland — симулятор средневекового королевства.

Игра двухмерная, разрабатывается на Game Maker Studio 2 и во время работы я столкнулся с множеством задач а-ля «должно быть красиво». Где-то пришлось придумать свой велосипед, где-то повезло наткнуться на описание решения похожих задач.

В свое время меня очень вдохновила статья про рендер в Graveyard Keeper - это очень классный материал для разработчика 2D игр, в сети подобного довольно мало. Поэтому надеюсь, что моя статья тоже послужит для кого-то источником вдохновения.

Итоговый результат в игре
Итоговый результат в игре

Тени

Для того, чтобы сделать «трехмерные» тени для 2д здания, нужно сперва разметить это здание какими-то примитивами – кубами, цилиндрами, призмами и т.д. Но эти примитивы не будут «отбрасывать тень» в привычном понимании этого слова – они сами будут тенями.

Рассмотрим, к примеру, куб (так-то это параллелепипед, но слово куб короче). Он состоит из восьми вершин. В х-компоненту нормали каждой вершины записывается 0.0, если это основание и 1.0, если это верхняя часть куба. В текстурные координаты в x-компоненту записывается высота фигуры, которая будет отбрасывать тень.

1 — вершина будет двигаться, 0 — вершина останется на месте
1 — вершина будет двигаться, 0 — вершина останется на месте

Вся остальная работа происходит в вершинном шейдере. В него передаются параметры солнца (угол над горизонтом и длина теней). Дальше элементарно – вершины, у которых 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;
}
На самом деле куб выглядит как на картинке 2. Т.е. и вершины "1" и вершины "0" находятся в одних и тех же координатах попарно. На рисунке 3 видно, как сдвигаются вершины "1" относительно источника света. На рисунке 4 представлен финальный вид тени после работы фрагментного шейдера, который просто заливает все черным цветом
На самом деле куб выглядит как на картинке 2. Т.е. и вершины "1" и вершины "0" находятся в одних и тех же координатах попарно. На рисунке 3 видно, как сдвигаются вершины "1" относительно источника света. На рисунке 4 представлен финальный вид тени после работы фрагментного шейдера, который просто заливает все черным цветом

Подобным образом создаются и другие примитивы – цилиндр и призмы для крыш двух видов (горизонтальная и вертикальная). Результат:

Примитивы, из которых собираются тени для всех зданий
Примитивы, из которых собираются тени для всех зданий

В итоге куб будет состоять из 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-глубины различных частей спрайта на примере внутреннего убранства храма
Размеченные квады для определения Z-глубины различных частей спрайта на примере внутреннего убранства храма

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

Минус этого подхода очевиден для тех, кто уже работал с Z-Buffer. Ведь он не поддерживает полупрозрачность. Т.е. если в буфер сперва было нарисовано полупрозрачное стекло, то потом, если мы попытаемся нарисовать что-то за этим стеклом, у нас ничего не получится, ведь стекло ближе, чем новые рисуемые пиксели. Чтобы обойти этот момент, нужно сперва отсортировать на CPU рисуемые ассеты от дальнего к ближнему и только потом рисовать их на экран (и в буфер). Но мы подошли к этому проще – в наших зданиях нет полупрозрачных элементов :)

Помимо Z-Quads (так я назвал эти полигоны для указания Z-уровня), важной частью разметки ассета, являются H-Quads (квады высоты). Эти квады нужны чтобы добавить в здания перепады высот (например ступеньки и помост кафедры в храме). Тут все просто — это прямоугольник с указанием высоты. Потом в рантайме находим пересечение нижней точки персонажа с H-Quad под ним и перемещаем персонажа на указанную высоту вверх или вниз.

Z-Quads и H-Quads в деле
Z-Quads и H-Quads в деле

Карты нормалей и окон

Есть такая технология 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 игры умирать пока не собираются, а красивости все любят.

Комментарии (14)


  1. coremission
    21.09.2022 12:56
    +2

    Очень здорово, что вы добавляете такие детали в игру, класс!

    У меня такой вопрос, а зачем вот этот шаг вначале с представлением примитивами (кубами, цилиндрами)? Мне кажется просто же можно использовать локальные координаты позиций вершин и какой-то uniform параметр высоты основания, или пересчитать все локальные координаты, чтобы локальный (0,0,0) был в основании. И никакие примитивы делать не нужно... Или я что-то упускаю?

    Еще рекомендую технику: Horizon Mapping, описана в книге Эрика Ленгьеля еще богаче может картинку сделать (в книге FGED2):
    Horizon Mapping

    • 7.8.1 Horizon Map Construction

    • 7.8.2 Rendering with Horizon Maps


    1. coremission
      21.09.2022 12:58
      +3

      upd: я понял - игра то 2D


    1. SilentPhil Автор
      21.09.2022 15:24

      Спасибо!

      Да, игра в 2D, поэтому особо много информации из спрайтов не вытащишь, приходится изощряться.

      Скоро будет продолжение про другие эффекты, которые я успел сделать для проекта.


  1. Zamuka
    21.09.2022 18:27

    Но ведь можно положить информацию о высотах в спрайт в момент его создания, тогда не нужны будут нейросети для высасывания из пальспрайта.


  1. butuzoff_ya
    21.09.2022 19:04
    +1

    Жду вашу игру! Во многом потому, что сам делаю стратегию в гамаке. Ну как, только хочу начать, лол. Но очень интересно, как вы всё это размутите!


    1. SilentPhil Автор
      21.09.2022 23:32
      +1

      Спасибо)

      Если позволите, то я возьму на себя ответственность за непрошенные советы - не пытайтесь делать сложную стратегию на гмс. Приходится сражаться с движком, чтобы выжать приемлемый фпс на необходимом количестве акторов с сложной системой поведения. Отсутствие нативной мультипоточности в движке дает о себе знать.

      Обновление 2.3+ улучшило синтаксис gml и позволило строить сложные архитектуры (но пришлось переписать половину проекта), но до идеального 2д движка ещё далеко.

      Если не пользуйтесь GMEdit, то мой второй непрошенный совет - попробуйте. Это лучше, чем стандартная IDE гейммейкера. Типизация делает чудеса на сложных проектах.


      1. butuzoff_ya
        22.09.2022 13:49

        Спасибо за советы! Да, конечно, я никогда и не думал делать что-нибудь сложное. Тем более, что я работаю один.

        А вы добавляете поддержку геймпада в игру? Вообще, планируете выходить на консоли?


  1. lgorSL
    21.09.2022 20:03
    +3

    Я долго пытался понять, что же мне кажется неправильным в гифках - солнце двигатется не в ту сторону! Как будто мы в южном полушарии.
    Сам подход классный, но если так заморачиваться с тенями, нормалями и прочим - возможно, проще сразу 3д модель рендерить в изометрическую камеру.


    1. SilentPhil Автор
      21.09.2022 23:25

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

      Насчёт 3d модели - все верно, так и есть. 2д накладывает очень много ограничений и требований к дополнительным телодвижениям. Но фарш уже не повернуть назад и нужно работать с тем, что есть и нужно сделать красиво!


  1. masyaman
    22.09.2022 06:13

    Хотелось бы увидеть похожую анимацию, но в более сложной сцене. Если поставить рядом 2 здания, деревья и ещё что-то, то как свет и тени будут взаимодействовать между собой? Нет ли там какой-то неришаемой проблемы?

    В статьях часто рассказывают о том, что получилось. И это, безусловно, круто и интересно читать. Но ещё интереснее читать что у людей не получилось, какие ограничения, где границы применимости подходов.


  1. Aquahawk
    22.09.2022 09:03

    давным давно было шикарное демо на эту тему, 13 лет назад, черт возьми.


    1. masyaman
      22.09.2022 17:09

      Игра со светом интересная, а вот с тенями тут не сложилось, насколько я вижу. Есть только базовое затенение земли, движения тени в зависимости от источника света я не заметил.

      Автор как раз работает с тенью, но интересно было бы посмотреть как она взаимодействует с другими объектами.


      1. SilentPhil Автор
        22.09.2022 20:54

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


        1. masyaman
          22.09.2022 21:47

          Спасибо за объяснение. Жаль, конечно. В тайне надеялся, что какая-то хитрость есть.

          Получается, что тень падает только на землю, верно? Левел дизайнерам надо по возможности избегать болших объектов побоизости? Иначе могут быть ситуации, когда, например, часть здания ярко освещена, хотя явно должа быть в тени.