2D магия в деталях. Часть первая
Игры большие и трехмерные уже давно радуют глаз реалистичным освещением, мягкими тенями, бликами и прочей осветительной красотой. В двумерных же играх — во главе стола прямые руки художника, который подсветит и затенит где нужно, спрайт за спрайтом, или даже пиксель за пикселем. А если хочется динамики и без художника, и да, в пиксельарте?
Небольшая ремарка
Представьте себе:
Боевой маг выходит на поле перед древним замком. Трава сминается под его сапогами, вечернее солнце слепит его глаза. Маг взмахивает посохом — вспышка! Огненный шар врезается в землю перед замком, ударная волна поднимает пыль и сгибает травинки. Комья обожжённой зелени вперемешку с землёй разлетаются в стороны. На каменной стене — гарь и копоть. Выстояла.
А теперь сделаем из этой воображаемой и эффектной картинки небольшую, но крайне технологичную, игрушку и поделимся наработками в Unity3D Asset store.
Планируется серия статей, в которых будут описаны: свет, генерация мешей, собственные системы частиц, работа с самописными редакторами, ragdoll на интегрировании Верле. В статьях будут описания алгоритмов, как придуманных из головы, так и найденных на просторах всемирной сети. В статьях не будет описаний вида «Добавим спрайт и камеру на сцену, назовём это статьёй, достойной Хабра, и выложим на суд добрых людей». На момент написания этой (первой) статьи, проект ещё не доведён до ума, так что в заключительной части планируется разбор ошибок и прочие дополнения.
Что такое свет в 2D?
Будем честны — «реалистичный свет» означает «хорошо выглядящий», а вовсе не «достоверно моделирующий оптические законы». Да и 2D — тоже не совсем верно, ведь если источник света будет в той же плоскости, что и спрайты — увидим мы ровным счетом ничего. Итак, давайте определимся, что считать освещением.
Костры, фонари, файрболы и прочая магия — вот наши основные источники света. Они находятся примерно в той-же плоскости, где проходит основной игровой процесс. А ещё — небо, которое освещает всю сцену целиком, и солнце/луна, которые не видны только во внутренних помещениях.
Судя по всему, все источники света можем разбить так:
Point. Точечный источник света, для которого мы можем указать положение, яркость, цвет и радиус действия.
Ambient. Не ограниченный по расстоянию источкик света, например солнце. Его свет не проникает в помещения. Определяется положением (чтобы правильно отбрасывать тени), яркостью и цветом.
Diffuse ambient «Настоящий» рассеянный свет, проникающий куда угодно. Было бы здорово, если цвет неба коррелировал бы с источниками света такого типа. Определяется цветом и яркостью.
Иконки типов источников, которые вы видите выше, тоже участвуют в проекте. У Unity3D есть специальный механизм отрисовки «дебажной» информации в редакторе — Gizmo. И с помощью них можно рисовать свои иконки, что очень удобно для работы с объектами:
Интересующимся — гуглить в сторону Gizmos.DrawIcon и MonoBehaviour.OnDrawGizmos.
А теперь разобьем игровую сцену на слои, начиная с ближайшего, и посмотрим, что и как освещать:
Стены и прочие препятствия. Твердые объекты, они отбрасывают тени и освещаются только рассеянным светом (т.к. все остальные источники «позади них»).
Игровые персонажи, трава, частицы и т.д. Тут проходит основной игровой процесс. Все объекты должны быть подсвечены источниками света с учетом теней от стен.
А ещё фоновые стены. Эти объекты находятся позади игрового процесса, но достаточно близко, чтобы тоже быть освещенными точечными источниками света. Тени от препятствий также учитываются.
Горы, замки и прочий задний план. Находятся далеко от точечных источников, освещаются только рассеянным светом.
Небо. Само по себе является источником рассеянного освещения. Точечные источники на него не влияют (это не совсем так, но я забегаю вперед).
Построение теней
Итак, с источниками света разобрались, с игровыми объектами тоже. Самое время — заняться тенями.
Источники света у нас точечные (рассеянное освещение теней не отбрасывает), значит если из пикселя «не видно» источник света (луч от пикселя до источника пересекает препятствия) — там тень. Круто! Осталось только пробегать по всем пикселям и для каждого искать пересечения… Нет, нет, нет, по этому пути мы не пойдём, не переживайте! В 3d играх существует метод с названием Shadow volumes. Идея довольно проста: берем меш, который отбрасывает тень, «вытягиваем» его от источника света, а затем, при рендере, смотрим, где находится пиксель — внутри меша или снаружи. Попробуем так-же! Возьмем меши для наших препятствий, вытянем… Да-да, мешей то нет. Впрочем, не беда — есть текстура со спрайтами, ей и воспользуемся.
Идея в следующем: вытащить из текстур информацию о спрайтах, найти в каждом спрайте грани, и построить по этим граням меш. Делается всё это в ScriptableObject’e, через кнопочку в редакторе. На выходе — ассоциативный массив, где ключ — это спрайт, а значение — информация о гранях.
По неясной мне причине, в Unity3D есть ScriptableObject’ы, а способа создания без написания кода почему-то нет. Так что, если хочется сделать свои объекты, пригодится вот такая штука.
Есть достаточно много функций с asset’ами в редакторе, находятся они в классе AssetDatabase, и используются, например, чтобы получить спрайты из текстуры:
Sprite[] GetSprites(Texture2D texture) {
var path = AssetDatabase.GetAssetPath(texture);
return AssetDatabase.LoadAllAssetsAtPath(path).OfType().ToArray();
}
Затем получаем цвета пикселей через texture.GetPixels (), руками бегаем и сравниваем соседние пиксели, не изменилось ли значение альфа канала.
На выходе получаем два массива (вертикальный и горизонтальный) вот таких вот структур (значения целые, т.к измеряем в пикселях):
public struct BasisLine
{
public int normal;
public int position;
public int start;
public int end;
}
И наконец, чтобы полноценно работать с ScriptableObject’ами (да и чем угодно другим!), очень полезны самописные редакторы. Благо в Unity3D это делается достаточно просто:
[CustomEditor(typeof(Edges.SpriteGenerator))]
public class SpriteGeneratorEditor : Editor {
public override void OnInspectorGUI() {
this.DrawDefaultInspector();
if (targets.Length != 1)
return;
var generator = (Edges.SpriteGenerator)target;
if (GUILayout.Button("Generate")) {
generator.UpdateMeshes(true);
serializedObject.ApplyModifiedProperties();
}
}
}
Не повторяйте моих ошибок — внимательно следите, чтобы приватные сериализуемые поля были помечены [SerializeField], а классы — [System.Serializable], иначе потом будете искать, куда делись данные из объектов в билде (в редакторе-то всё будет хорошо, до первого перезапуска Unity3D).
Еще один момент: при размещении препятствий на сцене есть смысл удалить лишние грани (это те, которые находятся в других препятствиях). Во-первых — это оптимизирует меш с тенями. Во-вторых — упрощает жизнь в следующих статьях (например, информация о поверхности препятствий используется для посадки травы). Если коротко: при генерации информации о спрайтах помимо граней находим прямоугольники, полностью заполняющие спрайт. Я делаю несколько проходов — сверху-вниз, слева-направо и т.д., а затем выбираю тот, в котором получилось меньше прямоугольников. При размещении на сцене пробегаемся по спрайтам-препятствиям, находим пересечения AABB спрайтов, а затем — граней одного спрайта с прямоугольниками другого. Конечно, там возникают всякие хитрые моменты вроде спрайтов, касающихся друг друга боками (и грань нужно частично удалить), или спрайтов, наложенных так, что грань одного продолжает грань другого (и эти грани нужно объединить в одну). Но результат того стоит.
Наконец-то, у нас есть всё, чтобы, наконец, построить теневые меши. Идея совсем простая. Для каждой грани SF с нормалью N строим прямоугольник ABCD, где координаты и нормали такие:
A.vertex = D.vertex = S;
B.vertex = C.vertex = F;
A.normal = B.normal = 0;
C.normal = D.normal = 0;
То есть толщина прямоугольника равна нулю, но у двух граней есть нормаль, а у двух — нормаль нулевая. Теперь мы можем в шейдере вытянуть те вершины, у которых ненулевая нормаль направлена в сторону от источника света. Это и будет наша тень:
Shader "NEngine/Light/Shadow" {
Properties {
_LightPosition ("Light position", Vector) = (0, 0, 0, 0)
_ShadowLength ("Shadow length", Range(0, 30)) = 0.1
_ShadowColor ("Shadow color", Color) = (0, 0, 0, 1)
}
SubShader {
Pass {
Tags {
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
}
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct appdata {
fixed4 vertex : POSITION;
fixed4 normal : NORMAL;
};
struct v2f {
fixed4 vertex : SV_POSITION;
fixed4 color : COLOR;
};
fixed2 _LightPosition;
fixed _ShadowLength;
fixed4 _ShadowColor;
v2f vert(appdata v) {
v2f o;
fixed2 normal = v.normal.xy;
fixed2 position = v.vertex.xy;
fixed2 delta = normalize(_LightPosition - position);
if (dot(delta, normal) > 0)
{
o.vertex = 0;
o.color = 0;
return o;
}
if (v.normal.z == 0)
{
o.color = _ShadowColor;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}
o.color = _ShadowColor;
fixed2 direction = -delta * _ShadowLength;
fixed4 vertex = v.vertex + fixed4(direction.xy, 0, 0);
o.vertex = mul(UNITY_MATRIX_MVP, vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target {
return i.color;
}
ENDCG
}
}
}
И на выходе мы получаем вот такую картинку:
Затенение, пиксели и свет
Изначально я сделал тени именно таким образом, да и источников света настоящих у меня не было. Просто некие абстрактные серенькие тени. На объекты они накладывались с помощью stensil-буфера — все спрайты, на которых можно отображать тень, записывали в буфер значение, и тени проверяли буфер перед отрисовкой пикселя. Но мало того, что выглядит нереалистично, так еще и из стиля выбивается — пиксели-то крупные. Думаем дальше.
А подумав, делаем Light2DManager, в котором источники света регистрируются при появлении, а потом отрисовываются в текстуру с небольшим разрешением. Каждый источник отрисовывается так:
- Сначала в материал с тенями записывем позицию текущего источника;
- Берем специальный спрайт (для Point — это спрайт с радиальным градиентом, для Ambient — просто прямоугольный спрайт размером с экран камеры) и меняем его позицию на позицию источника;
- Отрисовываем тени и спрайт света в текстуру (свет отсекается шейдером с помощью stensil-буфера).
А вот с выводом на экран этого освещения есть интересный момент. Дело в том, что цвет пикселя на экране расчитывается обычно так:
OBJECT_COLOR * LIGHT_COLOR
Так как цвет пикселя и цвет источника света в пределах от 0 до 1, то и результат будет от нуля до единицы. Причем больше «от нуля», чем «до единицы» — спрайты не белые и вносят свою лепту. А иногда хочется сделать такой яркий источник света, чтобы даже тёмные камни замковых коридоров засияли, как утреннее небо. Добавим дополнительный коэффициент HDRRatio, равный, к примеру 10. И в шейдере источника света будем получать результат вот так:
fixed4(light.a * _Amount / _HDRRatio, 0, 0, light.a)
А при смешении света и сцены — умножать на этот коэффициент. Таким образом, мы теряем градации освещения (сколько теряем — определяем HDRRatio), но можем пересвечивать сцену.
Смешивать свет со сценой будем через постэффект — небольшой шейдер, который будет накладывать свет в зависимости от значения в stensil-буфере (помните, что не все элементы должны быть освещены?). А все Diffuse ambient источники будем суммировать, с некоторым коэффициентом устанавливать как цвет фона на основной камере и как фоновое освещение для всех объектов сцены.
Мягкие тени
В пиксельарте есть важное правило, его можно найти чуть-ли не в любом туториале по этому виду изобразительного искусства. При рисовании прямой наклонной линии нужно следить, чтобы пиксели не смотрелись «ломано», переходы должны быть мягкими и не заметными.
Верхние линии — ломанные, грубые. Нижние — более сглаженные.
А вот линии теней сейчас не могут соблюдать это правило, все-таки шейдеру не хватает таланта художников (так что, господа дизайнеры, не переживайте, ваш труд никогда не заменит бездушный конвейер видеокарты). Да и вообще, с каких пор тени от солнышка такие резкие? Вот только без переделок сгладить тени не получится — свет отсекается stensil-буфером, а там — либо есть тень, либо нет, середины не существует.
Гугл на наши запросы выдает страшные слова — umbra и penumbra, выдает картинки чьих-то проектов, от которых слюнки текут. Общая их идея — делать более сложные меши, в которых есть и тень, и полутень. Но мы пойдем другим путём.
Заметим, что чем ближе тень к источнику тени, тем четче она. Значит, нам нужно как-то размывать тени с учетом расстояния до от объекта.
Нарисуем спрайт света только в один канал (например, красный). Нарисуем тень в другом канале (синем). А еще, нарисуем наиболее удаленные точки тени (помните, как она строится? Наиболее удаленные — это те, у которых нормаль не нулевая) в оставшемся зеленом канале. Получим вот такую картинку, на которой есть все необходимое: свет, тень и расстояние от источника тени:
Размоем это изображение, но если обычно при размытии (если брать соседние пиксели), мы делаем что-то такое:
(current + top + bottom + right + left) / 5.0
то сейчас будем учитывать значение из зеленого канала как вес:
(current + top * top.g + bottom * bottom.g + right * right.g + left * left.g) / (top.g + bottom.g + right.g + left.g + 1)
Теперь перемешиваем R-канал со светом и B-канал с тенью без градиента (по сути, просто умножаем два канала и цвет источника света). Получаем аккуратные размытые тени:
Красивости
На предыдущем скриншоте трава столь яркая, потому что освещена солнцем, но кажется, будто она светится сама. Добавим свет на грани спрайтов-препятствий.
В шейдере теней, который спрятан где-то в этой статье, отсекаются тени, которые светят «внутрь» объекта. Теперь они нам понадобятся, чтобы сделать самозатенение для граней. Сами грани будут принимать свет благодаря нашему постэффекту для смешивания освещения и сцены (опять-же, используя stensil-буфер). Грязный хак — чтобы объект не затенял ближайшие к источнику грани, будем двигать точки тени, в которых нормаль равна нулю в сторону от источника света (на один пиксель).
… То двигаем мы ещё и в сторону нормали, лишь бы один пиксель был точно без тени. Выглядит это как-то так:
fixed2 direction = -delta * _PixelSize;
fixed2 normalDirection = -v.normal.xy * _PixelSize;
if (abs(direction.x) < abs(normalDirection.x))
direction.x = normalDirection.x;
if (abs(direction.y) < abs(normalDirection.y))
direction.y = normalDirection.y;
o.color = fixed4(0, 0, 1, 1);
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex + fixed4(direction, 0, 0));
return o;
Так-то лучше:
Ещё бывает, что в воздухе много пара, пыли или дыма, и частички отражают свет, образуя в воздухе красивые лучи, которые называют «cумеречные лучи» или «god rays». У нас уже есть всё необходимое, чтобы их сделать — нужно только разрешить постэффекту рисовать свет там, где в stensil-буфер ничего не записано. Есть два момента: во-первых, добавим некий коэффициент, чтобы настраивать силу такого освещения, во-вторых, этот свет нужно складывать с цветом неба, а не умножать: лучи ведь не зависят от цвета неба, только от запылённости.
Заключение
Осталось посмотреть, как выглядит всё это в динамике:
С включенными Gizmo света, ботов, ветра и препятствий:
Итак, у нас есть вполне рабочее освещение для pixelart проекта. Оно поддерживает динамические объекты, мягкие тени и прочие эффекты. Вполне можно двигаться дальше! На данный момент в проекте есть несколько направлений, которые частично завершены, и про которые будет, надеюсь, интересно прочесть. Поэтому, о чём будет следующая статья — предоставляю решать вам.
Конечно, осталось достаточно нераскрытых вопросов, например, оптимизация и работа на мобильных девайсах. Но об этом — в следующие разы.