Как сделать тень в юнити
Реализация быстрых 2D-теней в Unity с помощью 1D shadow mapping
Введение
Недавно я приступил к реализации системы 2D-теней в Unity, которую можно было бы использовать в настоящей игре. Как известно профессиональным разработчикам, есть большая разница между тем, чего можно достичь в техническом демо и тем, что применимо для интеграции в полную игру, где реализованная возможность является лишь одной из множества. Влияние на ЦП, видеопроцессор и память должны находиться в балансе со всем остальным в игре. На практике у разных проектов возникают различные ограничения, но я решил создать систему, занимающую не больше пары миллисекунд времени обработки и не больше нескольких мегабайт в памяти.
Таким ограничением я отбрасывал множество уже существовавших способов расчёта теней, которые мне удалось найти. Популярной была пара техник. В одной применялась реализуемая на ЦП трассировка лучей, определяющая границы силуэтов блокирующей свет геометрии. В другой все препятствия для света рендерились в текстуру, а затем для неё выполнялся алгоритм типа ray-stepping с несколькими проходами для создания карты теней. Эти техники обычно используются не более чем с парой источников света и точно не позволили бы мне работать с десятками источников света в соответствии с выбранными мной ограничениями.
Наложение теней
Поэтому я решил создать 2D-аналог способа расчёта теней, который используется в большинстве современных 3D-игр: рендеринг геометрии с точки зрения источника освещения и создание буфера глубин, который позволит определять при рендеринге каждого из пикселя, видим ли он из источника освещения. Эта техника имеет название shadow mapping (наложение теней). В 3D она создаёт двухмерную текстуру, то есть в 2D она будет создавать одномерную текстуру. На скриншоте ниже показана моя готовая карта освещения в режиме просмотра ресурсов Unity; но на самом деле она не для одного источника освещения, а для 64: каждая строка пикселей в текстуре — это карта теней для отдельного источника.
В этом способе используются полярные координаты для преобразования 2D-положения в угол и расстояние (или глубину) относительно другого объекта.
То есть каждая строка в карте теней представляет собой 360 градусов вокруг источника освещения, а каждый пиксель представляет расстояние от источника освещения до ближайшей непрозрачной геометрии в этом направлении. Чем больше горизонтальное разрешение текстуры, тем больше точность получающихся теней.
Непрозрачная геометрия, отбрасывающая тени, которую далее я буду называть блокирующей геометрией, передаётся как список линий. Каждая пара вершин создаёт пару позиций в полярном пространстве, после чего пиксельный шейдер заполняет соответствующий отрезок в карте освещения. В каждом пикселе благодаря использованию стандартного теста z-буфера сохраняется только ближайший к геометрии пиксель. Неверно будет просто интерполировать в пиксельном шейдере полярную глубину для получения z-координаты, потому что так мы получим для прямых рёбер изогнутые тени. Вместо этого нам нужно вычислять точку пересечения между отрезком линии и лучом света под текущим углом, но это вопрос всего пары скалярных произведений и деления, что не очень затратно для современных видеопроцессоров.
Сложности
Всё это было бы очень просто, если бы не одна ложка дёгтя — серьёзная проблема при использовании полярных координат возникает тогда, когда мы получаем отрезок прямой в полярных координатах, который находится с двух сторон границы в 360 градусов. Обычным решением стало бы разбиение отрезка прямой на две отдельные части: первая часть бы заканчивалась на 360 градусах, а другая (остаток отрезка) — начиналась с 0. Однако вершинный шейдер получает только одну вершину и выдаёт один результат, и нет никакой возможности вывести два отдельных отрезка. Основная сложность такого подхода заключается в решении этой проблемы.
Можно решить её так: представлять исходные строки карты теней не в 360 градусах, а добавить дополнительные 180 градусов, то есть от 0 до 540. Отрезок прямой в полярном пространстве занимает не больше 180 градусов, поэтому этого достаточно, чтобы вместить любой отрезок, находящийся рядом с точкой 360. Это означает, что каждый отрезок прямой по-прежнему создаёт на выходе для пиксельного шейдера один отрезок прямой, как и нужно.
Недостаток этого способа в том. что первая часть строки (от 0 до 180) и последняя часть (от 360 до 540) фактически являются одной областью в полярном пространстве. Для проверки пикселя относительно карты теней нам нужно определить, попадает ли полярный угол в эту область, и если это так, то взять сэмплы из двух мест и выбрать минимум из двух глубин. Это не совсем то, к чему я стремился — ветвление и дополнительное сэмплирование ужасным образом скажутся на производительности, особенно при множественном сэмплировании текстуры для Percentage Closest Filtering (PCF) (эта техника широко используется для создания плавных теней на основе карт теней). Моё решение (после заполнения всех строк карт теней) заключается в следующем: выполнение ещё одного прохода видеопроцессора по карте теней, её ресэмплирование и комбинированиме первых 180 градусов с последними 180 градусами. По обычным стандартам буфера кадра текстура карт теней очень мала, поэтому она занимает незначительное время видеопроцессора. В результате мы получаем готовую текстуру карты теней, в которой достаточно одного сэмпла, чтобы определить, освещён ли текущий пиксель конкретным источником освещения.
Основной недостаток этой системы заключается в том, что для распознавания и обработки пограничных случаев блокирующая геометрия должна иметь специальный формат. Каждая вершина каждого отрезка прямой сама похожа на отрезок прямой, потому что хранит положение другого конца отрезка. Это значит, что нам придётся или строить геометрию в этом формате во время выполнения приложения, или предварительно. Мы не можем просто передать геометрию, которая используется для рендеринга. Однако у этого есть и хорошая сторона: после построения этой специальной геометрии мы по крайней мере не передаём никаких ненужных данных, то есть эффективность оказывается выше.
Ещё одно отличное свойство этой системы заключается в том, что готовая карта теней может быть записана обратно в видеопроцессор, и это позволяет выполнять через ЦП запросы видимости без необходимости трассировки лучей. Копирование текстуры обратно в видеопроцессор может быть достаточно затратной задачей, и несмотря на то, что в Unity 2018 нас ждёт долгожданная реализация асинхронных повторных считываний (asynchronous gpu read-backs) видеопроцессора, эту функцию не стоит применять без реальной необходимости.
Алгоритм
По сути, алгоритм работает следующим образом.
Какими же оказались затраты? Если считать, что блокирующая геометрия статична, и что мы используем дублирование геометрии (instancing), то полная карта теней для любого количества источников (подверженного ограничениям размеров текстуры) может быть передана в видеопроцессор за единственный вызов отрисовки. Объём перерисовки в карте теней определяется сложностью блокирующей освещение геометрии, но так как текстура по сравнению с современными буферами кадров очень мала, производительность кэша видеопроцессора должна оказаться фантастической. Аналогично, когда мы доходим до сэмплирования карты теней, оно не отличается от shadow mapping в 3D, за исключением того, что карта теней будет гораздо меньше.
Наша игра состоит из единой крупной среды и у нас есть 64 постоянно активных и отбрасывающих тень источников освещения, поэтому я использовал текстуру карты теней размером 1024×64. Затраты в пределах общего бюджета вычислений кадра оказались минимальными.
Дополнительные возможности
Если вы захотите расширить эту систему, то я могу предложить пару интересных возможностей. При обработке карты теней для устранения двух накладывающихся друг на друга областей можно воспользоваться возможностью и преобразовать значения для создания экспоненциальной карты теней, а затем размыть её (не забывайте, что нужно размывать только в горизонтальном направлении, иначе это подействует на несвязанные друг с другом источники!). Это позволит нам создать плавные тени без мультисэмплирования карты теней. Второе: как я упомянул ранее, сейчас демо выполняет отдельный вызов отрисовки для передачи геометрии затенения каждого источника, но если упаковать положение источника освещения и другие параметры в матрицу, то это можно тривиальным образом сделать в один вызов отрисовки при помощи дублирования (instancing).
Более того, я считаю, что почти без дополнительной работы со стороны ЦП можно реализовать в качестве расширения системы излучательность (radiosity lighting) с одиночным отражением. Для этого можно использовать следующий принцип: видеопроцессор может использовать карту теней из прошлого кадра для расчёта отражений лучей света в сцене. Пока я не могу сказать ничего более подробного, потому что пока не реализовал эту систему. Если она заработает, то будет гораздо более эффективной, чем обычные реализации Virtual Point Light, в которых используется выполняемая ЦП трассировка лучей.
К тому же можно использовать эту систему множеством интересных способов. Например, если заменить источники освещения излучателями звука, то эту систему можно применять для вычисления поглощения звука. Или её можно использовать для определения поля видимости в процедурах ИИ. В общем случае, возможно превратить трассировку лучей в поиск по текстуре.
Завершение
На этом я завершаю рассказ о подробностях реализации моей системы одномерного наложения теней. Если у вас появились вопросы, то задавайте их в комментариях к оригиналу статьи.
Как я делал 2D тени в Unity
Что первое приходит в голову разработчику инди-игры, когда он сталкивается с необходимостью добавления фичи, представления о реализации которой толком не имеет? Разумеется, он идёт искать следы тех, кто уже прошёл этот путь и удосужился записать свой опыт. Так поступил и я некоторое время назад, приступая к созданию теней в своей игре. Найти нужную информацию — в виде статей, уроков и гайдов — не составило особого труда. Однако, к моему удивлению, я обнаружил, что ни одно описанное решение мне попросту не подходит. Поэтому, реализовав своё собственное, я решил поведать о нём миру.
Стоит предупредить заранее, что данный текст не претендует на бытие неким ультимативным гайдом или мастер-классом. Использованный мною метод может быть не универсальным, далеко не самым эффективным и не закрывающим задачу создания двухмерных теней в полной мере. Это скорее история о том, к каким ухищрениям пришлось прибегнуть неопытному разработчику в моём лице для достижения удовлетворяющего его требованиям результата.
Сам результат перед вами:
А подробности пути к его достижению ждут вас под катом.
Постановка задачи
Итак, на момент принятия решения о добавлении в игру теней, у меня имелось:
Для всего этого необходимы были самые простейшие тени, повторяющие контуры объекта, и отбрасываемые от единственного фиксированного источника освещения.
При этом следовало трепетно отнестись к производительности. Ввиду специфики жанра и особенностей его реализации, большинство объектов, отбрасывающих тени, в каждый момент времени находятся непосредственно на экране. А их общее число может составлять больше сотни, если говорить об игровых сущностях, и пару тысяч, если говорить об отдельных спрайтах.
Реализация
Собственно, основная загвоздка оказалась в том, что Dwarfinator, грубо говоря — 2,5D игра. Подавляющее большинство объектов существует в двухмерном пространстве с осями X и Y, а ось Z используется крайне редко. Визуально же, и отчасти, геймплейно, ось Y используется для отображения как высоты, так и глубины, разделяясь таки образом на виртуальные оси Y и Z. Использовать в такой ситуации для создания теней стандартные средства Unity не представлялось возможным.
Но на самом деле, честного освещения мне и не требовалось, достаточно было иметь возможность вручную создавать тень для каждого объекта. Поэтому самое простое, что мне пришло в голову — просто разместить позади каждой сущности её копию, повёрнутую в трёхмерном пространстве так, чтобы имитировать нахождение на поверхности локации. Всем спрайтам такой псевдотени задавался чёрный цвет, при этом сохранялась иерархическая структура хозяина тени, что позволяло синхронно с хозяином анимировать её таким же аниматором.
Выглядела такая синхронная анимация примерно так:
Однако тени требовалась прозрачность. Самым простым решением было задать её каждому спрайту тени. Но такая реализация выглядела неудовлетворительно — спрайты накладывались друг на друга, образуя в месте наложения менее прозрачные области.
Стало очевидно, что прозрачность нужно накладывать на тень как на цельный объект. Первым экспериментом на эту тему стало навешивание на тень камеры, отрисовывающей эту тень в RenderTexture, которая потом уже использовалась в качестве материала прикреплённым к родителю тени Plane. Ему уже можно было без проблем задавать прозрачность. Сами же тени находились за пределами кадра, чтобы избежать наложения друг на друга областей захвата камер. Подход работал, но оказалось, что уже пара десятков теней вызывает серьёзные проблемы с производительностью, преимущественно, из-за числа находящихся на сцене камер. Кроме того, ряд анимаций предполагал значительное перемещение отдельных спрайтов моба в рамках его корневого объекта, из-за чего в поле зрения камеры должна была находиться область, значительно превышающая размер реального изображения в отдельный момент времени.
Решение нашлось быстро — если нельзя отрисовывать каждую тень отдельной камерой — почему бы не отрисовывать одной камерой все тени? Всё, что нужно было сделать — отвести под тени отдельную область сцены, несколько выше поля зрения основной камеры, направить на эту область дополнительную камеру, и отображать её вывод между локацией и остальными сущностями.
Ниже можно наблюдать пример вывода этой камеры:
Производительность от такой реализации страдала гораздо меньше, так что решение было сочтено рабочим и применено ко всем мобам, статичным объектам и снарядам. Далее последовала очередь спрайта локации. Использовать один спрайт на все объекты, как это было реализовано ранее, оказалось невозможным. Использование копии объекта в качестве его же тени хорошо работает только до тех пор, пока объект полностью плоский. Уже при создании теней для мобов было заметно, что разнесённые по третьей координате точки соприкосновения с поверхностью нарушают корректность тени относительно этих точек.
Следующий скриншот демонстрирует пример такого нарушения. За точку соприкосновения с поверхностью принята пятка моба, но тени ступней уже выходят за рамки самих ступней.
И если в случае с ногами огра ещё можно немного изменить положение тени и замаскировать проблему, то для нескольких десятков стволов деревьев на это нет и шанса. Все объекты локации, которые должны были отбрасывать тень, следовало сделать отдельными GameObject. Именно так я и поступил, разместив на префабе локации экземпляры соответствующих разрушаемых объектов и отключив им неиспользуемые в таком положении скрипты. Заодно благодаря этому стало возможным включить их в общую сортировку объектов сцены, и улетающие за пределы локации снаряды больше не отрисовывались строго поверх всех объектов, а пролетали между ними. К тому же стало возможным сделать сами объекты анимированными.
Но тут меня поджидала новая неприятность. С тенями и десятками новых объектов максимальное количество одновременно находящихся на сцене GameObject, а вместе с ними и компонентов Animator и SpriteRenderer, возросло более чем в два раза. Когда я выпускал на локацию всю волну мобов, что составляло порядка 150 штук, Profiler укоризненно показывал мне примерно 40мс, уходивших только на рендеринг и анимацию, а частота кадров в целом колебалась в районе 10. Я отчаянно оптимизировал собственные скрипты, борясь за каждую миллисекунду, но этого было недостаточно.
В поисках дополнительных средств оптимизации, я набрёл на просторах документации и гайдов на динамический батчинг.
Frame Debugger показывал, что батчатся у меня, в лучшем случае, детали каждого объекта или моба по отдельности. Создав для первых и для вторых по атласу спрайтов, я добился батчинга теней до всего нескольких вызовов отрисовки, но владельцы этих теней батчиться между собой упорно отказывались.
Эксперименты на отдельной сцене показали, что динамический батчинг ломается при наличии у объектов компонента SortingGroup, который я использовал для сортировки отображения сущностей на экране. Обойтись без него, в теории, было возможно, однако выставление значений сортировки для каждого спрайта и системы частиц в объекте по отдельности могло выйти ещё дороже, чем отсутствие батчинга.
Но кое-что мне не давало покоя. Объект-тень, являясь в реальной сцене потомком объекта-хозяина, технически входил в тот же самый SortingGroup, однако с динамическим батчингом объектов-теней проблем не возникало. Единственное отличие было в том, что объекты-хозяева отрисовывались основной камерой сразу на экран, а объекты-тени — сначала в RenderTexture.
В этом и оказалась загвоздка. Что именно является причиной такого поведения — интернету неведомо, но при рендеринге камерой изображения в RenderTexture, SortingGroup больше не ломали батчинг. Решение казалось весьма странным, нелогичным, и вообще самым что ни на есть костылём. Но реализовав рендеринг сущностей тем же методом, что и рендеринг теней, и получив таким образом помимо слоя теней, ещё и слой сущностей, я добился уже вполне приемлемых значений производительности.
Скриншот ниже демонстрирует пример отрисовки слоя сущностей.
Итого в общем случае рендеринг некоторой сущности в координате Y выглядит так:
На скриншоте ниже камера редактора переведена в трёхмерный режим для демонстрации расположения слоёв относительно друг друга.
Нюансы
Но как выяснилось в процессе тиражирования решения на другие сущности, общий случай не покрывал все возможные сценарии. Например, существовали сущности, находящиеся на некоторой высоте относительно поверхности, в частности, снаряды и некоторые персонажи катсцен. Кроме того, снаряды ещё и имели свойство поворачиваться в зависимости от направления своего движения по экрану, из-за чего помимо выставления им точки пересечения объекта и его тени, понадобилось выделять вращаемую часть в отдельный дочерний объект, править логику вращения снаряда и их анимации.
Следующий скриншот показывает пример вращения снарядов и их теней.
Летающие персонажи же, как и запланированные летающие мобы, могут вдобавок перемещаться в рамках своей виртуальной Y координаты, что потребовало создания механизма расчёта положения тени из положения её хозяина на виртуальной оси Y.
На гифке ниже изображён пример перемещения объекта по высоте.
Другим выбивающимся из общей концепции случаем оказался танк. В отличие от всех остальных сущностей, танк имеет весьма существенный размер по виртуальной оси Z, а общая реализация теней, как уже упоминалось, требует, чтоб объект был практически плоским. Самым простым способом это обойти были вручную нарисованные формы теней для отдельных деталей танка, благо поместить на слой теней можно было что угодно.
Для корректного построения рисованных вручную теней мне пришлось на основе скриншота уже существующей тени собрать конструкцию из линий, которую можно наблюдать на скриншоте ниже.
Если отмасштабировать и разместить эту конструкцию таким образом, что верхняя часть будет находиться в некоторой точке родительского объекта, а нижняя — в точке его соприкосновения с поверхностью, правый угол конструкции покажет место, в котором должна находиться соответствующая точка тени. Спроецировав таким образом несколько ключевых точек, не составляет особого труда построить по ним всю тень.
Помимо этого, отдельные детали танка могли иметь разную высоту крепления дочерних деталей, что, как и в случае с летающими персонажами и мобами, требовало подстройки положения тени каждой конкретной детали.
На скриншоте ниже показан танк, его тень в сборе и она же в виде отдельных частей.
Отдельной болью оказались тени стен. На момент начала работы над тенями стены имели ту же природу, что и детали танка — один объект из нескольких десятков отдельных спрайтов. Однако при этом у стен имелось по нескольку состояний, управляемых аниматором.
Крепко задумавшись, что с ними делать, я пришёл к выводу, что концепцию стен необходимо менять. В результате стены были разделены на секции, каждая из которых имеет собственный набор состояний, собственный аниматор и собственную тень. Это позволило использовать для параллельных оси X секций такой же подход к созданию теней, как и с мобами, а для тех секций, что не подходили под это правило уже придумывать что-то своё. В отдельных случаях пришлось создавать для тени секции собственный аниматор и вручную задавать положение спрайтов.
Например, в случае секции, отображённой на скриншоте ниже, тень сделана путём применения искажения для каждого отдельного бревна вместо всей секции целиком.