Привет, Хабр! Недавно мы вышли в релиз с нашей игрой, которую долго и упорно готовили и в процессе которой накопилось немалое количество интересных тем, которыми стоит поделиться с сообществом. Тема будет интересна далеко не только iOS и иным мобильным разработчикам, но и всем тем, кому интересно, как всякие графические вещи работают под капотом, а также всем фанатам 2D-стратегий, коим уже третее десятилетие являюсь я сам.

Сегодня поговорим о нюансах такой важной темы, как z-индексы на изометрической поверхности (да-да, не все тут так просто как кажется некоторым умникам). В мире 3d у нас, как ни странно, есть три координаты — x, y, z — которые полностью определяют положение объекта в пространстве. Задача определения близости к камере объектов там также стоит, но ложится целиком на плечи OpenGL. Разработчик лишь оперирует высокоуровневыми параметрами типа глубины z-буфера, которые влияют на производительность, но в остальном можно довериться OpenGL как черному ящику — у него хватает информации.

Совсем иная ситуация наблюдается в нашем “псевдо-3D” мире — каждый объект имеет только (x, y) — координаты и размер спрайта. Первой же задачей, которая становится перед программистом во время написания движка, является задача определения, какие объекты должны перекрывать друг друга перед нашей виртуальной “камерой”.

Синопсис


Координаты SpriteKit (где (0;0) — центр “мира”, а Y идет вверх) в данном случае нас совершенно не интересуют, т.к. они ничего не значат в нашем с вами изометрическом “мире”, так что давайте оговоримся — у нас есть ромбовидное поле наподобие Age of Empires.



Тайл с координатами (0;0) находится в левом углу ромба, абсцисса X увеличивается “вниз” и “вправо”, т.е. растет ближе к наблюдателю, ордината Y увеличивается “вверх” и “вправо”, т.е. уменьшается по мере приближения к наблюдателю.

Также рельсы должны быть “под” поездом, дым из трубы — “над” поездом. Но не будем сейчас заморачиваться со “слоями бытия” — очевидно, ничего не мешает нам сделать сколько угодно изометрических “слайсов”, работающих по одним и тем же правилам. Примем допущение, что в одном тайле всегда расположен один объект — для наглядности большего и не надо.



Рассмотрим два поезда выше. Очевидно, что с точки зрения наблюдателя вагоны должны располагаться “ниже” поезда, т.е. их z-индекс должен быть меньше. В то же время “верхний” поезд должен “перекрываться” ближним, быть “дальше”. Можем ли мы, имея только координаты (x; y) построить карту z-индексов для каждого тайла?

Очевидно, да, используя следующую формулу (псевдокод а-ля свифт):

zIndex = pos.x * field.size.width - pos.y

Таким образом мы гарантируем, что по мере роста ординаты объекты отдаляются (-pos.y), а также с ростом абсциссы объекты приближаются (pos.x) и, что немаловажно, любой объект, имеющий абсциссу, скажем, 44, будет заведомо “ближе”, чем любой объект, имеющий абсциссу 43. Дабы добавить сюда “слоеность” (помните, рельсы под поездом, дым над трубой), достаточно добавить какую-нибудь константу “высоты” слоя:

zIndex = layerZIndex + pos.x * field.size.width - pos.y

Все, статью можно заканчивать, а себя похвалить за усвоенные в 10-м классе основы стереометрии и приступать к логике игры. Нет? Если бы! Стал бы я писать про очевидные вещи! (ну как очевидные, пару дней гробится и на это)

Мы только приступаем к самому интересному, идем дальше.

Борьба за производительность


Каждый, хоть хоть раз запускал тестовый проект под SpriteKit (или кокос, или любой иной движок), видел магические цифры — fps и nodes.



Очевидно, что fps — количество кадров в секунду, nodes — количество нод, в основном спрайтов. Но на практике больше всего садит fps не количество нод, а иной параметр, который по умолчанию не выводится, но который также можно вывести одной строчкой — количество перерисовок draws.



В одной и той же сцене, как вы сейчас видите, количество нодов около 6000, и количество отрисовок — около 120. Это на минимальном зуме (камера максимально “близко” к поверхности), 1:1.

А теперь отдалим камеру на максимальное расстояние (у нас в игре это 2.5:1)



Мы поменяли масштаб всего в 2.5 раза (это еще в примере далеко не все объекты рисуются), а количество draws возросло в 5-6 раз при неизменном nodes count!

Разумеется, количество отрисовок влияет на fps несоизмеримо больше, чем абстрактное количество нодов. SpriteKit просто не рисует ноды, которые не попадают по вьюпорт (в камеру). Единственным исключением, которое я пока нашел, являются эмиттеры частиц, которые рисуются всегда, независимо от того видны они или нет.

Теперь поговорим о том, что же значит эта “отрисовка” draw. Видеокарта располагает все ноды “слоями”, руководствуясь их z-индексами. И проходит всю картинку раз за разом, начиная от самого нижнего и заканчивая самым верхним. Количество таких циклов отрисовки — и есть draws.

Теперь вы понимаете, что если каждый крошечный объект (а карта у нас большая, примерно 6000 х 3000) рисовать со своим собственным z-индексом, это угробит производительность любого телефона.

Проблемы лучше всего видны на старых 5 и 5с, но и наличие iPhone 10 ничего не гарантирует — при неправильном подходе можно угробить какое угодно мощное железо. В нашей игре одним из краеугольных камней была предельная четкость. На самом близком зуме спрайты один в один соответствуют ретиновым пикселям. Надо сказать, что в большинстве мобильных игр разрешение на порядки меньше, поэтому требования не такие громадные, но мы же делали качественно, как для себя…

Вот и приходится идти на хитрости.

  • Все объекты, с которыми не взаимодействует игрок, и которые находятся на одном уровне по Х-координате, можно вообще слить в один спрайт. Для видеокарты куда проще нарисовать один большой спрайт, чем 10 маленьких. Поэтому полосы леса между дорогами — это целостные спрайты, состоящие из нескольких деревьев. А деревья, которые не перекрывают пути и другие деревья — вообще вшиты в карту. В альфах, кстати, было довольно много багов, когда вековой дуб рос прямо под рельсами поезда или под железнодорожным светофором, так что внимательно тестируйте свою игру чтобы не насмешить пользователей.
  • Объекты, имеющие один z-индекс, рисуются в том порядке, в котором попадают в видеокарту. Т.е. добавив “далекие” объекты раньше “близких”, они правильно лягут, но не увеличат количество отрисовок видеокарты.

Все это позволяет сократить количество draws в разы, исправляя fps даже на стареньких iPhone. Пришлось на них сильно ограничить некоторые эффекты, но Apple не выпускает для них апдейтов уже год — грех будет жаловаться!

Высота рельефа


Ну все, движок готов, можно уже приступать к чему-то интересному? Кому-то и можно, а нам еще рано. Ведь поезд должен красиво выезжать из тоннеля, и тут все не так просто, как может показаться.



Поезд должен располагаться “выше”, чем “дальняя” стенка тоннеля, и “ниже”, чем крыша тоннеля и следующие за ним горы. Красиво ведь, когда карта такая многоуровневая, с перепадами высот — опять же, не бездушную ерунду делаем, а то что самим нравится!

Но вернемся к деталям — для этого карта была “разрезана” следующим образом.



Внутренняя стенка тоннеля и все остальное левее-ниже и



верх тоннеля вместе с горами, в которые он перетекает. Тут уж никакие процедурные генерации z-индексов не помогут, только суровый белорусский хардкод.

Внимательный хабраюзер заметил на скриншоте из игры, что близ тоннелей деревья аккуратно “выкошены”, обнажая девственно пляжный песочек. Эта, казалось бы, недоработка, происходит из принципиальной невозможности реализации таких посадок деревьев в 2D. Поезд, выходя из туннеля, должен быть заведомо “выше” деревьев, которые он перекрывает, закрывая их собой. Но эти же деревья должны перекрывать собою крышу тоннеля, под которую должен заезжать поезд! А крыша должна быть выше поезда, и так по кругу, имеем логическое противоречие…

Примерно по схожей причине, из-за несовершенства графического движка, в старых играх типа Duke Nukem и Doom2 нет больших перепадов высот и этажности зданий.

Вот поэтому близ тоннелей деревья и не растут.

Надеюсь, было интересно, игрушка вживую вот тут (free to play), следующая статья цикла будет про красивую реалистичную 2D-воду, не пропустите!

P.S. Кстати, видео для привлечения внимания можно посмотреть на youtube в нормальном качестве.

P.P.S. Игра пока доступна только в СНГ, Канаде и Ирландии, если кто-то захочет посмотреть из других стран, присылайте в личку почту с appleId — добавлю в TestFlight

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


  1. DrZlodberg
    25.06.2018 10:25

    Примерно по схожей причине, из-за несовершенства графического движка, в старых играх типа Duke Nukem и Doom2 нет больших перепадов высот и этажности зданий.
    Вполне себе есть, особенно в сторонних уровнях. Там всего-лишь нельзя увидеть (в думе) или сложно (в дюке) то, что находится под большим углом вниз или вверх.
    А ещё Дюку с его портальным движком и этажность проблем не представляла (чего в игре хватало)


    1. AstarothAst
      25.06.2018 10:31

      В DooM'е, если склероз не врет, не могло быть помещений друг под другом, то есть ни о какой этажности речи не идет, просто разница в высоте.


      1. DrZlodberg
        25.06.2018 10:47

        Так я и указал, что этажность только в Дюке была. В современных портах, кстати, есть и этажность, причём разными способами.


        1. iago Автор
          25.06.2018 11:35

          В Дюке да, была, но кривая на тот момент, помню мне в детстве аж голова болела от некоторых искажений из-за несовершенства движка


  1. AngReload
    25.06.2018 10:35
    +1

    На КПДВ дым от поезда проходит под стенкой левого тоннеля — после прочтения статьи я знаю куда смотреть :)


    1. iago Автор
      25.06.2018 11:34

      Ух ты, точно, полгода на этот дым смотрю и поезда тысячами отправляю, и не видел :) Это можно было бы решить, если верхнюю текстуру тоже разрезать для каждого тоннеля, но будем считать обычные пользователи ничего не заметят :)


  1. lgorSL
    25.06.2018 10:38

    Почти все эти проблемы можно было решить, если не присваивать спрайту фиксированную глубину, а, как в 3д, интерполировать её между вершинами спрайта.


    zIndex = layerZIndex + pos.x * field.size.width - pos.y

    Х и у похожи друг на друга, чем вам не подошло просто z = -(pos.z + pos.x - pos.y)? Для правильного рисования вертикальных элементов можно дополнительно хранить высоту для каждого пикселя, и тогда проблем типа "дерево заслоняет въезд в тоннель, который заслоняет поезд, а поезд заслоняет дерево" вообще не будет. Ну или можно высоту не хранить, просто вершины дерева сделать такими, чтобы оно было реально вертикальным.


    С вызовами отрисовки вы что-то намудрили. Если использовать z-координату по назначению, то можно будет рисовать спрайты в произвольном порядке и сгруппировать их по содержанию, и например, за один вызов нарисовать всю землю, (ну будет на ней 1200 полигонов, для видеокарты это совсем не страшно, она и 10 тысяч потянет), ещё одним вызовом — деревья и т.п. (на самом деле, вызовов будет чуть побольше: надо будет включить шейдер, передать видеокарте буфер вершин, установить юниформы и только потом вызывать рисование)


    1. iago Автор
      25.06.2018 11:21

      чем вам не подошло просто z = -(pos.z + pos.x — pos.y)

      в этом случае как-то еще надо задавать pos.z. Допустим, у меня по слоям именно так и задается (layerZIndex), чтобы рельсы были под поездом, а дым над поездом, временно пренебрежем pos.z. Теперь поясню, почему я умножаю на размер поля. Возьмем две точки (можете смотреть на первую иллюстрацию, ту что не гифка а сетка) с координатами (7,1) и (8,2). По вашей формуле они будут иметь одинаковый z1 = -(7 — 1) и z2 = -(8 — 2), z1=z2=-6 (почему там кстати минус?). В вашем случае их перекрываемость не детерминирована, в моем же позиции по X-координате дается больший «вес», чем по Y-координате и (8,2) будет заведомо «ближе» к камере, чем любая точка с координатой x=7. Изначально я делал именно так как вы описали, так подсказывает логика, но только до первых багов :)


    1. iago Автор
      25.06.2018 11:26

      можно дополнительно хранить высоту для каждого пикселя
      если такие отрисовки по маске встроены в видеокарту, как например коллизии полигонов в 3d, то будет работать возможно даже быстро. Но в 2d скорее всего такого нет (в SpriteKit точно нет), а такая отрисовка будет тормозить сильнее чем маска если писать вручную. Да и составлять для каждого спрайта вручную карту высоты (а там спрайты каждого куска леса где-то 1000х500, не меньше) — то еще удовольствие. В общем, при всем уважении к вашим статьям и опыту, я понимаю что вы опытный gamedev-разработчик, но в 3d, в 2d есть куча своих сложностей и, как ни странно, во многих вещах 2d тормозит больше 3d


    1. iago Автор
      25.06.2018 11:30

      С вызовами отрисовки вы что-то намудрили.
      ну и с вызовами отрисовки, возможно, у вас опыт какого-то принципиально другого движка (в 3d, предполагаю, отрисовка более оптимизирована и поверхности рисуются по слоям лучше), а тут абсолютно экспериментально подтверждено — +1 спрайт с иным zPosition дает ровно +1draws, +1draws даже статичного спрайта дает ощутимый минус к fps, а если спрайт еще и движется то на таком разрешении я усаживал iPhone 5 на 30-40 просто движущихся спрайтов с разными zPosition. Я вообще считаю 2d-графику сильно хуже внутри сделанной, чем 3d, а уж обычная иерархия вьюшек даже на айфоне — написана просто убого и страшно с точки зрения производительности.


      1. lgorSL
        25.06.2018 12:25

        У нас немного разный подход. Похоже, Вы смотрите на сцену как на холст, на котором можно в каком-то порядке нарисовать спрайты, а я — как на объёмную сцену, на которой можно произвольно располагать и поворачивать прямоугольники с картинками.


        Возьмем две точки (можете смотреть на первую иллюстрацию, ту что не гифка а сетка) с координатами (7,1) и (8,2). По вашей формуле они будут иметь одинаковый z1 = -(7 — 1) и z2 = -(8 — 2), z1=z2=-6 (почему там кстати минус?). В вашем случае их перекрываемость не детерминирована...

        Да, эти две точки будут иметь одинаковую глубину. И это логично. Если нарисовать множество точек с одинаковой глубиной, то получится горизонтальная прямая. Перекрываться точки они не будут, так как они находятся в разных частях экрана. А вот спрайты — да, могут перекрываться. Как вариант, можно ещё попробовать в нарисовать спрайты с точностью до пикселя, чтобы не было перекрытий или сделать так, чтобы перекрывающиеся части выглядели одинаково. Ну или сделать Вашим сопособом. Минус — типа при меньшей z координате объект становится ближе.


        в 2d есть куча своих сложностей и, как ни странно, во многих вещах 2d тормозит больше 3d

        Я не знал ограничений движка, который Вы используете. Посмотрел — действительно, там нет вершинных шейдеров и мои идеи не получится реализовать.
        Но в общем и целом — нет чёткого разделения. Видеокарты сделаны именно под 3д, и 2д является частным случаем. Совершенно нормально в плоской графике использовать z-координату и прочие хитрости из 3д игр — в идеале это не должно влиять на производительность.


        +1 спрайт с иным zPosition дает ровно +1draws, +1draws даже статичного спрайта дает ощутимый минус к fps, а если спрайт еще и движется то на таком разрешении я усаживал iPhone 5 на 30-40 просто движущихся спрайтов с разными zPosition.

        Это уже борьба с особенностями движка начинается. Похоже, его разработчики не планировали рисовать такое большое количество спрайтов. Если смотреть с точки зрения железа, то 600 спрайтов — не предел.


        Например, в советах к Unreal Engine пишут:


        Triangle count of the entire scene should be <=500k for any view. This has been determined to be the maximum poly count that can hit 30fps on both iPad4 and iPad Air.

        Лично мне цифра в 500 тысяч треугольников на сцене кажется слишком большой и более реальным выглядит ограничение в 50-100 тысяч. В любом случае, это на пару порядков больше, чем нужно для рисования шестисот прямоугольников.


        1. iago Автор
          25.06.2018 12:44

          Все так, я смотрю именно как на холст, на котором при помощи ухищрений создается иллюзия 3d, а по-другому 2d игры и не работают, можно конечно повернуть спрайт и тут, но что ж это будет за плоское дерево такое :) А если использовать уже ваши подходы в моделированию настоящего 3d-пространства, мы уже выйдем за рамки 2d и будем изобретать Unreal Engine вместо того, чтобы повторить некоторые фишки движка, скажем, Age of Empires 2.

          Про горизонтальную прямую почти согласен, но и тут не забывайте что в псевдо-3d не аксонометрия, с которой вы привыкли работать в 3d, а изометрия, так что тут не работают законы перспективы и использовать честные 3d-подходы не всегда целесообразно, да и надо ли — согласитесь, игрушка выглядит как красивое 2d из времен AoE2, только в ретине, а я именно этого и добивался.

          По поводу количества же полигонов — я всегда удивлялся, как видеокарты, которые рисуют такие миры в 3d, дохнут от 2d-игрушек и чтобы даже обеспечить плавный скролл в не самом примитивном лэйауте нужно знатно попотеть. Поэтому эмиттер частиц, для которого я думаю в видеокарте куча отдельных конвейеров, она рисует на ура, а 50-60 одновременно движущихся, пусть линейно, спрайтов — уже ложат fps даже на 6 айфоне


          1. AstarothAst
            25.06.2018 12:58

            Может быть я глупость спрошу, но почему бы не собрать сцену в памяти, и не отрисовать ее разом, как один большой спрайт, вместо того, что бы выводить поочередно все несколько сотен спрайтов?


            1. iago Автор
              25.06.2018 15:05

              На это есть несколько причин:
              — Объекты (например поезда) движутся относительно сцены, поэтому они должны быть на разных z-позициях и объекты сцены должны быть разными спрайтами
              — видеокарта iPhone 5 не держит текстур размером больше где-то 1.5к х 1.5к, так что их приходится резать на несколько частей

              А вообще все, что могло быть слито, я конечно же слил в одну большую текстуру. Отдельными спрайтами сцена представлена только там, где игровые объекты попадают за нее, т.е. деревья, здания, тоннели, рельсы, мосты. Их так много в nodes потому что, например, рельсы — это много одинаковых маленьких спрайтов, но на draws это как раз совершенно не влияет, видюха отлично умеет рисовать много одинаковых спрайтов. Да и много разных умеет в один проход, лишь бы у них был одинаковый zPosition.