3D-аркада в браузере: как мы сделали игру на React + Redux
- пятница, 24 апреля 2020 г. в 00:26:51
Привет, Хабр! В не такие уж далёкие годы, на первом курсе «программистского» факультета, мне нравилось задавать товарищам по учёбе вопрос: «Зачем вы вообще пошли сюда учиться?» Точной статистики ответов я, конечно, не вёл, но доподлинно помню: больше половины хотели делать игры. Большинство тех, кто так отвечал, оказались не готовы к обилию разных видов «математик» и «физик», которыми нас завалили в первые два года учёбы. Выдерживали не все — уже к концу второго курса из пяти переполненных групп осталось три неполных.
Не так давно нашей фронтенд-команде предоставилась возможность попробовать себя в роли gamedev. Очень коротко, задача такая: сделать самую настоящую 3D-игру, да так, чтобы можно было поиграть, просто открыв браузер. Даже мобильный. Даже в WebView.
В этом посте я постараюсь рассказать о том, как мы спроектировали архитектуру игры, с какими проблемами столкнулись, используя один из самых популярных и актуальных технологических стеков — React + Redux, и какими «хорошими практиками», вероятнее всего, придётся пожертвовать, если вы для схожих задач выберете этот же стек.
Год назад на корпоративном портале в разделе для RnD появился зазывающий пост, который начинался такими словами: «Делать стенд на CodeFest — наша добрая традиция. Сделать лучший стенд — наша в меру амбициозная цель». Далее следовал призыв погенерить идеи, ну и, конечно же, реализовать их. Итогом совместного брейншторма и последующих вечерних посиделок стала игра с гордым именем Gods in the sky.
Фото из группы CodeFest в ВК.
Игру задумали как MMO TPS на авиационную тематику, где каждый игрок управлял собственным самолётом, беспощадно сражался в небе с соперниками и попутно набирал балы. Из баллов, конечно же, формировалась турнирная таблица, а сами сражения комментировал самый настоящий стример. Игра вполне зашла на CodeFest. Как нам кажется, людям наш стенд понравился.
Несколько лет подряд 2ГИС готовит к Новому году спецпроекты, которые либо знакомят пользователей с нашим функционалом, либо просто напоминают о 2ГИС как о тёплой и ламповой компании. Часть миссии нашей команды как раз в том, чтобы создавать такие проекты, а потому первичный бриф упал на проработку к нам в начале октября.
Если коротко: самолёты меняем на Санта-Клаусов, пули — на снежки, которые не убивают. А да, ещё в эту игру поиграет N сотен тысяч человек, а дедлайн — 1 декабря. Ну, край — 15 декабря. А, это воскресенье, так что 16-е. Если не вдаваться в детали, то задача кажется очень простой, однако очень быстро всплыли большие но:
И если первый пункт нас, скорее, вдохновлял (кто же не любит делать проект с нуля?), со вторым наши админы, сильно поморщившись, согласились, то третий отбрасывал значительную часть потенциальных игроков. А человеческих ресурсов на четвёртый просто не было.
А ещё мы не были уверены, что успеем всё это сделать к Новому году. Поэтому после нескольких раундов обсуждений мы пришли к тому ТЗ, результатом которого стала игра «Авиаторы». Она до сих пор открыта и играбельна — пробуйте.
Пока мы неспешно согласовывали ТЗ, искали человеческие ресурсы бэкенда, завершали активные проекты в разработке, внезапно подкрался ноябрь. К моменту, когда мы, начали писать первые строки кода на календаре было 5 ноября, а до вполне реального, никак не сдвигаемого дедлайна оставалось 40 календарных дней.
В команде ровно три фронтенд-разработчика. Искать наиболее подходящий нам фреймворк, систему организации и манипуляции стейтом приложения под задачи геймдева было некогда, а погружаться всей команде в этот стек — тем более. Поэтому именно с точки зрения фронтенда мы решили взять постоянно используемые нами React и Redux. Для манипуляции c 3D-пространством, по совету тех, кто делал исходный Gods in the sky, выбрали three.js.
Если коротко, то во всех случаях выбрали хорошо знакомые наборы инструментов.
Инфраструктуру всего проекта можно представить простой схемой:
То есть пользователь приходит на лендинг или на игру, ему отвечает написанный фронтендерскими руками бэкенд на Node.js. Его задачи максимально просты:
Никакого SSR нет. В платформе под управлением kubernetes это выглядит как два абсолютно разных приложения, но с точки зрения кода всё одно. Поэтому просто запускается один и тот же сервер, но с разными аргументами. Так сделано для того, чтобы отказ, например, лендинга, не приводил к отказу самой игры (и наоборот). А ещё так удобнее мониторить приложения и анализировать логи. Раздача всей статики осуществляется с отдельного сервиса под управлением Nginx.
В этом разделе наконец посмотрим на код. Весь проект на TS, поэтому воспользуемся интерфейсом AppState, чтобы представить структуру:
interface AppState {
gameState: GameState;
gameObjects: GameObjects;
data: Data;
requests: RequestsState;
}
Ветка requests
— это самый обычный объект, каждый ключ которого — строка. Он однозначно идентифицирует запросы, значение — его состояние. Неинтересно.
Ветка data
— это данные, которые в основном запрашиваются один раз на старте игры, например, список регионов, что доступны для «полетать». Тоже неинтересно.
В GameState
есть два интересующих нас поля:
stage: 'factoids' | 'citySelect' | 'flyShow' | 'game' | 'results';
— стадия игры. В нашем случае стадии такие:
elapsedTime: number;
— счётчик количества миллисекунд, проведённых в фазе игры, чистое игровое время без учёта пауз.
Таких этапов, как factoids
и flyShow
, изначально не было ни в ТЗ, ни в задумках — мы просто опирались на elapsedTime
. Если он меньше либо равен 0, то показывали этап выбора города. Если больше либо равен максимальному времени игры, то это этап результатов. Потом мы поняли, что нужен облёт игровой сцены, чтобы дать понять игроку, где и что раскидано на карте. А ещё некоторое время спустя поняли, что надо дать нашим игрокам краткую инструкцию. И начались костыли с флагами… В какой-то момент работать с этим стало нереально, поэтому был срочный рефакторинг на выраженные стадии.
И, наконец, самая главная ветка:
interface GameObjects {
user: UserInGame;
gifts: { [id: string]: GiftInGame; };
boosts: { [id: string]: Boost; };
bombs: { [id: string]: Bomb; };
}
В этой ветке хранится информация о всех объектах, которые есть на сцене: сам игрок, подарки, бусты и бомбы. Там лежит буквально вся необходимая информация — от геокоординат каждого объекта до углового ускорения.
GameObjects — самая частоизменяемая ветка в игре. Меняется она столько раз, сколько FPS держит устройство. У одного из моих коллег на ноутбуке с очень мощным GPU мы наблюдали 100 обновлений в секунду. На Redmi Note 7 нам удалось удерживать 40 FPS стабильно (что более чем достаточно для этой игры). На моём стареньком и очень тормозном MI 5S выдержали 30 FPS, но с просадками до 20 при анимациях, что тоже не так уж плохо.
Конечно же, такой результат вышел не сразу. Перейдём теперь к проблемам производительности.
В наших проектах мы стараемся следовать стайл-гайду Redux, за исключением одного правила — мы не пишем редьюсеры как конечные автоматы. За три года работы с Redux я не помню ни одной проблемы, которой бы помог именно такой подход. Так зачем писать больше кода? Ни в коей мере не призываю вас игнорировать этот совет Redux, просто проясняю наши привычки.
Из раздела выше следует, что у нас четыре типа объектов на сцене. Под каждый тип объекта мы завели классы, управляющие сменой состояния объектов этого типа по ходу игрового времени. Между собой мы называли эти классы «бихевиорами» (от англ. behavior — поведение). Уж не знаю, как такое принято называть в игровой индустрии.
Под каждый объект создавался свой экземпляр нужного класса. Общая черта каждого класса — публичный метод tick, который принимал одним из аргументов тот самый сдвиг игрового времени в миллисекундах. Все бихевиоры помещались в одну коллекцию, которую после каждого сдвига игрового времени обходил итератор. Он вызывал публичный метод tick с каждым элементом. По результатам своих действий каждый бихевиор:
Получалось примерно по 25 экшенов обновления на каждый тик (сдвиг игрового времени).
Милый северный зверёк подкрался к нам незаметно. Сначала были написаны все бихевиоры (и не одномоментно), потом к этому прицепили React, а потом уже только вышли на этап рендеринга сцены. В какой-то момент команда заметила что вентиляторы их макбуков собираются развить первую космическую, а у меня на моем линуксе «чё-то все дёргается» (встроенная видеокарта, как оказалось, такие вещи не любит).
Ещё один участник команды в тот день. Фото взято с pixabay.com
Сделав глубокий вдох, запустили FPS-метр из девтулзов Хрома и увидели совсем не радостное число 13. Уже было ясно, что нас ждёт, но я все-таки подцепил свой MI 5S, запустил тот же FPS-метр — там вообще было 5-6, что не лезло ни в какие ворота. Зверёк растянул ухмылку пошире и стал немного потолще.
Вариантов не оставалось: вкладка performance и вперёд — искать долгие функции, подмечать закономерности. Работа на самом деле интересная, всем советую, круто прокачивается понимание происходящего в коде.
В итоге каждую секунду при FPS=30 получалось, что:
FPS * ~[число экшенов] ~= 750
раз породить новый экземпляр стейта, не мутируя старый, ибо так делать не стоит.FPS * ~2 * ~[число отреагировавших на смену стейта компонетов]
изменений состояний компонента.Про React чуть детальнее:
* ~2
вышло, потому что все почти все компоненты подписаны на обновления стейта через connect
. Сам connect
— это вообще-то HOC, тоже компонент, притом чистый.Опустим очевидное: компоненты оборачиваем в React.memo там, где забыли, мобильный контрол переделываем «правильно», чтобы он просто скрывался стилями, а не пропадал из DOM-а, анимации делаем максимально простые.
Остаются неочевидные проблемы, которые лежит в плоскости React + Redux, и основные усилия по оптимизации нужно инвестировать именно туда.
Про Redux
У нашего способа работы с Redux очевидная проблема — слишком частое пересоздание стейта без особого смысла. Нам по факту нужно ~1 раз поменять стейт на каждый тик, для нашей игры это будет самое то. ~1, потому что некоторые ветки проще всё-таки поменять разными экшенами — это вопрос удобства. Так у нас появляется экшен-креатор, суть которого заключалась в том, чтобы ветка GameObjects обновлялась нужным образом, реагируя только на него:
export function setNextGameObjects(payload: GameObjects) {
return {
type: 'SET_NEXT_GAME_OBJECTS' as const,
payload,
};
}
После этого разделяем редьюсер ветки GameObjects на две части:
SET_NEXT_GAME_OBJECTS
. SET_NEXT_GAME_OBJECTS
. Выглядит использование нового фейкового редьюсера примерно так:
let state = store.getState().gameObjects;
for (const action of this.collectedActions) {
state = gameObjectsFakeStateReducer(state, action);
}
store.dispatch(setNextGameObjects(state));
collectedActions — экшены, которые насоздавали бихевиоры. Организация способа помещения в одну коллекцию — задача примитивная, вариантов масса.
Итог этих действий — вместо ~25 экшенов на каждый тик сразу выигрываем в 10 раз: у нас теперь ~2 экшена в тике. Но особого профита это не даёт, так как gameObjectsFakeStateReducer как функция продолжает работать медленно. Ей все ещё нужно 25 раз создать абсолютно новый объект стейта. Выхода в условии ограниченности временных ресурсов мы не нашли иного, кроме как отказаться от иммутабельности стейта.
Насколько это помогло? А вот вам очень синтетический, но все же perf-тест. Оттуда видны 3 вывода:
Первый путь, конечно, неплох, но позволить мы его себе не можем — далеко не все клиенты смогут в ES2018, рисковать не стоит. Плюс всё ещё останутся проблемы у Firefox.
Путь №3 самый быстрый, но код будет… неприятный. И расширять набор полей будет ну совсем уж неудобно.
В итоге останавливаемся на мутирующем Object.assign. gameObjectsFakeStateReducer
становится gameObjectsFastStateReducer
, а под капотом происходит как-то так:
switch (action.type) {
case 'PARTIAL_GIFT_STATE_PATCH':
if (!state.gifts[action.payload.id]) {
return state;
}
Object.assign(state.gifts[action.payload.id], action.payload);
break;
// case, default ...
}
Плохо? Конечно, кто б спорил. Но работает, и переделка заняла пару часов. Итого переделка одной ветки стейта на антипаттерн даёт нам обработку 25 экшенов за 1 тик за 2-4мс (причем при четырёхкратном замедлении CPU). Пруф из профайлера:
Скринов с профайлингом «старой» версии не осталось, поэтому придётся поверить, что совокупная оптимизация Redux дала прирост к производительности всего проекта раз в 10 на этапе формирования нового стейта после каждого тика. Конечно же, из-за появившихся мутаций стейта, пришлось подшаманить некоторые компоненты, которые через connect были подписаны на ту или иную смену ветки стейта gameObjects. Но это мелочи, хоть и не очень приятные.
Про React
Вообще про оптимизации React написано уже очень много статей. Основной их посыл — свести к минимуму изменения DOM. По факту это означает, что нам следует стремиться к минимизации согласований, а делать это можно разными способами — reselect, React.memo/React.PureComponent, вручную написать shouldComponentUpdate и прочее. От минимизации количества согласований и оттолкнёмся.
Мы уже поработали с количеством экшенов, которые мы бросаем на каждый тик, и их теперь в обычных случаях ~2, в исключительных — до 5. Но это не давало гарантии, что React обработает близкие во времени изменения за один шаг свёртки, т.е. выполнит согласование и отобразит изменения виртуального DOM в реальный.
Однако этот процесс можно взять под контроль при помощи функции batch из пакета React + Redux. Собственно, суть этой функции — гарантировать обработку ряда синхронных изменений стейта за одну фазу согласования. Круто же, разве нет?
Но этого мало, у нас все ещё остаётся ряд компонентов, подписанных через connect на обновление данных из стейта. И они могут меняться очень часто, хотя делать этого не надо.
Тут нужен пример. Например, есть компонент <ScoreBoard />
, который
Это был классический React.PureComponent, который обёрнут в connect и в mapStateToProps. Он забирает из стейта поля elapsedTime
и score
. Score
меняется нечасто, а elapsedTime
— после каждого тика. Преобразование elapsedTime
в минуты-секунды происходит в методе render. Это значит, что сам компонент ререндерится по частоте FPS, то есть в каждом кадре у этого компонента выполняется shouldComponentUpdate. У HOC’a connect тоже выполняется — shouldComponentUpdate в каждом кадре.
Вот варианты, как можно «оптимизировать» этот компонент и добиться наибольшей эффективности, как в этом приложении:
elapsedTime
будет сравниваться как секунды (с последующим приведением к привычному формату времени). Это вариант, но он не избавит от жизненного цикла HOC’a connect. Сказано — сделано! У ScoreBoard’a больше нет connect, зато есть две пропсы, прилетающие сверху от родительского компонента. Родительскому компоненту нужно не забыть возвращать из mapStateToProps сразу секунды, чтобы он сам по себе не стал слишком часто обновляться.
Ну и так по аналогии со многими компонентами, использующими connect. После этих манипуляций осталось всего два компонента, которым действительно нужно получать актуальное состояние как можно чаще — компонент-обёртка над нашей картой и компонент-обертка над canvas-элементом, в который three.js рендерит все игровые объекты (бомбы, самолёт, и т.д). Если посмотреть на логику методов render у обоих компонентов, то там будет:
return <div id="map" className={s.map} ref={mapRef} />;
и
return <canvas id="scene" className={s.scene} ref={sceneRef} />;
Что бы ни происходило со стейтом приложения, на DOM эти компоненты влияют ровно один раз за свой жизненный цикл — когда происходит маунт. А это означает, что весь процесс согласований React им не нужен. Весь ререндер сводится к тому, чтобы отработал метод componentDidUpdate, в котором будем выполнять некие действия. Строго говоря, код componentDidUpdate компонента карты выглядел примерно так:
public componentDidUpdate() {
const { geoPos, orientedRotationQuanternion } = this.props;
const { map } = this.state;
map.setQuat(orientedRotationQuanternion);
map.setCenter([geoPos[0], geoPos[1]], { animate: false });
map.setZoom(getMapZoomFromHeight(geoPos[2]), { animate: false });
}
Дальше задача сводится к тому, чтобы componentDidUpdate у этих компонентов более никогда не вызывался. Шаги для этого такие:
public shouldComponentUpdate() { return false; }
— компонент из «чистого» превращается в «обычный»;store.subscribe(() => { /* … */})
);Получается примерно так:
store.subscribe(() => {
const state = store.getState();
const { map } = this.state;
const { geoPos, orientedRotationQuanternion } = state.gameObjects.user;
map.setQuat(orientedRotationQuanternion);
map.setCenter([geoPos[0], geoPos[1]], { animate: false });
map.setZoom(getMapZoomFromHeight(geoPos[2]), { animate: false });
});
Теперь компонент никогда не ререндерится, не участвует в согласовании и продолжает делать то, что от него требуется.
Я затрудняюсь сказать, является ли такой подход каким-то ярким антипаттерном, но для обычных приложений никогда не стал бы так делать.
Результат работы над реактом. Тонкие оранжевые полоски — React Tree Reconcilation + Commit
React Tree Reconcilation + Commit под микроскопом
Связка React + Redux подходит для разработки игр, если вы:
Подумайте ещё раз, вдруг в вашей ситуации есть варианты лучше. Если нет, берите именно этот стек. Если хотя бы один пункт не выполняется, то я бы не стал брать эти технологии. Что брать на замену? Предлагайте варианты в комментариях.
Настало время полезных советов:
Проблемы, которые вас точно ждут:
Проблемы и трудности были, но в итоге получилось одно приложение, которое работает на всех устройствах. Не нужны отдельные приложения под iOS, Android и Web и три команды разработки под каждую платформу.