Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Что первое приходит в голову разработчику инди-игры, когда он сталкивается с необходимостью добавления фичи, представления о реализации которой толком не имеет? Разумеется, он идёт искать следы тех, кто уже прошёл этот путь и удосужился записать свой опыт. Так поступил и я некоторое время назад, приступая к созданию теней в своей игре. Найти нужную информацию — в виде статей, уроков и гайдов — не составило особого труда. Однако, к моему удивлению, я обнаружил, что ни одно описанное решение мне попросту не подходит. Поэтому, реализовав своё собственное, я решил поведать о нём миру.
Стоит предупредить заранее, что данный текст не претендует на бытие неким ультимативным гайдом или мастер-классом. Использованный мною метод может быть не универсальным, далеко не самым эффективным и не закрывающим задачу создания двухмерных теней в полной мере. Это скорее история о том, к каким ухищрениям пришлось прибегнуть неопытному разработчику в моём лице для достижения удовлетворяющего его требованиям результата.
Сам результат перед вами:
А подробности пути к его достижению ждут вас под катом.
Итак, на момент принятия решения о добавлении в игру теней, у меня имелось:
Для всего этого необходимы были самые простейшие тени, повторяющие контуры объекта, и отбрасываемые от единственного фиксированного источника освещения.
При этом следовало трепетно отнестись к производительности. Ввиду специфики жанра и особенностей его реализации, большинство объектов, отбрасывающих тени, в каждый момент времени находятся непосредственно на экране. А их общее число может составлять больше сотни, если говорить об игровых сущностях, и пару тысяч, если говорить об отдельных спрайтах.
Собственно, основная загвоздка оказалась в том, что Dwarfinator, грубо говоря — 2,5D игра. Подавляющее большинство объектов существует в двухмерном пространстве с осями X и Y, а ось Z используется крайне редко. Визуально же, и отчасти, геймплейно, ось Y используется для отображения как высоты, так и глубины, разделяясь таки образом на виртуальные оси Y и Z. Использовать в такой ситуации для создания теней стандартные средства Unity не представлялось возможным.
Но на самом деле, честного освещения мне и не требовалось, достаточно было иметь возможность вручную создавать тень для каждого объекта. Поэтому самое простое, что мне пришло в голову — просто разместить позади каждой сущности её копию, повёрнутую в трёхмерном пространстве так, чтобы имитировать нахождение на поверхности локации. Всем спрайтам такой псевдотени задавался чёрный цвет, при этом сохранялась иерархическая структура хозяина тени, что позволяло синхронно с хозяином анимировать её таким же аниматором.
Выглядела такая синхронная анимация примерно так:
Однако тени требовалась прозрачность. Самым простым решением было задать её каждому спрайту тени. Но такая реализация выглядела неудовлетворительно — спрайты накладывались друг на друга, образуя в месте наложения менее прозрачные области.
На скриншоте ниже видно, как выглядит тень из нескольких полупрозрачных сегментов. Также видны использованные параметры искажения тени: поворот по оси X на -50 градусов, поворот по оси Y на -140 градусов, и масштаб по оси X, увеличенный в 1.3 раза относительно родительского объекта.
Стало очевидно, что прозрачность нужно накладывать на тень как на цельный объект. Первым экспериментом на эту тему стало навешивание на тень камеры, отрисовывающей эту тень в RenderTexture, которая потом уже использовалась в качестве материала прикреплённым к родителю тени Plane. Ему уже можно было без проблем задавать прозрачность. Сами же тени находились за пределами кадра, чтобы избежать наложения друг на друга областей захвата камер. Подход работал, но оказалось, что уже пара десятков теней вызывает серьёзные проблемы с производительностью, преимущественно, из-за числа находящихся на сцене камер. Кроме того, ряд анимаций предполагал значительное перемещение отдельных спрайтов моба в рамках его корневого объекта, из-за чего в поле зрения камеры должна была находиться область, значительно превышающая размер реального изображения в отдельный момент времени.
Решение нашлось быстро — если нельзя отрисовывать каждую тень отдельной камерой — почему бы не отрисовывать одной камерой все тени? Всё, что нужно было сделать — отвести под тени отдельную область сцены, несколько выше поля зрения основной камеры, направить на эту область дополнительную камеру, и отображать её вывод между локацией и остальными сущностями.
Ниже можно наблюдать пример вывода этой камеры:
Производительность от такой реализации страдала гораздо меньше, так что решение было сочтено рабочим и применено ко всем мобам, статичным объектам и снарядам. Далее последовала очередь спрайта локации. Использовать один спрайт на все объекты, как это было реализовано ранее, оказалось невозможным. Использование копии объекта в качестве его же тени хорошо работает только до тех пор, пока объект полностью плоский. Уже при создании теней для мобов было заметно, что разнесённые по третьей координате точки соприкосновения с поверхностью нарушают корректность тени относительно этих точек.
Следующий скриншот демонстрирует пример такого нарушения. За точку соприкосновения с поверхностью принята пятка моба, но тени ступней уже выходят за рамки самих ступней.
И если в случае с ногами огра ещё можно немного изменить положение тени и замаскировать проблему, то для нескольких десятков стволов деревьев на это нет и шанса. Все объекты локации, которые должны были отбрасывать тень, следовало сделать отдельными GameObject. Именно так я и поступил, разместив на префабе локации экземпляры соответствующих разрушаемых объектов и отключив им неиспользуемые в таком положении скрипты. Заодно благодаря этому стало возможным включить их в общую сортировку объектов сцены, и улетающие за пределы локации снаряды больше не отрисовывались строго поверх всех объектов, а пролетали между ними. К тому же стало возможным сделать сами объекты анимированными.
Но тут меня поджидала новая неприятность. С тенями и десятками новых объектов максимальное количество одновременно находящихся на сцене GameObject, а вместе с ними и компонентов Animator и SpriteRenderer, возросло более чем в два раза. Когда я выпускал на локацию всю волну мобов, что составляло порядка 150 штук, Profiler укоризненно показывал мне примерно 40мс, уходивших только на рендеринг и анимацию, а частота кадров в целом колебалась в районе 10. Я отчаянно оптимизировал собственные скрипты, борясь за каждую миллисекунду, но этого было недостаточно.
В поисках дополнительных средств оптимизации, я набрёл на просторах документации и гайдов на динамический батчинг.
Frame Debugger показывал, что батчатся у меня, в лучшем случае, детали каждого объекта или моба по отдельности. Создав для первых и для вторых по атласу спрайтов, я добился батчинга теней до всего нескольких вызовов отрисовки, но владельцы этих теней батчиться между собой упорно отказывались.
Эксперименты на отдельной сцене показали, что динамический батчинг ломается при наличии у объектов компонента SortingGroup, который я использовал для сортировки отображения сущностей на экране. Обойтись без него, в теории, было возможно, однако выставление значений сортировки для каждого спрайта и системы частиц в объекте по отдельности могло выйти ещё дороже, чем отсутствие батчинга.
Но кое-что мне не давало покоя. Объект-тень, являясь в реальной сцене потомком объекта-хозяина, технически входил в тот же самый SortingGroup, однако с динамическим батчингом объектов-теней проблем не возникало. Единственное отличие было в том, что объекты-хозяева отрисовывались основной камерой сразу на экран, а объекты-тени — сначала в RenderTexture.
В этом и оказалась загвоздка. Что именно является причиной такого поведения — интернету неведомо, но при рендеринге камерой изображения в RenderTexture, SortingGroup больше не ломали батчинг. Решение казалось весьма странным, нелогичным, и вообще самым что ни на есть костылём. Но реализовав рендеринг сущностей тем же методом, что и рендеринг теней, и получив таким образом помимо слоя теней, ещё и слой сущностей, я добился уже вполне приемлемых значений производительности.
Скриншот ниже демонстрирует пример отрисовки слоя сущностей.
Итого в общем случае рендеринг некоторой сущности в координате Y выглядит так:
Такой вот слоёный пирог.
На скриншоте ниже камера редактора переведена в трёхмерный режим для демонстрации расположения слоёв относительно друг друга.
Но как выяснилось в процессе тиражирования решения на другие сущности, общий случай не покрывал все возможные сценарии. Например, существовали сущности, находящиеся на некоторой высоте относительно поверхности, в частности, снаряды и некоторые персонажи катсцен. Кроме того, снаряды ещё и имели свойство поворачиваться в зависимости от направления своего движения по экрану, из-за чего помимо выставления им точки пересечения объекта и его тени, понадобилось выделять вращаемую часть в отдельный дочерний объект, править логику вращения снаряда и их анимации.
Следующий скриншот показывает пример вращения снарядов и их теней.
Летающие персонажи же, как и запланированные летающие мобы, могут вдобавок перемещаться в рамках своей виртуальной Y координаты, что потребовало создания механизма расчёта положения тени из положения её хозяина на виртуальной оси Y.
На гифке ниже изображён пример перемещения объекта по высоте.
Другим выбивающимся из общей концепции случаем оказался танк. В отличие от всех остальных сущностей, танк имеет весьма существенный размер по виртуальной оси Z, а общая реализация теней, как уже упоминалось, требует, чтоб объект был практически плоским. Самым простым способом это обойти были вручную нарисованные формы теней для отдельных деталей танка, благо поместить на слой теней можно было что угодно.
Для корректного построения рисованных вручную теней мне пришлось на основе скриншота уже существующей тени собрать конструкцию из линий, которую можно наблюдать на скриншоте ниже.
Если отмасштабировать и разместить эту конструкцию таким образом, что верхняя часть будет находиться в некоторой точке родительского объекта, а нижняя — в точке его соприкосновения с поверхностью, правый угол конструкции покажет место, в котором должна находиться соответствующая точка тени. Спроецировав таким образом несколько ключевых точек, не составляет особого труда построить по ним всю тень.
Помимо этого, отдельные детали танка могли иметь разную высоту крепления дочерних деталей, что, как и в случае с летающими персонажами и мобами, требовало подстройки положения тени каждой конкретной детали.
На скриншоте ниже показан танк, его тень в сборе и она же в виде отдельных частей.
Отдельной болью оказались тени стен. На момент начала работы над тенями стены имели ту же природу, что и детали танка — один объект из нескольких десятков отдельных спрайтов. Однако при этом у стен имелось по нескольку состояний, управляемых аниматором.
Крепко задумавшись, что с ними делать, я пришёл к выводу, что концепцию стен необходимо менять. В результате стены были разделены на секции, каждая из которых имеет собственный набор состояний, собственный аниматор и собственную тень. Это позволило использовать для параллельных оси X секций такой же подход к созданию теней, как и с мобами, а для тех секций, что не подходили под это правило уже придумывать что-то своё. В отдельных случаях пришлось создавать для тени секции собственный аниматор и вручную задавать положение спрайтов.
Например, в случае секции, отображённой на скриншоте ниже, тень сделана путём применения искажения для каждого отдельного бревна вместо всей секции целиком.
Вот, собственно, и всё. Несмотря на все вышеперечисленные нюансы, изначально поставленная задача оказалась выполнена в полном объёме, и теперь мой проект может похвастаться вполне себе приличными на вид тенями, пусть и несколько сомнительного происхождения. Надеюсь, благодаря этой статье, для следующего инди-разработчика, задавшегося аналогичным моему вопросом, интернет станет чуточку полезнее, уж если не в качестве примера для подражания, то хотя бы в качестве чужой ошибки для собственного обучения.
Стоит предупредить заранее, что данный текст не претендует на бытие неким ультимативным гайдом или мастер-классом. Использованный мною метод может быть не универсальным, далеко не самым эффективным и не закрывающим задачу создания двухмерных теней в полной мере. Это скорее история о том, к каким ухищрениям пришлось прибегнуть неопытному разработчику в моём лице для достижения удовлетворяющего его требованиям результата.
Сам результат перед вами:
А подробности пути к его достижению ждут вас под катом.
Немного о самой игре
Dwarfinator — двухмерный base defence/side-scroll shooter, разрабатывающийся с прицелом на мобильный и десктопный сегменты. Геймплей состоит из планомерного уничтожения волн противников в двух чередующихся режимах — защиты и погони. Прогрессия игрока включает в себя прокачку «танка» путём улучшения и замены различных его элементов вроде оружия, двигателя и колёс, а также повышение уровня и изучение активных и пассивных навыков. Прогрессия окружения предполагает постоянное увеличение числа мобов в волне, добавление в волну новых типов врагов по мере прохождения локации, и последовательную смену нескольких локаций, каждая из которых располагает собственным набором противников.
Постановка задачи
Итак, на момент принятия решения о добавлении в игру теней, у меня имелось:
- локация в виде двух спрайтов, один для отображения позади мобов и прочих сущностей, второй для отображения впереди них же;
- мобы и статичные разрушаемые объекты, постоянно анимируемые и состоящие из отдельных спрайтов в количестве от нескольких штук до нескольких десятков;
- снаряды, собственные и вражеские, представленные в большинстве случаев либо одним спрайтом, либо системой частиц, в последнем случае тени не требовалось;
- танк, состоящий из нескольких деталей, собранных по той же схеме, что и мобы;
- стены, имеющие несколько фиксированных состояний, являющиеся, опять же, набором отдельных спрайтов.
Для всего этого необходимы были самые простейшие тени, повторяющие контуры объекта, и отбрасываемые от единственного фиксированного источника освещения.
При этом следовало трепетно отнестись к производительности. Ввиду специфики жанра и особенностей его реализации, большинство объектов, отбрасывающих тени, в каждый момент времени находятся непосредственно на экране. А их общее число может составлять больше сотни, если говорить об игровых сущностях, и пару тысяч, если говорить об отдельных спрайтах.
Реализация
Собственно, основная загвоздка оказалась в том, что Dwarfinator, грубо говоря — 2,5D игра. Подавляющее большинство объектов существует в двухмерном пространстве с осями X и Y, а ось Z используется крайне редко. Визуально же, и отчасти, геймплейно, ось Y используется для отображения как высоты, так и глубины, разделяясь таки образом на виртуальные оси Y и Z. Использовать в такой ситуации для создания теней стандартные средства Unity не представлялось возможным.
Но на самом деле, честного освещения мне и не требовалось, достаточно было иметь возможность вручную создавать тень для каждого объекта. Поэтому самое простое, что мне пришло в голову — просто разместить позади каждой сущности её копию, повёрнутую в трёхмерном пространстве так, чтобы имитировать нахождение на поверхности локации. Всем спрайтам такой псевдотени задавался чёрный цвет, при этом сохранялась иерархическая структура хозяина тени, что позволяло синхронно с хозяином анимировать её таким же аниматором.
Выглядела такая синхронная анимация примерно так:
Однако тени требовалась прозрачность. Самым простым решением было задать её каждому спрайту тени. Но такая реализация выглядела неудовлетворительно — спрайты накладывались друг на друга, образуя в месте наложения менее прозрачные области.
На скриншоте ниже видно, как выглядит тень из нескольких полупрозрачных сегментов. Также видны использованные параметры искажения тени: поворот по оси X на -50 градусов, поворот по оси Y на -140 градусов, и масштаб по оси X, увеличенный в 1.3 раза относительно родительского объекта.
Стало очевидно, что прозрачность нужно накладывать на тень как на цельный объект. Первым экспериментом на эту тему стало навешивание на тень камеры, отрисовывающей эту тень в RenderTexture, которая потом уже использовалась в качестве материала прикреплённым к родителю тени Plane. Ему уже можно было без проблем задавать прозрачность. Сами же тени находились за пределами кадра, чтобы избежать наложения друг на друга областей захвата камер. Подход работал, но оказалось, что уже пара десятков теней вызывает серьёзные проблемы с производительностью, преимущественно, из-за числа находящихся на сцене камер. Кроме того, ряд анимаций предполагал значительное перемещение отдельных спрайтов моба в рамках его корневого объекта, из-за чего в поле зрения камеры должна была находиться область, значительно превышающая размер реального изображения в отдельный момент времени.
Решение нашлось быстро — если нельзя отрисовывать каждую тень отдельной камерой — почему бы не отрисовывать одной камерой все тени? Всё, что нужно было сделать — отвести под тени отдельную область сцены, несколько выше поля зрения основной камеры, направить на эту область дополнительную камеру, и отображать её вывод между локацией и остальными сущностями.
Ниже можно наблюдать пример вывода этой камеры:
Производительность от такой реализации страдала гораздо меньше, так что решение было сочтено рабочим и применено ко всем мобам, статичным объектам и снарядам. Далее последовала очередь спрайта локации. Использовать один спрайт на все объекты, как это было реализовано ранее, оказалось невозможным. Использование копии объекта в качестве его же тени хорошо работает только до тех пор, пока объект полностью плоский. Уже при создании теней для мобов было заметно, что разнесённые по третьей координате точки соприкосновения с поверхностью нарушают корректность тени относительно этих точек.
Следующий скриншот демонстрирует пример такого нарушения. За точку соприкосновения с поверхностью принята пятка моба, но тени ступней уже выходят за рамки самих ступней.
И если в случае с ногами огра ещё можно немного изменить положение тени и замаскировать проблему, то для нескольких десятков стволов деревьев на это нет и шанса. Все объекты локации, которые должны были отбрасывать тень, следовало сделать отдельными GameObject. Именно так я и поступил, разместив на префабе локации экземпляры соответствующих разрушаемых объектов и отключив им неиспользуемые в таком положении скрипты. Заодно благодаря этому стало возможным включить их в общую сортировку объектов сцены, и улетающие за пределы локации снаряды больше не отрисовывались строго поверх всех объектов, а пролетали между ними. К тому же стало возможным сделать сами объекты анимированными.
Но тут меня поджидала новая неприятность. С тенями и десятками новых объектов максимальное количество одновременно находящихся на сцене GameObject, а вместе с ними и компонентов Animator и SpriteRenderer, возросло более чем в два раза. Когда я выпускал на локацию всю волну мобов, что составляло порядка 150 штук, Profiler укоризненно показывал мне примерно 40мс, уходивших только на рендеринг и анимацию, а частота кадров в целом колебалась в районе 10. Я отчаянно оптимизировал собственные скрипты, борясь за каждую миллисекунду, но этого было недостаточно.
В поисках дополнительных средств оптимизации, я набрёл на просторах документации и гайдов на динамический батчинг.
Немного подробнее про батчинг
Если вкратце, то батчинг — это механизм, позволяющий минимизировать количество вызовов отрисовки, а вместе с ним и время, которое тратится в момент отрисовки кадра на взаимодействие между CPU и GPU. При его использовании вместо отправки на отрисовку каждого элемента в отдельности, схожие элементы группируются и отрисовываются вместе за один раз. В случае с Unity, движок сам старается по максимуму использовать этот механизм и дополнительных действий со стороны разработчика почти не требуется.
Frame Debugger показывал, что батчатся у меня, в лучшем случае, детали каждого объекта или моба по отдельности. Создав для первых и для вторых по атласу спрайтов, я добился батчинга теней до всего нескольких вызовов отрисовки, но владельцы этих теней батчиться между собой упорно отказывались.
Эксперименты на отдельной сцене показали, что динамический батчинг ломается при наличии у объектов компонента SortingGroup, который я использовал для сортировки отображения сущностей на экране. Обойтись без него, в теории, было возможно, однако выставление значений сортировки для каждого спрайта и системы частиц в объекте по отдельности могло выйти ещё дороже, чем отсутствие батчинга.
Но кое-что мне не давало покоя. Объект-тень, являясь в реальной сцене потомком объекта-хозяина, технически входил в тот же самый SortingGroup, однако с динамическим батчингом объектов-теней проблем не возникало. Единственное отличие было в том, что объекты-хозяева отрисовывались основной камерой сразу на экран, а объекты-тени — сначала в RenderTexture.
В этом и оказалась загвоздка. Что именно является причиной такого поведения — интернету неведомо, но при рендеринге камерой изображения в RenderTexture, SortingGroup больше не ломали батчинг. Решение казалось весьма странным, нелогичным, и вообще самым что ни на есть костылём. Но реализовав рендеринг сущностей тем же методом, что и рендеринг теней, и получив таким образом помимо слоя теней, ещё и слой сущностей, я добился уже вполне приемлемых значений производительности.
Скриншот ниже демонстрирует пример отрисовки слоя сущностей.
Итого в общем случае рендеринг некоторой сущности в координате Y выглядит так:
- Сущность помещается в координате Y − 20;
- Сущность отрисовывается наблюдающей эту координату камерой в RenderTexture для сущностей;
- Тень сущности помещается в координате Y + 20;
- Тень сущности отрисовывается наблюдающей эту координату камерой в RenderTexture для теней;
- Основная камера отрисовывает на экран основной спрайт локации — единственный элемент, рендеринг которого на текущий момент выполняется сразу на экран;
- Основная камера отрисовывает на экран Plane с RenderTexture теней в качестве материала;
- Основная камера отрисовывает на экран Plane с RenderTexture сущностей в качестве материала.
Такой вот слоёный пирог.
На скриншоте ниже камера редактора переведена в трёхмерный режим для демонстрации расположения слоёв относительно друг друга.
Нюансы
Но как выяснилось в процессе тиражирования решения на другие сущности, общий случай не покрывал все возможные сценарии. Например, существовали сущности, находящиеся на некоторой высоте относительно поверхности, в частности, снаряды и некоторые персонажи катсцен. Кроме того, снаряды ещё и имели свойство поворачиваться в зависимости от направления своего движения по экрану, из-за чего помимо выставления им точки пересечения объекта и его тени, понадобилось выделять вращаемую часть в отдельный дочерний объект, править логику вращения снаряда и их анимации.
Следующий скриншот показывает пример вращения снарядов и их теней.
Летающие персонажи же, как и запланированные летающие мобы, могут вдобавок перемещаться в рамках своей виртуальной Y координаты, что потребовало создания механизма расчёта положения тени из положения её хозяина на виртуальной оси Y.
На гифке ниже изображён пример перемещения объекта по высоте.
Другим выбивающимся из общей концепции случаем оказался танк. В отличие от всех остальных сущностей, танк имеет весьма существенный размер по виртуальной оси Z, а общая реализация теней, как уже упоминалось, требует, чтоб объект был практически плоским. Самым простым способом это обойти были вручную нарисованные формы теней для отдельных деталей танка, благо поместить на слой теней можно было что угодно.
Для корректного построения рисованных вручную теней мне пришлось на основе скриншота уже существующей тени собрать конструкцию из линий, которую можно наблюдать на скриншоте ниже.
Если отмасштабировать и разместить эту конструкцию таким образом, что верхняя часть будет находиться в некоторой точке родительского объекта, а нижняя — в точке его соприкосновения с поверхностью, правый угол конструкции покажет место, в котором должна находиться соответствующая точка тени. Спроецировав таким образом несколько ключевых точек, не составляет особого труда построить по ним всю тень.
Помимо этого, отдельные детали танка могли иметь разную высоту крепления дочерних деталей, что, как и в случае с летающими персонажами и мобами, требовало подстройки положения тени каждой конкретной детали.
На скриншоте ниже показан танк, его тень в сборе и она же в виде отдельных частей.
Отдельной болью оказались тени стен. На момент начала работы над тенями стены имели ту же природу, что и детали танка — один объект из нескольких десятков отдельных спрайтов. Однако при этом у стен имелось по нескольку состояний, управляемых аниматором.
Крепко задумавшись, что с ними делать, я пришёл к выводу, что концепцию стен необходимо менять. В результате стены были разделены на секции, каждая из которых имеет собственный набор состояний, собственный аниматор и собственную тень. Это позволило использовать для параллельных оси X секций такой же подход к созданию теней, как и с мобами, а для тех секций, что не подходили под это правило уже придумывать что-то своё. В отдельных случаях пришлось создавать для тени секции собственный аниматор и вручную задавать положение спрайтов.
Например, в случае секции, отображённой на скриншоте ниже, тень сделана путём применения искажения для каждого отдельного бревна вместо всей секции целиком.
Заключение
Вот, собственно, и всё. Несмотря на все вышеперечисленные нюансы, изначально поставленная задача оказалась выполнена в полном объёме, и теперь мой проект может похвастаться вполне себе приличными на вид тенями, пусть и несколько сомнительного происхождения. Надеюсь, благодаря этой статье, для следующего инди-разработчика, задавшегося аналогичным моему вопросом, интернет станет чуточку полезнее, уж если не в качестве примера для подражания, то хотя бы в качестве чужой ошибки для собственного обучения.