Предлагаю вашему вниманию перевод доклада Роберта Конрада с прошедшего в октябре прошлого года HaxeUp Sessions 2019 Linz. Данный доклад посвящен процессу портирования на консоли игры CrossCode, изначально написанной на HTML5. Если вам понравится перевод, то рекомендую также посмотреть его в оригинале на английском — Роберт очень веселый человек, постоянно шутит и его интересно слушать.
Немного об авторе: Роберт работает программистом в Deck13, одной из немногих игровых компаний, использующих свои собственные технологии (именно поэтому Роберт и выбрал эту компанию). В основном его работа связана с оптимизацией (например, недавно выпущенной игры Surge 2).
До этого он преподавал в Дармштадтском техническом университете (Германия), самостоятельно разрабатывал небольшие видеоигры, а также работал в небольшой видеоигровой компании.
Кроме того он часто проводит лекции в университетах и выступает на конференциях.
Kha, Kinc и Krom — основные проекты Роба. Это низкоуровневые мультимедийные фреймворки для геймдева. Их можно сравнить с SDL, но с дополнительными возможностями для кросс-платформенной работы с графикой и кросс-компиляцией (как кода игровой логики, так и графических шейдеров).
Kinc написан на C/C++. Kha — на Haxe и использует Kinc для работы на нативных платформах (Windows, Linux, Mac OS, Android, iOS, Nintendo Switch и т.д.), кроме того, Kha работает и в вебе (WebGL и Canvas).
А Krom — это среда исполнения JavaScript, но без поддержки Web-API. Вместо них имеются мультимедийные API, предоставляемые Kinc, а JS используется как скриптовый язык для быстрой работы с графикой, звуком и т.д. без необходимости компиляции. Krom работает на движке Chakra от Microsoft и в настоящее время используется в основном для отладки приложений на Kha.
Наиболее популярные проекты, использующие Kha — это Armory3D и ArmorPaint:
- Armory3D — игровой движок, использующий Blender в качестве редактора
- ArmorPaint — отдельный инструмент для рисования текстур. В настоящее время для него планируется добавить интеграцию с Unreal Engine и Unity. Кстати буквально вчера стало известно, что ArmorPaint получил Epic MegaGrant.
RPG Playground — еще один довольно популярный проект, использующий Kha. Его можно назвать RPG Maker’ом для веба.
Таким образом, Kha / Kinc / Krom позволяют создавать как простые браузерные 2D-игры, так и крупные 3D-проекты.
Но давайте перейдем к основной теме доклада — игре CrossCode:
CrossCode — это Action RPG, получившая довольно высокие оценки. Кроме того, это довольно большая игра — прохождение основной кампании занимает около 30 часов, а выполнение дополнительных заданий может занять еще 20 часов.
CrossCode доступна на Windows, MacOS и Linux и должна в скором времени выйти на PlayStation 4, Xbox One и Nintendo Switch. Но так как CrossCode изначально создавалась как HTML5-игра, то перед Робертом встала задача портировать ее на эти консоли.
Что такое “HTML5-игра” в контексте CrossCode?
- игра полностью написана на JavaScript
- в ней используются Canvas 2d API для отрисовки графики и WebAudio API для работы со звуком
Давайте “пойдем по стеку” и зададимся вопросом “Что такое JavaScript?”: если вы ничего не знаете о нем, то можете предположить, что это скриптовый язык, имеющий что-то общее с Java :)
Что же, пойдем дальше по этой ветке (но рано или поздно мы выберемся из стека, обещаю)...
А что такое Java? Java — это объектно-ориентированный язык программирования.
Так, а что такое объектно-ориентированное программирование?
Есть два основных языка, сформировавших современные представления о том, что является объектно-ориентированным программированием — это Simula и Smalltalk.
Simula — это язык, в котором были введены понятия класса, наследования, производного класса, виртуальных процедур.
Если говорить об этих особенностях Simula, то их довольно просто реализовать (как это сделал Бьёрн Страуструп при создании C++). Например, наследование можно реализовать с помощью вложенных структур, а виртуальные процедуры — с помощью указателей на функции. Таким образом, с технической точки зрения их можно считать не более чем синтаксическим сахаром, построенным поверх процедурного языка программирования, с системой некоторых ограничений.
А вот со Smalltalk все сложнее. Да, он позаимствовал из Simula модель классов, но при этом:
- в Smalltalk все доступно для изменения прямо во время исполнения программы, и для этого не требуется перекомпиляция и перезапуск программы
- Smalltalk является пионером в области JIT-компиляции
- Smalltalk — динамически типизированный язык
Данные особенности трудно реализуемы в процедурных языках программирования.
Из Smalltalk в свое время родился язык Self — также объектно-ориентированный, но в отличие от Smalltalk он основан не на классах, а на прототипах:
- для создания нового типа вы создаете новый объект «с нуля», добавляя к нему необходимые свойства
- а наследование осуществляется путем клонирования существующего объекта.
Если хотите узнать о Self немного больше, то рекомендую посмотреть небольшой фильм, посвященный данному языку программирования.
Разобравшись немного с ООП, вернемся к Java (т.к. теперь мы сможем понять, чем является данный язык):
Как язык программирования Java можно считать подобной Simula — язык также основан на классах. C технической точки зрения Java несет в себе немного и от Smalltalk: JIT-компиляция и автоматическое управление памятью в форме сборки мусора (garbage collection). Но эти технические особенности мало влияют на Java, как на язык программирования.
И, наконец, мы вернулись к JavaScript:
- JavaScript — это динамически типизированный (подобно Smalltalk) язык программирования, изначально созданный для написания сценариев, исполняемых на стороне клиента
- все числа в JavaScript — это числа с плавающей точкой двойной точности. Но с точки зрения производительности это не так уж и плохо, как это традиционно принято считать — на современных процессорах скорость работы с числами единичной точности незначительно превышает скорость работы с числами двойной точности (конечно, до тех пор, пока вы не начнете использовать SIMD)
- JavaScript довольно своеобразный язык. Например, для объявления переменных можно использовать
var
иlet
, для сравнения объектов есть операторы==
и===
. И как не упомянуть о системе преобразований и арифметике в JavaScript — не каждый сразу скажет, что получится в результате операции:[] + ""
. Такие особенности языка становятся критичными для крупного проекта, где по той или иной причине (случайно или намеренно) могут использоваться данные особенности. И в CrossCode такой код присутствует, что усложняет его портирование. - JavaScript использует ту же объектную модель что и Self (ООП основанное на прототипах). Подобно Smalltalk в JavaScript вы можете переопределять любые свойства у объектов прямо во время исполнения программы. Заставить работать такой быстро — также довольно сложная задача.
И так как задача Роберта — портировать CrossCode на консоли, то поговорим немного о них:
- современные консоли с точки зрения железа являются по сути ПК. Однако благодаря тому, что используемое в консолях железо заранее известно, у нас есть гораздо больше возможностей для оптимизации
- для того, чтобы разрабатывать игры для консолей, вам необходимо использовать специализированный программный стек, предоставляемый держателем платформы. Для каждой из консолей он свой. И для того, чтобы получить к нему доступ, необходимо стать сертифицированным разработчиком и подписать соглашение о неразглашении. Поэтому разработка для консолей во многом покрыта тайной.
ПК-версия CrossCode вышла в сентябре 2018 года, и издатель Deck13 Spotlight искал разработчика, который смог бы портировать игру на консоли. Этим человеком оказался Роберт. Сначала казалось, что все пойдет как по маслу — игра не выглядела слишком сложной с технической точки зрения — двухмерная графика с видом сверху. Роберт надеялся, что сможет заниматься им как второстепенным проектом. Однако, все пошло не так.
Первоначальная оценка сложности проекта:
- двухмерная игра
- пиксельарт
- с технической точки зрения используемые графические эффекты просты: масштабирование и режимы смешивания
- в качестве движка используется Impact
Impact — это игровой движок для создания 2D-игр на HTML5. Его разработка началась еще в 2010 году, так что его можно считать одним из первых. Долгое время Impact был платным, и только в 2018 году стал бесплатным и доступным по лицензии MIT.
За выходные Роберту удалось портировать одну из демок с Impact на Kha, это оказалось довольно просто. На основании этого небольшого эксперимента был составлен общий план портирования CrossCode на консоли — рассматривались 3 возможных варианта развития событий (от простого к сложному):
- использовать интерпретатор JavaScript, что в теории позволило бы использовать код игры напрямую. Дополнительно для этого пришлось бы дополнительно реализовать Canvas и WebAudio API. И если скорость работы этого решения оказалась бы удовлетворительной, то процесс можно будет считать практически завершенным
- портировать на консоли один из JIT-компиляторов для JavaScript (например, V8), что в теории позволит ускорить исполнение JavaScript
- если же ни один из предыдущих вариантов не окажется достаточно быстрым, то потребуется полностью переписать код игры с JavaScript
Роберт приступил к работе по первому варианту:
- портировал Kinc на современные консоли (до этого Kinc работал только на старых — Xbox 360 и Playstation 3)
- в качестве интерпретатора был выбран Duktape — довольно быстрый и легко интегрируемый интерпретатор JavaScript
- реализовал Canvas и WebAudio API — для этого уже были некоторые наработки (свой рендерер и звуковой миксер), поэтому никаких сложностей не возникло.
В итоге запустить игру на PlayStation 4 удалось довольно быстро.
Начальная секция игры работала быстро на мощном ПК, но недостаточно быстро на PlayStation. Чтобы ускорить игру Роберт заменил некоторые части, написанные на JavaScript, переписав их на C++, но этого все равно было недостаточно. Дальнейшие оптимизации давались все сложнее, т.к. основная работа выполнялась на стороне JavaScript, а для интерпретатора JavaScript не было профайлера. Поэтому попытки ускорить игру фактически осуществлялись наугад.
В итоге Роберт отказался от решения с интерпретатором и перешел ко второму варианту — использованию JIT-компилятора JavaScript:
Роберт уже был знаком с V8 — он пробовал его, когда выбирал движок для Krom, но отказался от его использования, т.к. посчитал его избыточным. По этой же причине он не стал использовать V8 и для CrossCode.
Поэтому Роберт начал смотреть в сторону JavaScriptCore — это тоже довольно крупный проект, но для него есть порт, входящий в состав EA Webkit — у него открытый исходный код, он уже работает на современных консолях и используется в играх от Electronic Arts. Однако EA Webkit оставил ощущение того, что проект был открыт скорее из-за того, что в нем используется лицензия GPL, а не потому что в Electronic Arts хотели сделать его общедоступным, кроме того, даже компиляция проекта оказалась непростой задачей.
В итоге Роберт остановился на самом неочевидном варианте — Chakra — JIT-компилятор от Microsoft. По опыту Роберта работать с ним оказалось проще всего, он неплохо показывал себя в сравнительных тестах. Однако дальнейшая судьба Chakra не ясна, т.к. в Microsoft приняли решение перевести свой браузер Edge на Chromium, проект пока что продолжает поддерживаться, но неизвестно сколько это продлится. Но в контексте CrossCode Роберт посчитал, что и в текущем состоянии Chakra будет достаточно.
Роберту удалось скомпилировать Chakra для одной из консолей (для какой именно — секрет).
Затем удалось завести JIT-компилятор — он начал компилировать код, выделять для него память и загружать в нее сгенерированный машинный код.
Следующим шагом был переход к выполнению сгенерированного кода. Что произошло дальше — тайна, покрытая NDA.
Однако, чтобы вы могли примерно догадаться, Роберт привел аналогию из мира iOS. Что бы произошло, если бы вы попытались портировать свой JIT-компилятор под iOS?
Вам бы удалось:
- скомпилировать проект
- сгенерировать машинный код
- перейти к его исполнению
- но затем iOS закроет ваше приложение. Потому что в iOS не допускается выполнять код из перезаписываемой области памяти (writable memory). По этой же причине iOS-версии Firefox и Chrome используют Webkit в качестве движка, а не свои собственные.
На основании этого вы можете попробовать догадаться, как это соотносится с разработкой под консоли, но никто вам не скажет точно является ли это истиной.
В итоге по какой-то “неизвестной причине” Роберту пришлось отказаться от идеи использования JIT-компилятора JavaScript. Принять это решение было непросто, т.к. уже была проделана довольно большая работа.
Поэтому пришлось перейти к работе по третьему сценарию — полностью переписать игру.
К этому моменту у Роберта уже сформировалось понимание того, что CrossCode — это очень большой проект. Именно поэтому Роберт считал данный сценарий невыполнимым: игра слишком большая, чтобы завершить проект в разумные сроки, кроме того для игры до сих пор выходят обновления, и Роберту пришлось бы также постоянно портировать и эти обновления.
И хотя у нас есть успешные примеры смены технологии, например, Minecraft, но над Minecraft работала команда. В одиночку такого не провернуть.
Роберт начал искать другой путь. И начал он с попытки создания Ahead-of-Time (AOT) компилятора для JavaScript. Проблема была только в том, что никто до этого не достиг существенных успехов в этом начинании.
С некоторыми ухищрениями Роберту удалось запустить игру в таком режиме — показывался начальный экран с логотипом и главным героем. Но затем начались буквально “темные дни”.
Позднее осознание масштабов задачи:
При запуске CrossCode выполняет большое количество работы — игра загружает ~1 GB json-файлов и подготавливает все данные. Все это происходит до того, как вы просто увидите начальный экран игры. Именно поэтому эти дни были “темными” — все, что Роберт видел в течение нескольких месяцев на своем мониторе — это черное окно игры. И в течение всего этого времени он не мог показать визуального прогресса в работе над портом.
Другая проблема — скорость работы игры. В самых “тяжелых” сценах игре требовалось ~10 мс на отрисовку кадра (и это на мощном ПК с процессором с частотой ядра 3.4 ГГц). Проблема усугубляется тем, что приложения на JavaScript не могут одновременно использовать несколько ядер (если, конечно, вы не используете WebWorker API, а в CrossCode это API не используется). Поэтому на консолях, где частоты процессоров существенно ниже, задача выдать стабильные 60 фпс, используя тот же самый код, работающий в одном потоке, выглядит мало выполнимой: вы не можете использовать JIT-компилятор, но при этом от вас требуется, чтобы ваш JavaScript-код работал быстрее, чем в V8 (а над V8 работал не один человек, и не один год).
Роберт признался, что он сразу бы отказался от работы над портированием проекта, если бы знал об ожидающих его проблемах.
Масштабы игры также добавляют сложностей — всегда есть вероятность того, что код на выходе из вашего самописного AOT-компилятора может содержать ошибки, при этом ошибки могут проявляться не сразу, а спустя часы после начала игровой кампании. И так как прохождение только основного сюжета занимает порядка 30 часов, то тестирование и выявление таких потенциальных ошибок становится реально непростым занятием.
И по опыту Роберта для крупных проектов, в которых все взаимосвязано, действует закон Мёрфи. Даже если вы пишете идеальный код, всегда есть вероятность того, что в нем возникнет ошибка. И эта вероятность растет по мере роста проекта и его сложности.
Что потребуется для работы нашего AOT-компилятора и какие особенности JavaScript он должен поддерживать?
Естественно нам понадобится сборщик мусора, ведь мы имеем дело с JavaScript.
Работа сборщика мусора особенно критична в случае, когда мы говорим об играх. Ведь мы хотим, чтобы код работал плавно, стабильно выдавая 60 фпс. Тогда для каждого кадра у нас есть ограниченный “бюджет” в 16,6 мс, часть из которого неизбежно будет забирать сборщик мусора — в те моменты, когда запускается сборка мусора, мы будем видеть скачки в плавности работы нашей игры. И на устройствах с относительно слабыми процессорами (например, в консолях), эти скачки будут заметны сильнее.
Когда вы пишете код на языке со сборкой мусора, вы неизбежно будете генерировать мусор, так уж устроены эти языки. Кроме того, некоторые конструкции таких языков сами по себе могут генерировать мусор. Именно поэтому нам нужен быстрый и надежный сборщик мусора.
Нам также понадобится поддержка замыканий, которые сами по себе могут генерировать мусор — для того, чтобы “захватить” переменную a
, потребуется выделить под нее память в динамической области памяти (и затем освободить эту память с помощью сборщика мусора).
Необходима поддержка работы с объектами в стиле JavaScript, когда к свойствам объекта можно обращаться не только с помощью оператора .
, но и как к значениям в ассоциативном массиве, то есть с помощью квадратных скобок.
И, как мы помним, расширение объектов в JavaScript (как и в Self) основано на прототипах: поэтому если искомое свойство не найдено в самом объекте, то его необходимо искать в его прототипе, а если оно не найдено в прототипе, то будем искать в прототипе прототипа и т.д.
Это приводит нас к вопросу типизации кода и ее влиянии на скорость работы программ. Рассмотрим данный вопрос на примере простого обращения к свойству объекта:
xx = b.x;
, где x
— это число.
В Си это выражение будет преобразовано в 1 или 2 машинные инструкции, и в таком коде будет возможен только 1 “кэш-промах” (cache miss).
В JavaScript все будет намного-намного сложнее:
- сначала мы будем искать свойство в самом объекте
- если поиск завершится неудачей, то потребуется искать его выше по цепочке прототипов до тех пор пока свойство не будет найдено
- после того, как свойство найдено, нам нужно поместить его значение в переменную
хх
. При это сборщик мусора должен будет удалить предыдущее значение этой переменной. Плюс под новое значение потребуется выделить новую память.
Естественно, что такой код может работать в сотни раз медленнее аналогичного C-кода (если только вы не знаете все типы заранее, тогда этот код можно будет оптимизировать до уровня C-кода).
Так как перед Робертом стоит только одна задача — портировать CrossCode на консоли, то создаваемый JavaScript-компилятор:
- должен гарантированно работать только с кодом CrossCode. Он не является инструментом общего назначения
- используемые оптимизации кода заточены только для кода CrossCode. Поэтому при попытке скомпилировать с его помощью любой другой код на выходе может получиться очень медленное приложение.
И не стоит забывать про ограниченные временные рамки — фанаты ждут версию для Nintendo Switch (в сообществе CrossCode это стало мемом — каждый раз, когда разработчики публикуют какую-нибудь новость в соц.сетях, то одним из первых комментариев является “Switch when?”)!
Давайте рассмотрим существующие решения в области типизации кода для JavaScript и создания AOT-компиляторов:
Flow от Facebook может осуществлять статический анализ типов и поддерживает аннотации типов (поэтому в случае, когда не удается автоматически вывести используемый тип, можно явно указать его).
TypeScript — аналогичный инструмент, работающий с расширенной версией JavaScript.
Есть относительно недавнее интересное исследование “AOT компиляция JavaScript”, авторы которого заявляют, что им удалось создать работающий AOT-компилятор. К сожалению самого компилятора нет в открытом доступе, но принцип его работы известен (хотя он оказался недостаточно хорош для целей Роберта) — компилятор генерирует две версии кода:
- в первой версии осуществляется попытка подобрать наиболее подходящие типы для переменных
- во второй версии код не типизируется и работает крайне медленно, но работает. Эта версия используется как запасной вариант.
Авторы исследования заявляют, что полученный на выходе код работает примерно в 2 раза медленнее кода в больших JS-движках (по мнению Роберта такие заявления несут мало смысла, т.к. из них не ясны причины, почему этот код работает медленнее, будет ли код на выходе всегда медленнее и т.д.).
Роберт также нашел диссертацию Пола Биггара, посвященную разработке AOT-компилятора для PHP — данная работа может оказаться полезной, если вы решите написать свой компилятор.
Но не пытайтесь задавать вопросы в Интернете — вы можете оказаться в ситуации с бесконечной рекурсией:
На Реддите есть пост, посвященный анонсу версии CrossCode для консолей. Кто-то в комментариях к нему высказался о том, что уже существуют решения для конвертации JavaScript в C++. На вопрос Роберта о том, где можно узнать об этом поподробнее, он получил совет обратиться к разработчикам CrossCode :)
Роберт также нашел проект NectarJS — AOT-компилятор для JavaScript (и не только). По заявлению его автора сгенерированный на выходе код может работать быстрее скомпилированного кода на Си. При этом в качестве доказательства он приводит пример кода для вычисления числа Фибоначчи. Это довольно специфичный пример, т.к. в нем заранее известны все типы на входе и выходе из функций, в нем не осуществляется работа с JavaScript-объектами, поэтому этот код легко оптимизируется. Так что по мнению Роберта это пример, который на самом деле ничего не доказывает.
В итоге Роберт решил использовать Haxe.
И вот почему:
- с большой натяжкой Haxe можно считать диалектом JavaScript. Цепочка рассуждений, приводящих к этому выводу, такова: Haxe в свое время родился как замена ActionScript 3; ActionScript 3 — это язык, основанный на стандарте ECMAScript 4; а ECMAScript 4 — это старый стандарт для JavaScript
- в Haxe есть замыкания и система выведения типов (не такая как в JavaScript, но это все равно лучше, чем никакая). Стандартные библиотеки языков имеют много общего. Поэтому можно предположить, что за счет такого сходства задача портирования проекта должна несколько упроститься
- Haxe компилируется в C++ (как раз то, что нужно для поддержки консолей), при этом для “плюсов” реализован довольно неплохой сборщик мусора (таким образом, не нужно тратить времени на его реализацию)
- и Роберт уже имел опыт работы с Haxe
Тогда все что остается — это задача по преобразованию JavaScript-кода в Haxe. Но она, к сожалению, усложняется за счет того, что:
- Haxe основан на классах, а не прототипах
- в Haxe не поддерживается загрузка кода во время исполнения, а в JavaScript такая возможность используется постоянно.
Но давайте рассмотрим поближе код движка Impact и то, как в нем используются прототипы: в Impact объявляется объект ig.Class
, который реализует собственную систему классов (как в Java :) ) — довольно распространенный прием в мире JavaScript (в современном JavaScript также появилась поддержка классов, которая делает то же самое, но уже с поддержкой на уровне самого языка).
Можно считать, что Роберту повезло, что прототипы используются именно таким образом, т.к. это означает, что достаточно парсить только вызовы к объекту ig.Class
и преобразовывать их в классы Haxe. Проблему с прототипами для случая CrossCode можно считать решенной! И все благодаря тому, что JavaScript-программисты используют этот язык как Java.
Проблема с загрузкой кода также значительно упрощается, если реализовывать ее только для случаев, используемых в движке Impact:
- для загрузки модуля создается script-элемент, которому назначается свойство
src
с путем к загружаемому скрипту - для этого элемента назначается слушатель события загрузки скрипта
- затем созданный элемент добавляется в дерево DOM, и тем самым запускается процесс загрузки скрипта.
Компилятор Роберта находит подобные вызовы, осуществляет анализ зависимостей между загружаемыми модулями игры и загружает и инициализирует их в нужном порядке (т.к. порядок загрузки модулей имеет значение — модули, зависящие от других модулей, не могут работать до тех пор, пока их зависимости не будут загружены).
Хотя в Haxe и есть замыкания, но их работа несколько отличается от замыканий в JavaScript — в JavaScript указатель на this
не захватывается автоматически, для этого необходимо использовать встроенный метод bind
.
Поэтому в некоторых случаях в Haxe-функциях (аналогах JavaScript-функций) пришлось добавить дополнительный параметр.
Но сходство Haxe с JavaScript обманчиво, и попытки подменить один язык другим без учета их особенностей ведут к появлению неожиданных багов.
Например, Роберт попытался использовать абстрактные типы Haxe для имитации преобразований типов в JavaScript, но столкнулся с тем, что преобразования абстрактных типов работают иначе (и в результате преобразований получались не те типы). В итоге он отказался от их использования.
Еще одна проблема, связанная с отличиями Haxe и JavaScript — это работа с числами. Как уже упоминалось, в JavaScript все числа — это 64-битные числа с плавающей точкой. При этом и в Haxe и в JavaScript есть математическая библиотека, включающая в себя такие функции как Math.round/ceil/floor
. Но в Haxe эти функции возвращают целые числа (что вполне логично), а целые числа в Haxe — 32-битные. И это означает, что используя их, вы теряете точность. Если не знать об этом, то можно нарваться на очень интересные проблемы. Тут Роберту опять относительно повезло, т.к. он наткнулся на эту проблему в одной из сцен в игре, когда на экране отображалось такое большое число, и в какой-то момент (когда это число стало слишком большим, чтобы поместиться в 32 бита) игра попросту зависла. Вот такой визуальный баг-репорт.
Обойти эту проблему оказалось просто, т.к. в Haxe есть функции Math.fround/fceil/ffloor
, возвращающие числа с плавающей точкой.
Вот еще одно отличие: и в Haxe и в JavaScript вы можете объявить литерал объекта, но их отличие в том, что если в JavaScript (начиная со стандарта ECMAScript 2015) проитерировать по всем полям такого объекта, то порядок свойств будет таким же, как и при объявлении объекта. А в Haxe порядок свойств при итерировании по ним не определен. Это отличие Роберт также обнаружил не сразу и ему пришлось поправить это поведение на стороне Haxe.
И раз уж мы упомянули неопределенное поведение, то стоит сказать, в Haxe не все строго определено. Это обусловлено тем, что Haxe может компилироваться под совершенно разные платформы, у каждой из которых свое поведение, и если пытаться поддерживать одинаковое поведение на всех платформах, то это неизбежно ударит по скорости работы кода на выходе, поэтому такая неопределенность является компромиссом между скоростью и универсальностью языка.
Учитывая размер проекта, Роберту требовалось одинаковое поведение. Поэтому порт CrossCode собирается только под hxcpp. В начале Роберт пробовал компилировать полученный Haxe-код в JavaScript, надеясь, что это ускорит разработку, но из-за различий в поведении ему пришлось отказаться от этой идеи. По мнению Роберта для данного проекта это не является существенной проблемой, но вам все же стоит знать о том, что такие отличия существуют.
В качестве альтернативы hxcpp (hxcpp — библиотека для поддержки C++ в Haxe) Роберт также рассматривал возможность использования HL/C, который конвертирует байт-код виртуальной машины HashLink в C-код. Код, полученный с помощью HL/C, компилируется быстрее, однако hxcpp-рантайм обеспечивает лучшую производительность. По этой причине Роберт выбрал hxcpp — для CrossCode скорость выполнения кода важнее скорости его компиляции.
В Deck13 для ускорения компиляции проектов используется IncrediBuild — пакет программ, позволяющих распределить выполнение “тяжелых” задач (например, компиляции C++ кода) между компьютерами, объединенными в сеть.
Однако при работе из дома Роберту пришлось найти другое “решение” данной проблемы — им оказались старые point-and-click адвенчуры :) Они идеально подходят для того, чтобы скоротать время в ожидании, когда завершиться компиляция проекта: такие игры можно запустить в окне рядом с окном компилятора, их можно поставить на паузу в любое время, и от них можно легко отвлечься и перейти к отладке проекта.
Теперь давайте рассмотрим улучшения в Kha и hxcpp, появившиеся благодаря работе над CrossCode:
Во-первых, была улучшена поддержка работы со звуком в Kha. В Kha реализован свой звуковой микшер (audio mixer), и реализован он на Haxe. Благодаря этому пользователи могут легко вносить изменения в его работу. Но по этой же причине, на его работу влияет сборщик мусора: когда hxcpp запускает процесс сборки мусора, происходит остановка всех потоков, выполняется сборка мусора, и затем выполнение потоков продолжается. При работе со звуком такие кратковременные паузы особенно критичны — при воспроизведении звуковых эффектов могут возникать “заикания”.
Но теперь, благодаря помощи со стороны Хью Сандерсона (автора hxcpp), код звукового микшера в Kha может работать независимо от сборщика мусора.
Во-вторых, удалось найти и исправить несколько ошибок в hxcpp, связанных с поддержкой Юникод.
Полноценная поддержка Юникод появилась в Haxe 4, и до недавнего времени она была отключена в hxcpp по-умолчанию. Протестировать поддержку Юникод в hxcpp помогло сообщество пользователей Kha — Роберт включил ее в форке hxcpp, распространяемом вместе с Kha.
В-третьих, удалось исправить ошибки в работе сборщика мусора, основанном на поколениях (generational garbage collector). По-умолчанию в hxcpp использовался не такой быстрый алгоритм сборки мусора, но при этом его реализация была хорошо оттестирована. Включив в hxcpp новый сборщик мусора, Роберту удалось избавиться от “подвисаний” игры, связанных с работой сборщика мусора, но спустя примерно 30 секунд игра “упала”.
С помощью Хью Сандерсона удалось исправить ошибки в работе сборщика мусора: кое-что Роберт смог поправить самостоятельно, другие проблемы удалось изолировать и тогда Роберт обращался к Хью с примерами, в которых найденные проблемы повторялись.
Кроме того, Роберт снова воспользовался помощью сообщества Kha, включив generational garbage collector по-умолчанию и предупредив пользователей о возможных проблемах. К счастью, к этому моменту все критические проблемы уже были исправлены — сборщик мусора теперь работал стабильно, и сон у Роберта стал крепче :)
Следующую часть доклада Роберт посвятил тому, как его компилятор выводит типы переменных:
Изначально типы всех переменных неизвестны — им присваивается тип Unknown
.
Затем компилятор может определить типы данных:
- встретив переменную со значением
1
, он “поймет”, что перед ним число и присвоит переменной типDouble
(как уже упоминалось, в JavaScript все числа имеют двойную степень точности, а также нет целочисленных типов) - аналогично работает выведение типов
Boolean
иArray
(компилятор присвоит переменной типArray
, если увидит, что в коде создается массив[]
, который присваивается этой переменной в качестве ее значения) - к сожалению определение типов для объектов происходит несколько сложнее. Созданный компилятор парсит систему классов, используемую в Impact, таким образом он получает информацию о пользовательских типах. Встретив переменную, которой присваивается значение одного из таких типов, компилятор присваивает ей соответствующий тип. Однако если в дальнейшем компилятор встретит код, обращающийся к несуществующему в данном объекте свойству, то ему будет присвоен “табличный” (map-like) тип. Код, использующий “табличные” типы, работает медленнее, поэтому такие ситуации нежелательны, но в случае данного проекта неизбежны
Остальные типы:
String
— строки с поддержкой Юникод- классы, которые могут передаваться и использоваться впоследствии для создания объектов
- множественный тип
Multiple
. Данный тип используется в случаях, когда компилятор находит, что переменной, для которой уже был определен тип, присваивается значение другого типа. С типомMultiple
могут выполняться любые операции, но как можно догадаться, код, использующий такие переменные, работает крайне медленно (поэтому если всем переменным в проекте присвоить типMultiple
, то полученный код будет работать, но будет делать это словно в замедленной съемке). - тип
Nothing
используется в основном для типизации возвращаемых типов - тип
Function
используется для определения типа “функция” (т.к. в JavaScript функции можно передавать в качестве аргументов другим функциям и присваивать их в качестве значений переменным) - тип
Undecidable
— это своего рода “главный враг” (boss enemy) от мира типизации — самый сложный тип. Он используется в случаях, когда у компилятора нет никакого способа определить тип переменной — обычно такое происходит при парсинге JSON (данные для парсинга получаются из внешнего источника, недоступного компилятору, поэтому компилятор ничего не знает о результате парсинга).Undecidable
-переменные работают аналогичноMultiple
-переменным, но с некоторыми нюансами, связанными с дальнейшим выведением типов.
Рассмотрим подробнее выведение типов в компиляторе Роберта:
Компилятор проходит по всему коду проекта, и когда один тип “встречает” другой, компилятору нужно выполнить кучу разных вещей.
Самый простой вариант дальнейших действий компилятора — это “выравнивание типов”. Пример такого сценария — обращение к полю объекта. Встретив в коде выражение типа a.x
, компилятор сначала определит тип объекта a
(экземпляром какого класса является объект a
), таким образом компилятор сможет определить тип поля x
данного объекта. В идеале тип поля x
и тип присваиваемого ему значения должны совпадать. Если же выведенные типы не совпадают, то компилятору необходимо “выровнять” их, сделав их совместимыми. Для этого тип поля x
должен быть “повышен” до совместимого типа. Например, Boolean
может быть повышен до Double
и даже до Object
, т.к. для JavaScript такие преобразования валидны. Но в то же время Double
не может быть повышен до Object
— для такого случая тип поля будет заменен на Multiple
.
Более сложный сценарий выведения типов в компиляторе — коллизии типов (type clash), которые могут происходить:
- при присвоении переменной значения другой переменной,
- а также при передаче переменной в качестве аргумента функции.
В таких ситуациях переменная a
должна иметь возможность хранить значения того типа, который уже был ей присвоен, плюс значения типа переменной b
. Тип переменной b
при этом обычно не изменяется, но тип переменной a
обычно “повышается”.
Также компилятор обрабатывает сценарий “мягких” коллизий типов (soft type clash), которые могу возникать при сравнении двух переменных — в JavaScript у вас есть возможность сравнить переменную, хранящую численное значение, и null
или undefined
. В таких случаях выведенный тип переменной повышается до Multiple
.
Процесс выведения типов переменных разделен на три фазы, каждая из которых происходит не за один проход, а в цикле — до тех пор, пока компилятор не найдет новых типов.
Основная цель каждой из фаз — свести к минимуму число переменных с типом Undecidable
, т.к. они сильнее всего влияют на скорость работы программы.
На начальной фазе выполняется выведение типов без возможности присвоения Undecidable
-типа.
На второй фазе типизация выполняется уже с возможностью присвоения Undecidable
-типа, но только для переменных, все еще имеющих Unknown
-тип (то есть для тех переменных, которым еще не был присвоен другой тип). Обычно на этом этапе большинство переменных с типом Undecidable
становятся переменными с другими типами.
Далее, для того, чтобы избавиться от оставшихся в коде Undecidable
-переменных, Роберт вносит точечные изменения в код конвертера. Таким образом, например, Роберту удалось избавиться от Undecidable
-переменных, получаемых в результате парсинга JSON.
Компилятор также выполняет “унификацию типов”. Для чего она нужна?
В JavaScript-коде часто используются литералы объектов, но они не попадают в систему классов, построенную ранее, и это негативно сказывается на скорости работы игры (т.к. если не пытаться типизировать данные объекты, то компилятор будет использовать для них табличный тип).
Поэтому компилятор Роберта ищет в коде все определения литералов объектов и пытается конвертировать их в классы (в случаях, когда это имеет смысл). Для этого компилятор анализирует как объявленные объекты используются: какие поля они имеют при объявлении, переменным каких типов они присваиваются. На основании данного анализа компилятор создает новый класс, который и используется в качестве типа.
Это была сложная задача, но в итоге ее удалось решить.
Что пока не удалось решить, так это ограничения в компиляторе, связанные с Mutliple
-типом: в коде часто встречаются места, где одна переменная может хранить значения разных типов, и было бы гораздо эффективнее “запоминать” эти типы, но на данный момент компилятор просто преобразует такую переменную к Mutliple
-типу.
И что еще хуже, компилятор можно “обмануть”:
- допустим, у нас есть переменная
x
известного типа - можно создать объект табличного типа
var a = {};
- затем его можно “преобразовать” в табличный тип, хранящий
Multiple
-значения
поместив значение переменнойx
в качестве значения одного из полей объектаa
(a[‘x’] = x;
), компилятор “забудет” тип помещенного значения. И если впоследствии обратиться к этому полю, то компилятор будет использовать для негоMutliple
-тип (var xx = a[‘x’]; // xx будет иметь тип Multiple
)
- и если впоследствии вызвать метод у данного объекта, имеющего
Mutliple
-тип, то компилятор не сможет выполнить (уточняющее) выведение типов аргументов для этого метода (xx.something(really_weird_object);
) - но при этом компилятор мог ранее вывести типы параметров для этого метода (например, компилятор встречал места в коде, где этот метод вызывался только с численными аргументами)
- поэтому, если вызвать этот метод с аргументами, имеющими несовместимые с выведенными ранее типы, то выполнение программы будет аварийно завершено. А этого, как вы понимаете, допустить нельзя, если вы хотите выпустить игру на консолях.
Для отлавливания подобных ошибок Роберт добавил специальный режим компиляции, при котором выполнение программы сопровождается проверками типов переменных (при каждом присваивании) и аргументов функций (при каждом вызове функций). Игра при этом работает очень и очень медленно, но при возникновении подобных ошибок можно сразу увидеть, где ошибка возникла и какие при этом типы использовались (и тогда такую ошибку можно исправить).
Конечно же, в CrossCode используется eval
— метод, с помощью которого можно выполнить JavaScript код, представленный строкой. Поэтому Роберту пришлось реализовать интерпретатор JavaScript, который может взаимодействовать с откомпилированным JavaScript-кодом. К счастью потребовалось реализовать поддержку только небольшого подмножества JavaScript, использовавшегося в вызовах eval
.
Используя описанные решения, Роберту удалось довести порт до играбельного состояния, игру можно было пройти, но она работала недостаточно быстро. Основная причина относительно низкой скорости работы игры заключалась в том, что компилятор часто выбирал Multiple
-тип для переменных. Чтобы избавиться от избыточного использования Multiple
, Роберт стал “помогать” компилятору с помощью “подсказок типов” (type hints), добавляя в JavaScript-код комментарии с именами типов, которые компилятор должен использовать для переменных и аргументов функций. И это оказалось одним из основных методов улучшения скорости работы CrossCode — анализируя места в коде, где часто используется Multiple
, а также причины, почему так происходит, Роберт добавляет комментарии с подсказками, а остальную работу выполняет его компилятор.
Используя подсказки типов всего в нескольких местах, Роберту удалось значительно ускорить работу CrossCode — игра заработала быстрее, чем в V8 (на текущий момент игра работает в несколько раз быстрее, чем в V8).
Пока что дату релиза порта Роберт назвать не может, но говорит, что релиз будет очень скоро:
- сейчас игру можно пройти, в ней не осталось критических ошибок,
- однако в побочных квестах все еще находятся ошибки (а побочных квестов в CrossCode много)
- в некоторых особенно “тяжелых” сценах все еще наблюдается падение скорости работы (но в целом скорость работы приемлемая)
Подводя предварительные итоги, можно сказать что Haxe:
- помог сэкономить время, которое пришлось бы потратить на реализацию таких вещей как замыкания и сборщик мусора
- с другой стороны Роберту пришлось решать неожиданные проблемы, связанные с различиями между JavaScript и Haxe.
Но, в конце концов, Роберт считает, что Haxe все-таки больше помог, нежели помешал ему (но насколько точно — сказать сложно).
Используя накопленный опыт портирования CrossCode, Роберт в свободное время начал экспериментировать с написанием “правильного” компилятора JavaScript, но уже без использования Haxe (но ожидать релиза универсального компилятора, по словам Роберта, все же не стоит, если только этим проектом не заинтересуются инвесторы).
Если вы захотите написать свой компилятор JavaScript, то теперь вы примерно знаете, что от вас потребуется:
- скорее всего вам понадобится прочитать материалы, на которые Роберт ссылался в своем докладе,
- начните со спецификации ECMAScript и реализации описанных в ней тестов (они лучше подходят для начала работы). Пусть эти тесты и будут работать медленно, но это уже будет хорошим началом
- затем вы можете заняться типизацией кода и связанными с ней вопросами, рассмотренными в докладе Роберта.
Роберт считает, что раз ему удалось в одиночку создать свой неуниверсальный компилятор JavaScript для CrossCode (а он не считает себя специалистом по компиляторам), то создание универсального компилятора JavaScript — вполне реализуемая задача, особенно если ей займется команда опытных программистов.
Роберт также считает, что чем больше JavaScript-программисты “деградируют” в Java-программистов (то есть используют JavaScript как Java, насколько это возможно), то тем проще достичь хорошей производительности JavaScript-кода и скомпилированного из него кода. Именно поэтому ему удалось написать свой AOT-компилятор.
И немного о Deck13 Spotlight — издателе CrossCode:
Если вы не хотите писать свой компилятор JavaScript (или заниматься подобными вещами), то этим может заняться Роберт, как сотрудник компании :)
Deck13 Spotlight занимается издательством небольших инди-игр и весомым доводом в пользу сотрудничества с ними является возможность привлечения опытных разработчиков из студии Deck13. Поэтому издательство сможет помочь инди-компаниям по всем вопросам, связанным с разработкой и портированием игр.
Спасибо за внимание!