2D магия в деталях. Часть первая. Свет


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


    Небольшая ремарка


    Представьте себе:


    Боевой маг выходит на поле перед древним замком. Трава сминается под его сапогами, вечернее солнце слепит его глаза. Маг взмахивает посохом — вспышка! Огненный шар врезается в землю перед замком, ударная волна поднимает пыль и сгибает травинки. Комья обожжённой зелени вперемешку с землёй разлетаются в стороны. На каменной стене — гарь и копоть. Выстояла.

    А теперь сделаем из этой воображаемой и эффектной картинки небольшую, но крайне технологичную, игрушку и поделимся наработками в Unity3D Asset store.


    Планируется серия статей, в которых будут описаны: свет, генерация мешей, собственные системы частиц, работа с самописными редакторами, ragdoll на интегрировании Верле. В статьях будут описания алгоритмов, как придуманных из головы, так и найденных на просторах всемирной сети. В статьях не будет описаний вида "Добавим спрайт и камеру на сцену, назовём это статьёй, достойной Хабра, и выложим на суд добрых людей". На момент написания этой (первой) статьи, проект ещё не доведён до ума, так что в заключительной части планируется разбор ошибок и прочие дополнения.


    Cтатьи цикла


    Часть первая. Свет.
    Часть вторая. Структура.
    Часть третья. Глобальное освещение.


    Что такое свет в 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<Sprite>().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
            }
        }
    }

    И на выходе мы получаем вот такую картинку:




    Затенение, пиксели и свет


    Изначально я сделал тени именно таким образом, да и источников света настоящих у меня не было. Просто некие абстрактные серенькие тени. На объекты они накладывались с помощью stencil-буфера — все спрайты, на которых можно отображать тень, записывали в буфер значение, и тени проверяли буфер перед отрисовкой пикселя. Но мало того, что выглядит нереалистично, так еще и из стиля выбивается — пиксели-то крупные. Думаем дальше.
    А подумав, делаем Light2DManager, в котором источники света регистрируются при появлении, а потом отрисовываются в текстуру с небольшим разрешением. Каждый источник отрисовывается так:


    1. Сначала в материал с тенями записывем позицию текущего источника;
    2. Берем специальный спрайт (для Point — это спрайт с радиальным градиентом, для Ambient — просто прямоугольный спрайт размером с экран камеры) и меняем его позицию на позицию источника;
    3. Отрисовываем тени и спрайт света в текстуру (свет отсекается шейдером с помощью stencil-буфера).

    А вот с выводом на экран этого освещения есть интересный момент. Дело в том, что цвет пикселя на экране расчитывается обычно так:


    OBJECT_COLOR * LIGHT_COLOR
    

    Так как цвет пикселя и цвет источника света в пределах от 0 до 1, то и результат будет от нуля до единицы. Причем больше "от нуля", чем "до единицы" — спрайты не белые и вносят свою лепту. А иногда хочется сделать такой яркий источник света, чтобы даже тёмные камни замковых коридоров засияли, как утреннее небо. Добавим дополнительный коэффициент HDRRatio, равный, к примеру 10. И в шейдере источника света будем получать результат вот так:


    fixed4(light.a * _Amount / _HDRRatio, 0, 0, light.a)

    А при смешении света и сцены — умножать на этот коэффициент. Таким образом, мы теряем градации освещения (сколько теряем — определяем HDRRatio), но можем пересвечивать сцену.
    Смешивать свет со сценой будем через постэффект — небольшой шейдер, который будет накладывать свет в зависимости от значения в stencil-буфере (помните, что не все элементы должны быть освещены?). А все Diffuse ambient источники будем суммировать, с некоторым коэффициентом устанавливать как цвет фона на основной камере и как фоновое освещение для всех объектов сцены.




    Мягкие тени


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


    Если кому-то интересно, конкретный пример

    Верхние линии — ломанные, грубые. Нижние — более сглаженные.


    А вот линии теней сейчас не могут соблюдать это правило, все-таки шейдеру не хватает таланта художников (так что, господа дизайнеры, не переживайте, ваш труд никогда не заменит бездушный конвейер видеокарты). Да и вообще, с каких пор тени от солнышка такие резкие? Вот только без переделок сгладить тени не получится — свет отсекается stencil-буфером, а там — либо есть тень, либо нет, середины не существует.
    Гугл на наши запросы выдает страшные слова — 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-канал с тенью без градиента (по сути, просто умножаем два канала и цвет источника света). Получаем аккуратные размытые тени:





    Красивости


    На предыдущем скриншоте трава столь яркая, потому что освещена солнцем, но кажется, будто она светится сама. Добавим свет на грани спрайтов-препятствий.
    В шейдере теней, который спрятан где-то в этой статье, отсекаются тени, которые светят "внутрь" объекта. Теперь они нам понадобятся, чтобы сделать самозатенение для граней. Сами грани будут принимать свет благодаря нашему постэффекту для смешивания освещения и сцены (опять-же, используя stencil-буфер). Грязный хак — чтобы объект не затенял ближайшие к источнику грани, будем двигать точки тени, в которых нормаль равна нулю в сторону от источника света (на один пиксель).


    Если быть честным...

    … То двигаем мы ещё и в сторону нормали, лишь бы один пиксель был точно без тени. Выглядит это как-то так:


    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". У нас уже есть всё необходимое, чтобы их сделать — нужно только разрешить постэффекту рисовать свет там, где в stencil-буфер ничего не записано. Есть два момента: во-первых, добавим некий коэффициент, чтобы настраивать силу такого освещения, во-вторых, этот свет нужно складывать с цветом неба, а не умножать: лучи ведь не зависят от цвета неба, только от запылённости.




    Заключение


    Осталось посмотреть, как выглядит всё это в динамике:



    И, для любопытных, как он выглядит в редакторе:

    С включенными Gizmo света, ботов, ветра и препятствий:



    Итак, у нас есть вполне рабочее освещение для pixelart проекта. Оно поддерживает динамические объекты, мягкие тени и прочие эффекты. Вполне можно двигаться дальше! На данный момент в проекте есть несколько направлений, которые частично завершены, и про которые будет, надеюсь, интересно прочесть. Поэтому, о чём будет следующая статья — предоставляю решать вам.


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

    Only registered users can participate in poll. Log in, please.

    О чём будет следующая статья?

    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 25

      +1
      Магия!
      и прямые руки…
        0
        Прямые руки это собственно и есть магия
          +1
          Да ладно, главное — пробовать. Я список своих косяков опишу с последней статье )
            0
            Исходники можно?
              –3
              Все исходники тут: www.shadertoy.com
                0
                Прошу прощения, что целую вечность не отвечал. К сожалению, в открытом виде (по крайней мере, пока) исходников нет. Если всё пойдет по плану, результаты этого цикла окажутся в Unity asset store, доступные за некоторую цену.
                На самом деле, мне кажется, более полезно будет мне дописать этот цикл, делясь всеми алгоритмами и хинтами, как когда-то давным-давно, Dionis_mgn рассказал, как сделать 2д тени.
          0
          можно вопрос? А как вы реализовали ветер?
            0
            Это будет в одной из следующих статей )
            На самом деле, ничего сложного, там даже не Навье-Стокс. Основная фишка — взаимодействие с травой и частицами (но это, скорее, визуальная фишка, в коде все просто).
              +1
              В симуляции и рендеринге задачи могут решаться не от начала к концу, а от конца к началу. Тоесть это не какой-то абстрактный ветер шатает траву или деревья, а трава и деревья шатаются так, как-будто есть ветер. Понятия ветер в игре может и не быть вовсе. Красивые полеты и шатания делаются уже на самом объекте магией синусов-косинусов, если много не будут отжирать, конечно.
                +2
                Да, обычно так и делают — сам ветер не видно, значит можно отобразить его следствия и все. Это работает и применяется повсеместно.
                Но в этом проекте — ветер настоящий. Причина — повышенная интерактивность: взрывы или движения героя влияют на ветер, а он в свою очередь — на частицы и траву. Получается очень эффектно.
              0
              Видели Legend of Dungeon?
              Как думаете — это 3D или нет?
                0
                Да, это 3д. Команда разработки периодически стримит на твич
                +4
                одна из лучших статей за последнее время. спасибо!
                  +1
                  Глядя на статичные картинки я вообще не ощущал как падает свет, магия стала видна только на видео в динамике. Скорее всего это из-за того, что свет солнца выглядит странно, больше похоже на свет фонарного столба. Вероятно верхний источник света очень маленького размера и, судя по тени, находится довольно близко к верхней плитке.
                  P.S. Тень на небо падает — это фича или баг?
                    0
                    Все верно, солнце очень близко — чтобы линии теней были не параллельны (так нагляднее показать ломаные линии неразмытых теней). Ну а размеры источников света — точечные, и ощущение их размера — только из-за размытия.
                    О тени на фоне неба есть в самой статье — это god rays, которые можно использовать на туманных картах, например.
                    +3
                    Хорошая статья, мне было приятно читать (как начинающему), но разве что хотелось бы ссылочку на проект сделанный в этой статье. Заранее спасибо.
                      +1
                      Привет!
                      Этот принцип построения теней может быть полезен и для рисования мозаик: красное стекло отбрасывает «красную тень». Достаточно лишь меш строить с информацией о цвете вершины (доставать можно всё с тех же спрайтов). Тоже может оказаться интересным эффектом.

                      P.S.: Читал статью с мыслью «не я один до такого додумался». Потом увидел автора =)
                        0
                        Одного у меня «god rays» на данном примере вызывают ощущение, что задний фон — это некая стена? Видимо, не хватает более сложного «задника» (горы, лес, облака)
                          0
                          Именно поэтому обычно они будут выключены. Включать их имеет смысл на темных или туманных уровнях.
                          +1
                          Есть познавательная статья по расчету освещения для 2D-объектов, может будет интересна сообществу: LINK

                          Визуализация алгоритма:
                          image
                            0
                            Метод хороший, но сильно проигрывает по производительности предложенному в статье. Плюс, имеет «лишние» потери качества при преобразованиях.
                            0
                            «То есть толщина прямоугольника равна нулю, но у двух граней есть нормаль, а у двух — нормаль нулевая.» Граней? может вы имели ввиду вершин?
                              0
                              Да, спасибо, поправлю
                              0
                              Очень мило выглядит. Интересная статья.
                                +1
                                ScriptableObject'ы создаются вот таким атрибутом:
                                [CreateAssetMenu(menuName="GameEngine/FilterData", fileName = "FilterData.asset")]
                                public class FilterData : ScriptableObject {}
                                

                                Only users with full accounts can post comments. Log in, please.