Усатый стрелок с полигональным пузом. Часть вторая


    Рассказ про разработку проекта похож на паутину: повсюду тянутся ниточки ассоциаций, истории про интересные идеи. А иногда нити повествования обвиваются коконом вокруг необычного бага. Вот и сейчас, материала накопилось столько, что приходится начинать работать над второй частью статьи до того, как первая опубликована.


    А теперь, когда опубликована вторая часть, материала достаточно и для третьей части! :)

    Сегодня в программе: смесь визуала и архитектуры проекта. Но сначала, ещё парочка деталей про тени.
    Итак, поехали!


    Статьи



    Оглавление



    Level 3.4. Менеджер теней.


    Как вы помните, тени уже генерируются на CPU с кучей оптимизаций. А вот их отрисовку нужно доработать. Пока я разбирался с генерацией, мне нужен был самый простой способ рендеринга, поэтому всё работает так:


    1. У каждого объекта, отбрасывающего тень, два потомка, которые рендерят тени с разными шейдерами (один только back — грани, другой — front);
    2. На сцене лежит огромный спрайт, который отрисовывается самым последним, и подкрашивается в нужный цвет, если в стенсиле ненулевое значение.

    Догадываетесь, какие проблемы это вызывает?
    1. Самое простое — дублирование объектов (по два одинаковых потомка у каждого элемента). Избавиться от них, сделав двухпроходный шейдер — не вариант, т.к. объекты с многопроходными шейдерами не умеют батчиться;
    2. Далее, возможность сделать только один источник света с тенями;
    3. Очень скупые возможности по работе со светом (т.к. по сути, света и нет, только тень). Так что сделать цветную тень можно, а цветное освещение — нет;
    4. Стенсил буфер занят целиком и его не получится использовать для других эффектов.

    Идея простая: вынести рендеринг теней в отдельный проход, добавив возможность отбрасывать тени любому количеству источников света (да-да, fps будет проседать).


    Всего потребовалось несколько классов:


    • LightSource, фонарик с бесконечной дальностью, настройкой цветов тени и освещения;
    • IShadowSource, интерфейс с функцией пересчёта тени void RebuildShadow(Vector3 lightPosition);
    • ShadowRenderer, который регистрирует все источники света и теней и умеет рендерить тени.

    Код написан, шейдеры проверены, можно двигаться дальше. И тут начались проблемы.
    Косяки с тенями
    Забагованные тени.


    На изображении выше целых две проблемы.


    Во-первых, тени слишком длинные и иногда неправильно перекрывают объекты. Такое может быть, если тени рендерятся поверх пустого z-buffer'а (другие объекты могут перекрывать тень, но сами тени в z-buffer ничего не пишут).


    Во-вторых, тени в каком-то странном шуме. Такое бывает, если работать с неочищенным буфером.


    Итак, проблема в том, что z-buffer, с которым я работаю, судя по всему, не использовался камерой. Рендеринг кадра сейчас работает так:


    1. Рендеринг сцены в RenderTexture;
    2. Рендеринг тени, depth-buffer берётся из п.1, а color-buffer свой (об этом ниже);
    3. Композинг теней и отрендереной сцены;
    4. Постэффекты.

    Про раздельное использование буферов.

    Когда работаешь с постэффектами зачастую нужно с помощью какого-то шейдера преобразовать текстуру. В Unity3D для этого есть метод Graphics.Blit. Мы передаём в него исходную текстуру, указываем target — куда отрисовывать, материал и даже проход шейдера.
    По сути, мы работаем минимум с тремя различными буферами:


    1. Исходный color buffer, откуда мы читаем цвета пикселей;
    2. Целевой color buffer, куда мы пишем цвета;
    3. Depth+stencil buffer, в который мы пишем (и из которого читаем глубину и данные стенсила).

    И в методе Graphics.Blit целевой color buffer и depth buffer неразделимы. Т.е., если нам нужно, например, читать глубину геометрии сцены из исходной текстуры, а записывать пиксели в целевую — облом.


    Или если мы сделали рендеринг сцены в текстуру, при этом часть шейдеров записала данные в стенсил, а теперь хотим получить новую текстуру, воспользовавшись этими данными (и сохранив исходную текстуру!) — тоже облом.


    Выход есть и в документации Unity3D об этом прямо сказано:


    Note that if you want to use depth or stencil buffer that is part of the source (Render)texture, you'll have to do equivalent of Blit functionality manually — i.e. Graphics.SetRenderTarget with destination color buffer and source depth buffer, setup orthographic projection (GL.LoadOrtho), setup material pass (Material.SetPass) and draw a quad (GL.Begin).

    В общем, модифицированная версия Blit, позволяющая разделить передачу буферов:


    static void Blit(RenderBuffer colorBuffer, RenderBuffer depthBuffer, Material material) {
        Graphics.SetRenderTarget(colorBuffer, depthBuffer);
    
        GL.PushMatrix();
        GL.LoadOrtho();
    
        for (int i = 0, passCount = material.passCount; i < passCount; ++i) {
            material.SetPass(i);
    
            GL.Begin(GL.QUADS);
            GL.TexCoord(new Vector3(0, 0, 0));
            GL.Vertex3(0, 0, 0);
            GL.TexCoord(new Vector3(0, 1, 0));
            GL.Vertex3(0, 1, 0);
            GL.TexCoord(new Vector3(1, 1, 0));
            GL.Vertex3(1, 1, 0);
            GL.TexCoord(new Vector3(1, 0, 0));
            GL.Vertex3(1, 0, 0);
            GL.End();
        }
    
        GL.PopMatrix();
    
        Graphics.SetRenderTarget(null);
    }

    Использование в коде:


    void RenderShadowEffect(RenderTexture source, RenderTexture target, LightSource light) {
        shadowEffect.SetColor("_ShadowColor", light.ShadowColor);
        shadowEffect.SetColor("_LightColor", light.LightColor);
        shadowEffect.SetTexture("_WorldTexture", source);
        shadowEffect.SetTexture("_ShadowedTexture", target);
    
        Blit(target.colorBuffer, source.depthBuffer, shadowEffect);
    }

    Итак, в чем же дело? Почему моя RenderTexture, в которую я рендерю камеру на выходе совершенно пуста (и даже не очищена от мусора)?


    Выключаю тени и смотрю, что показывает frame debug:




    Странные рендер-текстуры.


    Любопытно. Судя по всему, постэффект антиалиасинга принудительно переводит камеру на рендеринг в свою текстуру. При этом доступа до этой текстуры у меня нет: при дебаге в Camera.аctiveTexture пустая.
    Ах, так, антиалиасинг! Лезешь в мою последовательность отрисовки? Тогда я залезу в твой код!


    Постэффекты работают через метод MonoBehaviour.OnRenderImage, а я через MonoBehaviour.OnRenderImage и
    MonoBehaviour.OnPostRender. Делаю грязный хак: переименовываю OnRenderImage в Apply и вызываю его руками, после рендеринга теней, с моими renderTexture. Теперь антиалиасинг не мешает теням.


    Новые тени позволяют делать смешные, но не очень нужные штуки вроде хроматической aберрации или гладких теней.

    Сотня обычных бледных теней с небольшим смещением.

    Три цветных тени.


    Пока тени притормаживают на мобилках (съедают примерно 10 — 15 лишних fps). Если все будет грустно, переведу под конец все в однопроходную отрисовку, а пока не буду налегать на источники света.


    Хинт: импровизируйте в отладке графики! Отлаживать вертексные шейдеры бывает больно, поэтому визуализируйте все данные, которые сможете: вытягивайте вертексы вдоль нормалей, добавляйте цвет и прозрачность и т.д.

    Дебажная визуализация через шейдеры и гизмо.

    Оказалось, что добавлять новые классы стало тяжелее из-за некоторых неудачных проектных решений.
    Todo: почистить архитектуру и код проекта


    Level 4.1. Рефакторинг архитектуры.


    Как вы помните, я развиваю проект с прототипа. Но тянуть все прототипную архитектуру (знаете, какая архитектура в прототипах, написанных за 2 часа?) не хочется, значит, нужен рефакторинг.


    Итак:


    Для начала выношу как можно больше данных из MonoBehaviour в ScriptableObject. Это всяческие стили, настройки, библиотека префабов;



    Настройки проекта.


    Разбиваю всю логику на маленькие классы, например, BulletCaster или MovableObject. Каждый из них содержит в себе нужные настройки и преследует только одну цель.


    Эти классы обладают очень простым интерфейсом.
    public class BulletCaster : MonoBehaviour {
        public void CastBullet(Vector2 direction);
    }
    
    public class MovingBody : MonoBehaviour {
        public Vector2 Direction {get; set;}
        public bool IsStopped {get; set;}
    }


    Из микроклассов можно собрать сложную логику.


    Убираю прямые зависимости от синглтонов (Clock, ShadowManager и т.д.) и реализую паттерн service locator (несколько спорная вещь, но куда аккуратнее, чем россыпь синглтонов).


    Реализую обработку столкновений через слои, оптимизирую их, явно убирая невозможные столкновения (например, статика <-> статика).


    Оптимизирую создание объектов, написав глобальный pool. Думаю, это очередной велосипед, но мне хотелось написать его своими руками. Пул умеет создавать объекты по ключу-префабу, инициализировать их после создания, уведомлять объекты о создании/удалении.


    А однажды с пулом вышел забавный казус.

    У моих пуль есть ограничение по времени жизни (примерно 10 секунд "незамороженного" времени). Как то раз появился странный баг: часть пуль исчезала прямо в воздухе, словно кулдаун наступал раньше срока и пуля исчезала по таймеру.


    Отловить было сложно: не все пули исчезали, а дебажить каждую, надеясь, что хоть одна исчезнет — очень утомительно.


    Впрочем, удалось выяснить два странных факта:


    1. Пули начинали исчезать только после перезапуска уровня;
    2. Код, ответственный за удаление пуль вообще не вызывался.

    Самое важное правило в очередной раз не подвело:


    Чем страннее кажется баг, тем глупее его причины.

    Итак, наслаждайтесь:


    1. Уровни пересоздаются на одной сцене, без перезагрузки;
    2. При создании уровня я забыл удалять старые стены (т.к. уровень одинаковый, это не было заметно нигде, кроме иерархии;
    3. Когда пуля касалась такой двойной стены, обработчик коллизии вызывался дважды;
    4. В обработчике коллизии пуля удаляется (добавляется в пул). Таким образом, в данных пула оказывалось две ссылки на одну и ту же пулю;
    5. Через какое-то время игрок стрелял этой пулей;
    6. При попытке выстрелить ещё раз из пула забиралась ссылка на уже активную, летящую пулю. Она переинициализировалась, меняла свои координаты и "предыдущая" пуля исчезала прямо в воздухе.

    Конечно, такая ошибка могла быть и без старых стен, при сложных коллизиях. Поэтому я добавил проверки на активность объекта при столкновениях и, конечно, начал удалять старые стены.


    Вспоминаю про проблему с неудобным тачем и реализую TouchManager. Он запоминает последнее прикосновение и трекает только его. Он сохраняет N последних движений, игнорируя слишком короткие (дрожание пальца). В момент, когда палец или мышь перестали касаться экрана, менеджер рассчитывает направление и длину жеста. Если жест слишком короткий — менеджер игнорирует его: игрок передумал, не выбрав чёткого направления.


    Теперь код стал более читаемым, добавлять новые классы стало проще, а ощущение, что архитектура проекта развалится при добавлении самой последней фичи перед релизом, исчезло.
    Можно вернуться к арту и игровой логике. По правде говоря, и в логике игры не мешало бы провести чистку.


    Todo: подумать над геймплейными элементами, понятностью и простотой геймплея для игрока.


    Level 4.2. Рефакторинг игровых объектов


    Когда я обдумывал геймплейные фичи, я был заворожён огромным количеством возможностей. Судите сами, все объекты могут обладать четырьмя ортогональными характеристиками:


    1. Отражает ли пули или поглощает их?
    2. Уничтожим ли объект пулей?
    3. Подвижен ли или статичен?
    4. Каков тип объекта (игрок/враг/гражданский)?

    Все эти характеристики можно скомбинировать и даже менять на лету. Но как показать это игроку? Сначала мой список объектов выглядел так:


    1. Обычные стены. Поглощают пули;
    2. Зеркальные стены. Отражают пули;
    3. Многоэтажные стены. Каждый этаж — обычный или зеркальный. При попадании пули нижний этаж уничтожается, верхние падают вниз. Так можно делать счётчики и т.д.;
    4. Ящики. Динамические, но неуничтожимые, поглощают пули;
    5. Зеркальные ящики. Динамические, но неуничтожимые, отражают пули.;
    6. Цыплята. Динамические, уничтожимые, игрок теряет очки при их гибели;
    7. Враги. Динамические, уничтожимые, нужно победить всех для прохождения уровня;
    8. Зеркальные враги. Обычные враги, но пуля, уничтожая врага, отражается;
    9. Кристаллы. Динамические, уничтожаются пулей, если игрок коснётся их, он получает бонус;
    10. Игрок.


    Все доступные объекты.


    У меня явно будут проблемы с понятной визуализацией всей этой красоты. Когда я начинал работать над low-poly версией, я планировал использовать простое цветовое кодирование:


    1. Цвет кромки определяет тип объекта;
    2. Белый цвет потолка обозначает статический объект (стену), тонированный в цвет кромки потолок — динамический.

    Например, серая кромка означает, что объект поглотит пулю, фиолетовая — отразит. Но получается, что цвет кромки должен кодировать очень много свойств. Слишком сложно. Нахожу типы объектов с очевидными проблемами:


    1. Многоэтажные стены. Когда остаётся только один этаж — он не будет отличаться от обычной стены. Но он будет уничтожим. Или нет? Очень нелогичная фича;
    2. Зеркальные ящики. Пуля всегда движется с одной скоростью. Переотражаясь от ящиков, она будет их бесконечно и непредсказуемо ускорять;
    3. Зеркальные враги. Придётся использовать другой цвет, не похожий ни на "вражеский", ни на "зеркальный". Эта сущность только путает все карты.

    Итого, остаётся:


    1. Два типа стен, обычные и зеркальные;
    2. Игрок;
    3. Цыплята;
    4. Враги;
    5. Кристаллы;
    6. Ящики.

      Объекты, оставшиеся после чистки.

    Всё хорошо кодируется цветом, объектов стало мало, но есть простор для левелдизайна.


    Теперь, когда я определился с игровыми сущностями и почистил код от мусора, можно довести графику до финального состояния. Это и эффекты и физика, и всякие интерфейсные элементы. Короче говоря, работы много.


    Todo: начать разработку релизных эффектов.


    Level 5.1. Эффект гибели.


    Точный, выверенный жест и ход сделан. Пуля летит, отражается от стены, проходит в считанных пикселях от игрока, задевает кромку другого отражателя и вновь меняет направление. Теперь она нацелена в последнего противника на карте. Ход закончен. Новый ход, в ожидании победы. Направление уже не важно: прошлая пуля сделает своё дело. Итак, законы геометрии неумолимы и снаряд находит свою цель. Пуля касается врага… И враг просто исчезает. Вот облом.

    Да, хочется какого-то фана при попадании пули. Чтобы игра визуально говорила:


    • "Да, чел, ты это сделал! Ты расфигачил его в чёртовой бабушке!"
      Или наоборот:
    • "Аккуратнее, аккуратнее… Нееееет! Ты был так близко, а теперь придётся проходить все снова!"

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


    Ладно, что требуется от эффекта гибели?


    1. Он должен быть ярким, ощутимым и значимым;
    2. Последствия гибели должны быть видны в течении всего уровня, помогая планировать перепрохождение, но не отвлекая.

    Осколки! Пусть пуля разбивает противников на кусочки! Хм, а это не сложно? Неа, все объекты выпуклые, а резать выпуклые многоугольники — одно удовольствие.


    На самом деле, просто разрезать противника пополам я не могу. Он состоит из нескольких мешей:


    1. Внутренняя часть, выпуклый многоугольник, тут все просто;
    2. Цветное кольцо. Оно мало того, что не выпуклое, да ещё и с дыркой. Но состоит из N (где N — количество сторон) выпуклых четырёхугольников. Так что просто сохраню их в массиве и разрежу каждый из них;
    3. Внешняя сторона. По сути, это внешняя сторона четырёхугольников из предыдущего пункта. Но я буду работать с ней как с большим выпуклым многоугольником — для рендеринга боковых граней и физики через PolygonCollider2D.


    Части объекта, которые нужно разрезать отдельно.


    В результате алгоритм получается такой:


    1. Преобразую объект (игрока, врага и т.д.) во внутреннюю часть, массив кусков кольца, внешнюю часть. В дальнейшем буду именовать эту структуру "Piece";
    2. Нахожу геометрический центр для Piece;
    3. Выбираю случайное направление и "провожу" прямую в этом направлении через геометрический центр;
    4. Разрезаю Piece этой прямой. Для этого беру каждый многоугольник из Piece (кольцо, внутренняя и внешняя части или их кусочки):
      4.1. Создаю левый и правый массив точек;
      4.2. Указываю текущий массив — левый;
      4.3. Добавляю первую точку многоугольника в текущий массив;
      4.4. Прохожу по всем оставшимся точкам;
      4.5. Если текущая и предыдущая точки находятся с одной стороны прямой, добавляю текущую точку в текущий массив;
      4.5. Если текущая и предыдущая точки находятся с разных сторон прямой, нахожу точку пересечения, добавляю её и в левый и в правый массивы. Переключаю текущий массив на противоположный. Добавляю в новый текущий массив текущую точку.
    5. Добавляю все левые осколки в новый левый Piece, а правые — в правый;
    6. Снов разрезаю получившиеся осколки рекурсивно, до указанной глубины.

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


    Модифицирую код создания меша, коллайдера и тени многоугольника, чтобы он мог создавать ещё и осколки по заданным точкам.



    Получаю примерно такие осколки.


    Сначала я планировал сделать кешированные разбиения и использовать только их, но оказалось, что разбиение в реалтайме не вызывает тормозов, а выглядит эффектнее.

    Осколки выявили неприятный баг с тенями: я неправильно искал силуэтные точки на многоугольниках. Именно из-за этого на предыдущем видео часть осколков отбрасывают тени лишь частично. Исправления уже доступны в предыдущей статье.

    Итак, теперь при попадании пули в объект я подменяю последний на осколки. Объект убирается в пул, а осколки загружаются из пула и обновляют свои меши/коллайдеры согласно своей новой форме. Скорости для rigidBody рассчитываются исходя из скорости разрушенного элемента и направления пули. У осколков выключен флаг isBullet, они взаимодействуют только со стенами и друг с другом. У каждого осколка есть специальный класс FloorHider, он опускает объект по координате z сквозь пол, а после его полного исчезновения — удаляет (перемещает в пул.)


    Небольшая ретроспектива:


    1. Осколки — очень "физически" понятный образ. Поэтому он помогает понять концепцию остановки времени. Осколки начинают разлетаться, тут время останавливается и всё замирает. Игра перестала выглядеть детерминированно пошаговой;
    2. Все осколки батчатся друг с другом и не тормозят;
    3. Кристалл сейчас удаляется при прикосновении без эффектов. Может, тоже разбивать на кусочки?
    4. Не хватает какого-то следа после уничтожений, хочется, чтобы на уровне оставались последствия атак;
    5. Фаново смотрится, хочется стрелять!


    Осколки!


    Разлетающиеся на куски враги — весьма эффектный штрих, но он подпорчен тем, что осколки исчезают без всяких следов. Есть и ещё несколько причин, кроме эффектности, почему мне бы эти следы добавить.


    Todo: реализовать эффект следов от осколков.


    Level 5.2.1. Эффект пятен.


    Когда я думал над визуалом, у меня в голове крутилась идея "пятен крови", которые бы появлялись при гибели игрока или npc. На длинных уровнях такие пятна станут удобными метками для навигации. А в случае проигрыша помогут оценить начальное положение врагов и обдумать тактику повторного прохождения.

    Итак, пятна. Вариант с декалями и отрисованными текстурами отбрасываю: мне кажется, так я выбьюсь из стиля. Пробую отобразить след от осколков или места их исчезновения. Смотрится плохо:




    Разные варианты пятен.


    Думаю над полноценной заливкой, среди вариантов прототипирую такой:



    Просто создание кучи треугольников.


    Да, этот вариант мне понравился больше. Но создавать множество треугольников с диким overdraw'ом — плохая идея. Впрочем, результат похож на мозаику, так что думаю в сторону диаграммы Вороного.


    Вот только генерировать её на лету, особенно с последующей релаксацией Ллойда (релаксация делает ячейки схожими по размеру) на мобильных устройствах будет слишком больно. Нужен предрассчет. И отсюда очередная проблема: пятна могут быть на любом расстоянии друг от друга, и я, очевидно, не могу предрассчитать бесконечно большую диаграмму. Знаете, что такое тайлинг? :)


    Для начала, нахожу подходящую библиотеку для генерации диаграммы Вороного.


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


    1. Создаю N точек в квадрате с координатами {-0.5, -0.5, 0.5, 0.5};
    2. Для каждого Y в интервале [-tiles, tiles] и X в интервале [-tiles, tiles], кроме X = 0, Y = 0:
      2.1. Копирую точки со смещением X, Y (при tiles = 1 получается 9 тайлов с моим начальным в центре);
    3. Строю по всем точкам (включая смещённые клоны) диаграмму Вороного;
    4. Применяю при необходимости релаксацию Ллойда;
    5. Прохожу по всем получившимся полигонам и оставляю только те, у которых центр находится в исходном квадрате {-0.5, -0.5, 0.5, 0.5}.

    В результате получается тайл с полигонами, у которого левая сторона идеально подходит к правой, верхняя — к нижней (с диагоналями — аналогично). На самом деле, не всё так гладко.
    Идея в том, что диаграмма Вороного — очень локальная штука, поэтому можно эмулировать тор, сделав несколько копий исходных точек во все стороны. Но вот релаксация Ллойда уж точно локальной не является, и чем больше количество итераций, тем больше нужно делать копий (увеличивать значение tiles).


    Да и координаты центров не всегда получается корректно проверить, все-таки, плавающая запятая. Поэтому иногда, очень редко, на краешке мозаики не хватает какого-нибудь элемента.

    Найдёте повторения?


    Подсказка


    Итак, получается примерно такой кусочек мозаики:

    Мозаика отрендерена на текстуре средствами библиотеки.


    Делаю небольшой ScriptableObject, хранящий массив рассчитанных тайлов и редактор с большой кнопкой "recalculate tiles".


    Проблемы с редкими дырами из-за float'а в проверках на попадание полигона в тайл я решил перегенерированием некорректных тайлов. Т.к. я делаю предрассчет один раз, руками в редакторе, могу себе такое позволить. :)

    Теперь бы выводить эти тайлы на экран!


    Todo: генерировать треугольники тайлов мозаики.


    Level 5.2.2. Рендеринг тайлов.


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


    Для проверки алгоритма создал вот такую "мозаику", с ней проще будет найти проблемы:

    Поддельная мозаика


    Допустим, у меня мозаика генерируется из 512 точек. Значит, на выходе получится 512 полигонов и проверять каждый на пересечение с окружностью — слишком дорого. Поэтому храню мозаику в виде небольших прямоугольных блоков:

    Визуализация разделения на блоки.
    Зная площадь мозаики и количество полигонов, можно получить оптимальное количество блоков, при котором скорость поиска будет максимальна.


    Итак, логика поиска такая:


    Дана окружность с координатами center и радиусом radius. Нужно найти все полигоны, попадающие в окружность.


    1. Считаем из AABB окружности координаты первого и последнего блоков, попадающих в AABB (Значения координат могут быть меньше 0 или больше rows — 1 из-за тайлинга).
      var rectSize = size / (float)rows;
      int minX = Mathf.FloorToInt((center.x - radius) / rectSize);
      int minY = Mathf.FloorToInt((center.y - radius) / rectSize);
      int maxX = Mathf.CeilToInt((center.x + radius) / rectSize);
      int maxY = Mathf.CeilToInt((center.y + radius) / rectSize);
    2. Проходим по каждому блоку от min до max;
    3. Отбрасываем блоки, не пересекающиеся с окружностью;
    4. Получаем реальные координаты блока внутри тайла:
      int innerX = ((x + rows) % rows + rows) % rows;
      int innerY = ((y + rows) % rows + rows) % rows;
    5. Проходим по всем полигонам в блоке;
    6. Добавляем в список те полигоны, центры которых принадлежат окружности (с учётом смещения).

    Итак, теперь можно одним запросом получить данные обо всех полигонах, попадающих в заданную окружность:

    Запрос полигонов. Тестовая мозаика изменена на более наглядную


    Все полигоны диаграммы Вороного выпуклы по определению, поэтому триангулировать их и добавить в меш — проще простого. Делаю первый тест рендеринга:



    Выглядит, мягко говоря, скучно. Более того, если два пятна создаются с примерно на расстоянии одного тайла, например, {0, 0} и {1, 1}, бывают заметны повторения.


    Спасибо любимой, она предложила хорошую модификацию этого алгоритма:


    1. Убрать релаксацию Ллойда, сделав полигоны более резкими;
    2. Заменить многоугольники на треугольники;
    3. Добавить больше случайности в расположение треугольников.

    А теперь в картинках:
    Пусть у нас есть вот такая мозаика:

    Points = 500, Relax = 5


    В ней бывают видны повторы, а ещё она очень однообразна.
    Убираем релаксацию Ллойда:

    Points = 500, Relax = 0


    А теперь смешиваем все карты: считаем полигоном не многоугольник, сгенерированные в диаграмме Вороного, а треугольники, получаемые триангуляцией для рендеринга:

    Triangles, Points = 500, Relax = 0


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

    Выделены нулевые треугольники в каждом полигоне диаграммы.


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

    Паттерны перестали быть заметны.


    Резюмирую: теперь у меня есть бесконечная мозаика, в которой не видно повторов. Я могу делать запрос на получение треугольников этой мозаики в определённом радиусе. Судя по всему, основа для пятен "крови" готова.


    Todo: придумать конкретную геометрию пятен и замостить её мозаикой.


    Заключение.


    Проект развивается, и обрастает эффектами. Я отрезаю лишнее на уровне прототипа и пытаюсь упрощать всё донельзя — как геймплей, так и визуальную часть. Тем не менее, остается еще несколько эффектов (и их полировка), без которых игра смотрится незаконченной.


    Итак, ещё несколько выводов:


    1. Процедурные эффекты рулят. Иногда классические алгоритмы (вроде диаграммы Вороного) не очень подходят, но после напильника и лобзика приобретают нужные качества;
    2. Закон Хофштадтера: Любое дело всегда длится дольше, чем ожидается, даже если учесть закон Хофштадтера. В общем, разработка проекта затягивается. :)
    3. Unity3D очень полезна, но иногда ставит палки в колёса. Если ваши кастомные постэффекты перестали работать — посмотрите во frame debug, может, после обновления Unity3D решила дополнить отрисовку своими эффектами (и включила на камере msaa).

    В следующей статье я планирую закончить рассказ про рендеринг пятен, описать эффект следов, особенности редактора карт и загрузки уровней.


    Спасибо за внимание, жду ваших комментариев и feedback'a!

    Метки:
    Поделиться публикацией
    Комментарии 4
    • +1

      Хмммм. По идее, кровища (с) от разбитых малополигональных объектов должна быть похожа на малополигональное пятно (т.е. многоугольник на полу с малым числом сторон). Может, их ляпать, с прозрачностью, которая может уменьшаться от времени, типа кровь высыхает, а также их можно складывать одно на другое, получится, что под зеленым пятном синее, например. Базовую идею можно потащить в Amorphous+, пускай там и набор пятен, и они сплайновые, а не многоугольные.

      • 0

        Да, про форму пятен — вопрос отдельный. В этой статье я более-менее рассказал, как я получил бесконечный источник "материала" для пятен — мозаику из диаграмы Вороного. А вот конкретная форма пятен определяется через metaballs из точек, где исчезают осколки. Но про это я хочу подробнее в следующей части написать.

      • 0
        Зеркальные враги. Придётся использовать другой цвет, не похожий ни на «вражеский», ни на «зеркальный». Эта сущность только путает все карты.

        А нельзя ли сделать что-то вроде свечения (возможно, пульсирующего) вокруг врагов, что сигнализирует о том, что они отражают пули?
        p.s. просто озвучил мысль вслух.

        Мало что понимаю в Unity3D, но тем не менее очень интересно читать подобные статьи, пишите еще :)
        • +1

          Техническая возможность есть, конечно :)
          Но тогда для консистентности нужно сделать и отражающие стены тоже со свечением пульсирующим. В общем, чаще проще убрать какие-то возможности, чем придумывать способы отображения )
          Этому проекту подрезание ветвей точно пойдет на пользу.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.