Рассказ про разработку проекта похож на паутину: повсюду тянутся ниточки ассоциаций, истории про интересные идеи. А иногда нити повествования обвиваются коконом вокруг необычного бага. Вот и сейчас, материала накопилось столько, что приходится начинать работать над второй частью статьи до того, как первая опубликована.
А теперь, когда опубликована вторая часть, материала достаточно и для третьей части! :)
Сегодня в программе: смесь визуала и архитектуры проекта. Но сначала, ещё парочка деталей про тени.
Итак, поехали!
Статьи
- Первая часть. Усатый стрелок из двадцати трёх полигонов.
- Вторая часть. Усатый стрелок с полигональным пузом.
Оглавление
- Менеджер теней
- Рефакторинг архитектуры
- Рефакторинг игровых объектов
- Эффект гибели
- Эффект пятен
- Рендеринг тайлов
- Заключение второй части
Level 3.4. Менеджер теней.
Как вы помните, тени уже генерируются на CPU с кучей оптимизаций. А вот их отрисовку нужно доработать. Пока я разбирался с генерацией, мне нужен был самый простой способ рендеринга, поэтому всё работает так:
- У каждого объекта, отбрасывающего тень, два потомка, которые рендерят тени с разными шейдерами (один только back — грани, другой — front);
- На сцене лежит огромный спрайт, который отрисовывается самым последним, и подкрашивается в нужный цвет, если в стенсиле ненулевое значение.
- Самое простое — дублирование объектов (по два одинаковых потомка у каждого элемента). Избавиться от них, сделав двухпроходный шейдер — не вариант, т.к. объекты с многопроходными шейдерами не умеют батчиться;
- Далее, возможность сделать только один источник света с тенями;
- Очень скупые возможности по работе со светом (т.к. по сути, света и нет, только тень). Так что сделать цветную тень можно, а цветное освещение — нет;
- Стенсил буфер занят целиком и его не получится использовать для других эффектов.
Идея простая: вынести рендеринг теней в отдельный проход, добавив возможность отбрасывать тени любому количеству источников света (да-да, fps будет проседать).
Всего потребовалось несколько классов:
- LightSource, фонарик с бесконечной дальностью, настройкой цветов тени и освещения;
- IShadowSource, интерфейс с функцией пересчёта тени void RebuildShadow(Vector3 lightPosition);
- ShadowRenderer, который регистрирует все источники света и теней и умеет рендерить тени.
Код написан, шейдеры проверены, можно двигаться дальше. И тут начались проблемы.
Забагованные тени.
На изображении выше целых две проблемы.
Во-первых, тени слишком длинные и иногда неправильно перекрывают объекты. Такое может быть, если тени рендерятся поверх пустого z-buffer'а (другие объекты могут перекрывать тень, но сами тени в z-buffer ничего не пишут).
Во-вторых, тени в каком-то странном шуме. Такое бывает, если работать с неочищенным буфером.
Итак, проблема в том, что z-buffer, с которым я работаю, судя по всему, не использовался камерой. Рендеринг кадра сейчас работает так:
- Рендеринг сцены в RenderTexture;
- Рендеринг тени, depth-buffer берётся из п.1, а color-buffer свой (об этом ниже);
- Композинг теней и отрендереной сцены;
- Постэффекты.
Когда работаешь с постэффектами зачастую нужно с помощью какого-то шейдера преобразовать текстуру. В Unity3D для этого есть метод Graphics.Blit. Мы передаём в него исходную текстуру, указываем target — куда отрисовывать, материал и даже проход шейдера.
По сути, мы работаем минимум с тремя различными буферами:
- Исходный color buffer, откуда мы читаем цвета пикселей;
- Целевой color buffer, куда мы пишем цвета;
- 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 секунд "незамороженного" времени). Как то раз появился странный баг: часть пуль исчезала прямо в воздухе, словно кулдаун наступал раньше срока и пуля исчезала по таймеру.
Отловить было сложно: не все пули исчезали, а дебажить каждую, надеясь, что хоть одна исчезнет — очень утомительно.
Впрочем, удалось выяснить два странных факта:
- Пули начинали исчезать только после перезапуска уровня;
- Код, ответственный за удаление пуль вообще не вызывался.
Самое важное правило в очередной раз не подвело:
Чем страннее кажется баг, тем глупее его причины.
Итак, наслаждайтесь:
- Уровни пересоздаются на одной сцене, без перезагрузки;
- При создании уровня я забыл удалять старые стены (т.к. уровень одинаковый, это не было заметно нигде, кроме иерархии;
- Когда пуля касалась такой двойной стены, обработчик коллизии вызывался дважды;
- В обработчике коллизии пуля удаляется (добавляется в пул). Таким образом, в данных пула оказывалось две ссылки на одну и ту же пулю;
- Через какое-то время игрок стрелял этой пулей;
- При попытке выстрелить ещё раз из пула забиралась ссылка на уже активную, летящую пулю. Она переинициализировалась, меняла свои координаты и "предыдущая" пуля исчезала прямо в воздухе.
Конечно, такая ошибка могла быть и без старых стен, при сложных коллизиях. Поэтому я добавил проверки на активность объекта при столкновениях и, конечно, начал удалять старые стены.
Вспоминаю про проблему с неудобным тачем и реализую TouchManager. Он запоминает последнее прикосновение и трекает только его. Он сохраняет N последних движений, игнорируя слишком короткие (дрожание пальца). В момент, когда палец или мышь перестали касаться экрана, менеджер рассчитывает направление и длину жеста. Если жест слишком короткий — менеджер игнорирует его: игрок передумал, не выбрав чёткого направления.
Теперь код стал более читаемым, добавлять новые классы стало проще, а ощущение, что архитектура проекта развалится при добавлении самой последней фичи перед релизом, исчезло.
Можно вернуться к арту и игровой логике. По правде говоря, и в логике игры не мешало бы провести чистку.
Todo: подумать над геймплейными элементами, понятностью и простотой геймплея для игрока.
Level 4.2. Рефакторинг игровых объектов.
Когда я обдумывал геймплейные фичи, я был заворожён огромным количеством возможностей. Судите сами, все объекты могут обладать четырьмя ортогональными характеристиками:
- Отражает ли пули или поглощает их?
- Уничтожим ли объект пулей?
- Подвижен ли или статичен?
- Каков тип объекта (игрок/враг/гражданский)?
Все эти характеристики можно скомбинировать и даже менять на лету. Но как показать это игроку? Сначала мой список объектов выглядел так:
- Обычные стены. Поглощают пули;
- Зеркальные стены. Отражают пули;
- Многоэтажные стены. Каждый этаж — обычный или зеркальный. При попадании пули нижний этаж уничтожается, верхние падают вниз. Так можно делать счётчики и т.д.;
- Ящики. Динамические, но неуничтожимые, поглощают пули;
- Зеркальные ящики. Динамические, но неуничтожимые, отражают пули.;
- Цыплята. Динамические, уничтожимые, игрок теряет очки при их гибели;
- Враги. Динамические, уничтожимые, нужно победить всех для прохождения уровня;
- Зеркальные враги. Обычные враги, но пуля, уничтожая врага, отражается;
- Кристаллы. Динамические, уничтожаются пулей, если игрок коснётся их, он получает бонус;
- Игрок.
Все доступные объекты.
У меня явно будут проблемы с понятной визуализацией всей этой красоты. Когда я начинал работать над low-poly версией, я планировал использовать простое цветовое кодирование:
- Цвет кромки определяет тип объекта;
- Белый цвет потолка обозначает статический объект (стену), тонированный в цвет кромки потолок — динамический.
Например, серая кромка означает, что объект поглотит пулю, фиолетовая — отразит. Но получается, что цвет кромки должен кодировать очень много свойств. Слишком сложно. Нахожу типы объектов с очевидными проблемами:
- Многоэтажные стены. Когда остаётся только один этаж — он не будет отличаться от обычной стены. Но он будет уничтожим. Или нет? Очень нелогичная фича;
- Зеркальные ящики. Пуля всегда движется с одной скоростью. Переотражаясь от ящиков, она будет их бесконечно и непредсказуемо ускорять;
- Зеркальные враги. Придётся использовать другой цвет, не похожий ни на "вражеский", ни на "зеркальный". Эта сущность только путает все карты.
Итого, остаётся:
- Два типа стен, обычные и зеркальные;
- Игрок;
- Цыплята;
- Враги;
- Кристаллы;
- Ящики.
Объекты, оставшиеся после чистки.
Всё хорошо кодируется цветом, объектов стало мало, но есть простор для левелдизайна.
Теперь, когда я определился с игровыми сущностями и почистил код от мусора, можно довести графику до финального состояния. Это и эффекты и физика, и всякие интерфейсные элементы. Короче говоря, работы много.
Todo: начать разработку релизных эффектов.
Level 5.1. Эффект гибели.
Точный, выверенный жест и ход сделан. Пуля летит, отражается от стены, проходит в считанных пикселях от игрока, задевает кромку другого отражателя и вновь меняет направление. Теперь она нацелена в последнего противника на карте. Ход закончен. Новый ход, в ожидании победы. Направление уже не важно: прошлая пуля сделает своё дело. Итак, законы геометрии неумолимы и снаряд находит свою цель. Пуля касается врага… И враг просто исчезает. Вот облом.
Да, хочется какого-то фана при попадании пули. Чтобы игра визуально говорила:
- "Да, чел, ты это сделал! Ты расфигачил его в чёртовой бабушке!"
Или наоборот: - "Аккуратнее, аккуратнее… Нееееет! Ты был так близко, а теперь придётся проходить все снова!"
Но сейчас при попадании пули элементы просто исчезают. Пробую сделать плавное погружение объектов сквозь пол. Выглядит медленно и неестественно, а ещё, непонятно, что объект уже уничтожен и с ним нельзя взаимодействовать.
Ладно, что требуется от эффекта гибели?
- Он должен быть ярким, ощутимым и значимым;
- Последствия гибели должны быть видны в течении всего уровня, помогая планировать перепрохождение, но не отвлекая.
Осколки! Пусть пуля разбивает противников на кусочки! Хм, а это не сложно? Неа, все объекты выпуклые, а резать выпуклые многоугольники — одно удовольствие.
На самом деле, просто разрезать противника пополам я не могу. Он состоит из нескольких мешей:
- Внутренняя часть, выпуклый многоугольник, тут все просто;
- Цветное кольцо. Оно мало того, что не выпуклое, да ещё и с дыркой. Но состоит из N (где N — количество сторон) выпуклых четырёхугольников. Так что просто сохраню их в массиве и разрежу каждый из них;
- Внешняя сторона. По сути, это внешняя сторона четырёхугольников из предыдущего пункта. Но я буду работать с ней как с большим выпуклым многоугольником — для рендеринга боковых граней и физики через PolygonCollider2D.
Части объекта, которые нужно разрезать отдельно.
В результате алгоритм получается такой:
- Преобразую объект (игрока, врага и т.д.) во внутреннюю часть, массив кусков кольца, внешнюю часть. В дальнейшем буду именовать эту структуру "Piece";
- Нахожу геометрический центр для Piece;
- Выбираю случайное направление и "провожу" прямую в этом направлении через геометрический центр;
- Разрезаю Piece этой прямой. Для этого беру каждый многоугольник из Piece (кольцо, внутренняя и внешняя части или их кусочки):
4.1. Создаю левый и правый массив точек;
4.2. Указываю текущий массив — левый;
4.3. Добавляю первую точку многоугольника в текущий массив;
4.4. Прохожу по всем оставшимся точкам;
4.5. Если текущая и предыдущая точки находятся с одной стороны прямой, добавляю текущую точку в текущий массив;
4.5. Если текущая и предыдущая точки находятся с разных сторон прямой, нахожу точку пересечения, добавляю её и в левый и в правый массивы. Переключаю текущий массив на противоположный. Добавляю в новый текущий массив текущую точку. - Добавляю все левые осколки в новый левый Piece, а правые — в правый;
- Снов разрезаю получившиеся осколки рекурсивно, до указанной глубины.
При каждом разрезании я делю объекты на две части, поэтому при трёх разрезаниях получается 8 осколков. Можно было бы немного "играть" с глубиной, но и так красиво.
Модифицирую код создания меша, коллайдера и тени многоугольника, чтобы он мог создавать ещё и осколки по заданным точкам.
Получаю примерно такие осколки.
Сначала я планировал сделать кешированные разбиения и использовать только их, но оказалось, что разбиение в реалтайме не вызывает тормозов, а выглядит эффектнее.
Осколки выявили неприятный баг с тенями: я неправильно искал силуэтные точки на многоугольниках. Именно из-за этого на предыдущем видео часть осколков отбрасывают тени лишь частично. Исправления уже доступны в предыдущей статье.
Итак, теперь при попадании пули в объект я подменяю последний на осколки. Объект убирается в пул, а осколки загружаются из пула и обновляют свои меши/коллайдеры согласно своей новой форме. Скорости для rigidBody рассчитываются исходя из скорости разрушенного элемента и направления пули. У осколков выключен флаг isBullet, они взаимодействуют только со стенами и друг с другом. У каждого осколка есть специальный класс FloorHider, он опускает объект по координате z сквозь пол, а после его полного исчезновения — удаляет (перемещает в пул.)
Небольшая ретроспектива:
- Осколки — очень "физически" понятный образ. Поэтому он помогает понять концепцию остановки времени. Осколки начинают разлетаться, тут время останавливается и всё замирает. Игра перестала выглядеть детерминированно пошаговой;
- Все осколки батчатся друг с другом и не тормозят;
- Кристалл сейчас удаляется при прикосновении без эффектов. Может, тоже разбивать на кусочки?
- Не хватает какого-то следа после уничтожений, хочется, чтобы на уровне оставались последствия атак;
- Фаново смотрится, хочется стрелять!
Осколки!
Разлетающиеся на куски враги — весьма эффектный штрих, но он подпорчен тем, что осколки исчезают без всяких следов. Есть и ещё несколько причин, кроме эффектности, почему мне бы эти следы добавить.
Todo: реализовать эффект следов от осколков.
Level 5.2.1. Эффект пятен.
Когда я думал над визуалом, у меня в голове крутилась идея "пятен крови", которые бы появлялись при гибели игрока или npc. На длинных уровнях такие пятна станут удобными метками для навигации. А в случае проигрыша помогут оценить начальное положение врагов и обдумать тактику повторного прохождения.
Итак, пятна. Вариант с декалями и отрисованными текстурами отбрасываю: мне кажется, так я выбьюсь из стиля. Пробую отобразить след от осколков или места их исчезновения. Смотрится плохо:
Разные варианты пятен.
Думаю над полноценной заливкой, среди вариантов прототипирую такой:
Просто создание кучи треугольников.
Да, этот вариант мне понравился больше. Но создавать множество треугольников с диким overdraw'ом — плохая идея. Впрочем, результат похож на мозаику, так что думаю в сторону диаграммы Вороного.
Вот только генерировать её на лету, особенно с последующей релаксацией Ллойда (релаксация делает ячейки схожими по размеру) на мобильных устройствах будет слишком больно. Нужен предрассчет. И отсюда очередная проблема: пятна могут быть на любом расстоянии друг от друга, и я, очевидно, не могу предрассчитать бесконечно большую диаграмму. Знаете, что такое тайлинг? :)
Для начала, нахожу подходящую библиотеку для генерации диаграммы Вороного.
Теперь разбираюсь с тайлингом. Построить диаграмму прямо на торе было бы идеальным решением, но лезть в дебри библиотеки или даже писать свою очень не хочется. Поэтому придумываю следующий алгоритм:
- Создаю N точек в квадрате с координатами {-0.5, -0.5, 0.5, 0.5};
- Для каждого Y в интервале [-tiles, tiles] и X в интервале [-tiles, tiles], кроме X = 0, Y = 0:
2.1. Копирую точки со смещением X, Y (при tiles = 1 получается 9 тайлов с моим начальным в центре); - Строю по всем точкам (включая смещённые клоны) диаграмму Вороного;
- Применяю при необходимости релаксацию Ллойда;
- Прохожу по всем получившимся полигонам и оставляю только те, у которых центр находится в исходном квадрате {-0.5, -0.5, 0.5, 0.5}.
В результате получается тайл с полигонами, у которого левая сторона идеально подходит к правой, верхняя — к нижней (с диагоналями — аналогично). На самом деле, не всё так гладко.
Идея в том, что диаграмма Вороного — очень локальная штука, поэтому можно эмулировать тор, сделав несколько копий исходных точек во все стороны. Но вот релаксация Ллойда уж точно локальной не является, и чем больше количество итераций, тем больше нужно делать копий (увеличивать значение tiles).
Да и координаты центров не всегда получается корректно проверить, все-таки, плавающая запятая. Поэтому иногда, очень редко, на краешке мозаики не хватает какого-нибудь элемента.
Найдёте повторения?
Итак, получается примерно такой кусочек мозаики:
Мозаика отрендерена на текстуре средствами библиотеки.
Делаю небольшой ScriptableObject, хранящий массив рассчитанных тайлов и редактор с большой кнопкой "recalculate tiles".
Проблемы с редкими дырами из-за float'а в проверках на попадание полигона в тайл я решил перегенерированием некорректных тайлов. Т.к. я делаю предрассчет один раз, руками в редакторе, могу себе такое позволить. :)
Теперь бы выводить эти тайлы на экран!
Todo: генерировать треугольники тайлов мозаики.
Level 5.2.2. Рендеринг тайлов.
Допустим, что пятно будет идеально круглое и мне известны его координаты и радиус. Нужно как-то получить все полигоны, которые находятся внутри этого пятна. Кроме того, пятно может появится в любых координатах, так что мозаику нужно "виртуально" тайлить.
Для проверки алгоритма создал вот такую "мозаику", с ней проще будет найти проблемы:
Поддельная мозаика
Допустим, у меня мозаика генерируется из 512 точек. Значит, на выходе получится 512 полигонов и проверять каждый на пересечение с окружностью — слишком дорого. Поэтому храню мозаику в виде небольших прямоугольных блоков:
Визуализация разделения на блоки.
Зная площадь мозаики и количество полигонов, можно получить оптимальное количество блоков, при котором скорость поиска будет максимальна.
Итак, логика поиска такая:
Дана окружность с координатами center и радиусом radius. Нужно найти все полигоны, попадающие в окружность.
- Считаем из 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);
- Проходим по каждому блоку от min до max;
- Отбрасываем блоки, не пересекающиеся с окружностью;
- Получаем реальные координаты блока внутри тайла:
int innerX = ((x + rows) % rows + rows) % rows; int innerY = ((y + rows) % rows + rows) % rows;
- Проходим по всем полигонам в блоке;
- Добавляем в список те полигоны, центры которых принадлежат окружности (с учётом смещения).
Итак, теперь можно одним запросом получить данные обо всех полигонах, попадающих в заданную окружность:
Запрос полигонов. Тестовая мозаика изменена на более наглядную
Все полигоны диаграммы Вороного выпуклы по определению, поэтому триангулировать их и добавить в меш — проще простого. Делаю первый тест рендеринга:
Выглядит, мягко говоря, скучно. Более того, если два пятна создаются с примерно на расстоянии одного тайла, например, {0, 0} и {1, 1}, бывают заметны повторения.
Спасибо любимой, она предложила хорошую модификацию этого алгоритма:
- Убрать релаксацию Ллойда, сделав полигоны более резкими;
- Заменить многоугольники на треугольники;
- Добавить больше случайности в расположение треугольников.
А теперь в картинках:
Пусть у нас есть вот такая мозаика:
Points = 500, Relax = 5
В ней бывают видны повторы, а ещё она очень однообразна.
Убираем релаксацию Ллойда:
Points = 500, Relax = 0
А теперь смешиваем все карты: считаем полигоном не многоугольник, сгенерированные в диаграмме Вороного, а треугольники, получаемые триангуляцией для рендеринга:
Triangles, Points = 500, Relax = 0
Теперь избавляемся от видимых паттернов. Полигоны, полученные из диаграммы Вороного, фактически, однонаправленны: нулевая вершина сверху, следующие вершины идут по часовой стрелке. Из-за этого чётко прослеживается "направление", как будто бумагу скомкали по диагонали и получилась мятая поверхность со складками, вытянутыми в одну сторону:
Выделены нулевые треугольники в каждом полигоне диаграммы.
Всё, что требуется — создавать треугольники начиная со случайной вершины полигона. Дополнительным плюсом становится то, что после триангуляции исчезают все повторы: в каждом тайле полигон триангулируется по-разному:
Паттерны перестали быть заметны.
Резюмирую: теперь у меня есть бесконечная мозаика, в которой не видно повторов. Я могу делать запрос на получение треугольников этой мозаики в определённом радиусе. Судя по всему, основа для пятен "крови" готова.
Todo: придумать конкретную геометрию пятен и замостить её мозаикой.
Заключение.
Проект развивается, и обрастает эффектами. Я отрезаю лишнее на уровне прототипа и пытаюсь упрощать всё донельзя — как геймплей, так и визуальную часть. Тем не менее, остается еще несколько эффектов (и их полировка), без которых игра смотрится незаконченной.
Итак, ещё несколько выводов:
- Процедурные эффекты рулят. Иногда классические алгоритмы (вроде диаграммы Вороного) не очень подходят, но после напильника и лобзика приобретают нужные качества;
- Закон Хофштадтера: Любое дело всегда длится дольше, чем ожидается, даже если учесть закон Хофштадтера. В общем, разработка проекта затягивается. :)
- Unity3D очень полезна, но иногда ставит палки в колёса. Если ваши кастомные постэффекты перестали работать — посмотрите во frame debug, может, после обновления Unity3D решила дополнить отрисовку своими эффектами (и включила на камере msaa).
В следующей статье я планирую закончить рассказ про рендеринг пятен, описать эффект следов, особенности редактора карт и загрузки уровней.
Спасибо за внимание, жду ваших комментариев и feedback'a!
Автор: Русанов Семен