Все заметки

Тесты, Typescript и переход на Three.js

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

Вряд ли это кто-нибудь замечал

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

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

Конфиг очень большой

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

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

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

Во-вторых, сейчас все что есть на сцене, передается на видеокарту, даже если пользователь этого не видит. Когда сцена маленькая, это особо не заметно. Но вот если мир будет намного больше, то придется огребать и играть в двух фпсах. Отбрасывание объектов, не попадающих в кадр – это одна из базовых оптимизаций. Называется Frustum Culling.

Ну и в-третьих, при движении камеры можно заметить, что между тайлами то и дело возникают какие-то стремные щели. Это следствия использования атласа с кучей текстур. Перед тем как кусочек атласа отобразится на экране координаты и размеры этого кусочка нужно перевести в координаты с которыми работает пиксельный шейдер. Это промежуток от -1 до 1 по обеим осям. То есть координаты неизбежно превращаются в дроби с длиннющей плавающей частью. Поэтому при отрисовки можно столкнуться с подобными шероховатостями. Существуют различные «абузы» для того чтобы эту проблему обойти. К примеру, к каждому изображению в атласе добавляется рамка в 1-2 пикселя, которая дублирует края изображения.

Артефакт отрисовки
Об этих линиях речь

Накидав проблем в «To Do» столбец на доске задач, я принялся за работу.

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

Разработка игр на движке подразумевает некоторый Data Driven Development: куча конфигов скармливается игровым системам, каждый объект это коробка с кучей компонентов, у каждого своя структура полей и в некоторых случаях она может быть довольно развесистой как в том же аниматоре. Так и хочется обмазать все это дело типами.

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

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

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

В итоге, около 60-ти процентов кода переведено на TypeScript и примерно 30 процентов покрыто тестами. Дел еще много, но уже жить стало намного спокойнее.

Статистика с количеством файлов на Typescript
Кажется гитхаб считает эту статистику не строками кода, а файлами, поэтому 77.8% – не совсем честная оценка

Уделили немного внимания тех долгу, теперь можно приниматься за рендеринг. Работа предстояла серьезная. Некоторые фичи из тех, что я хотел сделать намекали на то, что под отрисовку разных объектов нужны разные шейдеры, поэтому я начал думать о генераторе шейдеров, который будет конструировать код шейдера с учетом характеристик объекта. Рендерер же перед отрисовкой очередного объекта будет выбирать подходящий шейдер из набора.

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

Спрайт эффекта потемнел и посинел как и все остальное, чего быть не должно (не обращаем внимания на прилетевшую тычку)

На протяжении пары недель я размышлял как все это сделать, набрасывал планы, расписывал по пунктам что нужно сделать и когда я закончил, то открыл терминал и ввел “npm install three”.

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

Переход на Three.js был, можно сказать, безболезненным. В движке рендеринг является отдельной системой, которая никак не влияла на состояние игры и не взаимодействовала с другими модулями. Ну разве что с анимацией, да и то через компонент в который аниматор записывал номер очередного кадра. Можно убрать рендеринг из списка систем и игра продолжит работать. Правда играть так будет сложновато.

За отображение UI отвечает другая система, поэтому можно попытаться сопротивляться вслепую или просто наблюдать приближающуюся кончину

По сути, я поменял только реализацию этой системы, посадив ее на рельсы Three.js. Бесконечные бинды текстур и пробросы буферов через Webgl API заменились жонглированием высокоуровневыми абстрациями типа сцены, объектов, материлов, геометрии, камеры и тд. Данные берутся из тех же самых компонентов, где хранилась информация об отображении игровых объектов.

Разработка шла довольно бодро и отрисовать свою игру на Three.js я смог отрисовать за один-два вечера. Но еще много чего нужно было причесать и доработать, поэтому закончил я только через несколько недель.

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

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

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

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

При наступлении ночи видно, что по краям картинка темнее чем в центре

Помимо света Three.js предоставляет возможность настраивать и то, как объекты на этот свет реагируют. Делается это через разные типы материалов. Как раз то что мне было нужно для сохранения насыщенности спрайтов эффектов по ночам.

Эффект отображается так как был нарисован. Ни цветовая гамма не интенсивность глобального освещения на него больше не влияют

Frustum Culling о котором я говорил выше, есть в Three.js из коробки и для его активации даже делать ничего не нужно. Все происходит под капотом библиотеки.

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

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

Скриншот замера производительности
Рендеринг на Three.js со всеми новыми фичами вроде света занимает где-то пятую часть от общей нагрузки

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

Подумав на досуге пару дней о том, как можно анимировать сразу несколько слоев объекта через один конфиг, мне пришла идея что никакие слои не нужны и возможно стоит просто дать аниматору чуть больше свободы. Сейчас аниматор позволяет управлять номером кадра, менять его угол поворота, отключать отображение и тому подобное. То есть он может влиять только на один компонент, который описывает отображение. Но что если он будет иметь доступ вообще к любому компоненту объекта и даже больше – будет влиять на компоненты дочерних объектов? Сейчас кадр – это объект, описывающий параметры для конкретного компонента, но это может быть объект, где ключ – это путь до поля любого компонента на любом уровне вложенности. Через редактор анимаций прописать эти пути не составит труда.

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

Так получилось, что путь от постановки задачи до ее реализации занял у меня где-то 5 месяцев. На пути было много рефакторинга, доработок и в довесок – изучение новой библиотеки. А решение в итоге не потребовало ничего из этого. Все что было нужно – доработать аниматор.