На Pixonic DevGAMM Talks выступал еще наш DTO Антон Григорьев. Мы в компании уже говорили, что работаем над новым PvP-шутером и Антон поделился некоторыми нюансами архитектуры этого проекта. Он рассказал, как построить разработку, чтобы изменения в игровой логике клиента появлялись на сервере автоматически (и наоборот), и можно ли не писать код, но при этом минимизировать трафик. Ниже — запись и расшифровка доклада.
Не буду учить, как что-то делать, расскажу о том, как это делали мы. Чтобы вы не наступили на те же грабли и могли использовать наш опыт. Еще полтора года назад мы в компании не умели делать шутеры на мобилках. Вы скажете, как же так, у вас же есть War Robots, 100 млн загрузок, 1,5 млн DAU. Но в этой игре роботы очень медленные, а мы хотели сделать быстрый шутер и архитектура War Robots не позволяла этого.
Мы знали, как и что делать, но у нас не было опыта. Тогда мы наняли человека, у которого этот опыт был и сказали: сделай то же самое, что ты уже сто раз делал, только лучше. Потом сели и начали думать об архитектуре.
Пришли к Entity Component System (ECS). Думаю, многие знают, что это такое. Все объекты мира представлены сущностями. Например, игрок, его пушка, какой-то объект на карте. У них есть свойства, которые описываются компонентами. К примеру, компонент Transform — это положение игрока в пространстве, а компонент Health — это его здоровье. Есть логика — она отдельна и представлена системами. Обычно системы — это метод Execute(), который проходится по компонентам определенного типа и что-то делает с ними, с игровым миром. Например, MoveSystem проходится по всем компонентам Movement, смотрит скорость в этом компоненте, параметр и на основе этого вычисляет новое положение объекта, т.е. записывает его в Transform.
У такой архитектуры есть свои особенности. Когда разрабатываешь на ECS, нужно думать и делать по-другому. Один из плюсов — это композиция вместо множественного наследования. Помните этот ромбик с множественным наследованием в С++? Все его проблемы. В ECS этого нет.
Вторая особенность — это разделение логики и данных, про которое я уже говорил. Что это нам дает? Мы можем пачками хранить состояние мира и его историю, можем сериализовать, можем отправлять эти данные по сети и менять их в real-time. Это всего лишь данные в памяти — мы можем в любое время изменить любое значение. Таким образом очень удобно менять логику игры (или для дебага).
Также очень важно следить за порядком вызова систем. Все системы идут друг за другом, вызываются методом Execute() и, в идеале, должны быть независимыми. На практике такого не бывает. Одна система что-то меняет в мире, другая система потом это использует. И если мы этот порядок нарушим — игра будет идти по-другому. Вероятно не сильно, но точно не так, как раньше.
Наконец, одна из главных и самая важная для нас особенность — мы можем выполнять один и тот же код, как на клиенте, так и на сервере.
Дай разработчику возможность, и он найдет 99 способов и причин делать свое решение, а не использовать уже существующие. Думаю, многие так делали. Мы на тот момент искали ECS Framework. Рассматривали Entitas, Artemis C#, Ash.net и собственное решение, которое могли бы написать по опыту пришедшего к нам специалиста.
Не пытайтесь прочитать, что написано на слайде, это не так важно. Важно то, сколько зеленого и красного в столбцах. Зеленое — значит, что решение поддерживает требования, красное — не поддерживает, желтое — поддерживает, но не совсем.
В столбце ECS — потенциально наше решение. Как видите, оно круче — мы могли бы поддерживать намного больше требований. В итоге некоторые из них мы не поддержали (в основном, потому что они не понадобились), а некоторые, без которых мы не смогли работать дальше, пришлось сделать. Выбрали архитектуру, долго работали, делали минимально играбельную версию и… факап.
Получилась максимально неиграбельная версия. Игрока постоянно откатывало, тормоза, сервер зависал в середине матча. Играть в это было невозможно. Какие были причины неудач?
Причина №1 и самая важная — неопытность. Но как же так? Мы же наняли опытного человека, который должен был сделать все красиво. Да, но на самом деле мы дали ему только часть работы. Мы сказали: «Вот тебе гейм-сервер, работай над ним». А в нашей архитектуре (об этом чуть позже) очень важную роль играет клиент. И именно эту часть мы отдали человеку, у которого не было нужного опыта. Нет, он хороший программист, сеньор — просто не было опыта. Т.е. он даже не представлял, какие там могут быть грабли.
Причина №2 — нереальные аллокации. 80 Кбайт/кадр. Много это или нет? Если учесть, что у нас 30 кадров в секунду, то за секунду мы получаем 2,5 Мбайт, а за 5-минутный матч уже больше 600 Мбайт. Короче, много. Garbage collector начинает усиленно пытаться всю эту память освободить (когда мы требуем от него больше и больше), что приводит к спайкам. Учитывая, что мы хотели 30 кадров в секунду, эти спайки нам мешали очень сильно. Причем, как на клиенте, так и на сервере.
Основная причина аллокаций заключалась в том, что мы постоянно аллоцировали массивы данных. Каждый раз практически на каждый кадр. Использовали LINQ, лямбда-выражения и Photon. Photon — это сетевая библиотека, с которой мы знакомы и используем в War Robots. И вроде все хорошо, но она аллоцирует память каждый раз, когда посылает данные или принимает их.
Если с первыми проблемами мы разобрались (переписали на свои кастомные коллекции, сделали кэширование), то с Photon практически ничего нельзя было сделать, потому что это сторонняя библиотека. Можно было только уменьшить размер пакета, а он у нас был 5 Кбайт. Много? Да. Есть MTU — это минимальный фактический размер пакета, который посылается по UDP, не разбивая пакет на мелкие части. Он, примерно, 1,5 Кбайт, а у нас было 5 (это в среднем, было и больше).
Соответственно, Photon резал наш пакет на мелкие и отправлял каждый кусок как reliable, т.е. с гарантированной доставкой. Каждый раз, когда часть не доходила, он посылал ее еще и еще раз. Мы получали еще большую задержку и сеть работала плохо.
Все эти аллокации приводили к тому, что мы получали кадр около 100 миллисекунд, когда нужно было 33. А там же рендеринг, симуляция и другие действия — всё это занимает CPU. Все эти проблемы — комплексные, т.е. нельзя решить было какую-то одну, и все станет хорошо. Нужно было решить их все сразу.
И еще небольшая проблема, которая была во время разработки — большое количество репозиториев. На слайде написано 5, но, мне кажется, их было даже больше. Все эти репозитории (для клиента, гейм-сервера, общего кода, настройки и еще чего-то) подключались сабмодулями в два основных репозитория на клиент и гейм-сервер. С этим было тяжело работать. Программисты умеют работать с Git, SVN, но есть еще художники, дизайнеры и т.д. Думаю, многие пытались научить художника или дизайнера работать с системой контроля версий. Это реально тяжело, поэтому если ваш дизайнер умеет это делать — берегите его, он ценный сотрудник. В нашем случае психанули даже программисты, и в итоге мы сократили всё до одного репозитория.
Это стало отличным решением проблемы. У нас там лежит папка с сервером и папка с клиентом. Сервер состоит из проекта гейм-сервера, генератора кода и вспомогательных инструментов.
Клиент — это Unity-клиент и общий код. Общий код — это структура данных о мире, т.е. Entities, компоненты и симуляция системы. Этот код, в основном, создается серверным генератором. Его же использует сервер. Т.е. это общая часть для клиента и сервера.
Лайфках. Берем TeamCity, натравливаем на наш репозиторий, собираем и деплоим сервер. Каждый раз, когда клиент меняет общую логику, у нас тут же собирается гейм-сервер — теперь для этого не нужен серверный программист. Обычно есть сервер, клиент и какая-то фича. Клиент ее пилит у себя, сервер у себя, и когда-то у них это все заработает. В нашем случае не так — клиент может писать эту фичу и все работает на сервере.
Матч состоит из общей части (обозначена как ECS) и представления (это юнитивые MonoBehaviour классы, GameObject’ы, модельки, эффекты — все, чем представлен мир). Они не связаны.
Между ними есть Presenters, который работает с обеими частями. Как вы понимаете, это MVP (Model-View-Presenter) и любую из этих частей можно заменить, если понадобится. Есть еще часть, которая работает с сетью (на слайде — Network). Это сериализация информации о мире, сериализация ввода, отправка на сервер, получение сервером, коннект к серверу и т.д.
Еще лайфках. Берем и заменяем эту часть посылкой не реальной, по сети, а виртуальной. Создаем некий объект внутри клиента и отправляем ему сообщения. Он реализует серверную симуляцию — теперь этот объект делает все, что происходило на гейм-сервере. Остальных игроков заменяем ботами.
Готово. Мы получили игру и возможность ее тестировать без гейм-сервера. Что это значит? Это значит, что художник, сделав новый эффект, может нажать кнопку Play в редакторе, сразу же на карте попасть в матч и увидеть, как это работает. Или же дебаг для клиентских программистов того, что они написали.
Но мы пошли дальше и приделали к этому слою эмуляцию пинга задержек сети jitter (это когда пакеты в сети доходят не в том порядке, в котором были отосланы) и другие сетевые штуки. В итоге получили практически реальный матч без гейм-сервера. Работает, проверено.
Вернемся к кодогенерации.
Я уже говорил, что у нас есть кодогенератор в гейм-сервере. Есть свой domain-specific language, который на самом деле простой С# класс. В данном случае класс Health. Мы помечаем его своими атрибутами. Например, есть атрибут Component. Он говорит, что Health — это компонент в нашем мире. На основе этого атрибута генератор создаст новый C# класс, в котором будет куча вещей. Их можно написать руками, но он сгенерирует. К примеру, метод добавления компонента в Entity, метод поиска компонентов, сериализацию данных и т.д. Есть атрибут типа DontSend, который говорит, что какое-то поле по сети посылать не обязательно — он не нужен серверу или не нужен клиенту. Или же атрибут Мах, сообщающий, что у игрока максимальное значение здоровья — тысяча. Что нам это дает? Вместо поля, которое занимает 32 бита (int), мы посылаем 10 бит — в три раза меньше. Такой кодогенератор позволил нам уменьшить размер пакета с 5 Кбайт до 1.
1 Кбайт < 1,5 — т.е. мы уложились в MTU. Photon перестал резать и сеть стала намного лучше. Практически все ее проблемы у нас ушли. Но мы пошли дальше и сделали дельта-компрессию.
Это когда вы посылаете один полный стейт, а дальше только его изменения. Не бывает такого, чтобы весь мир сразу полностью изменился. Постоянно меняются только какие-то части и эти изменения по размеру намного меньше самого стейта. Мы получили в среднем 300 байт, т.е. в 17 раз меньше, чем было изначально.
Зачем это нужно, если и так в MTU попадали? Игра постоянно растет, появляются новые фичи, а вместе с ними появляются объекты, entity, новые компоненты. Размер данных растет. Если бы мы остановились на 1 Кбайт, то очень скоро вернулись бы к той же самой проблеме. Сейчас, переписав на дельта-компрессию, до этого мы дойдем очень не скоро.
Теперь самая сладкая часть. Синхронизация. Если вы играете в шутеры, то знаете, что такое Input Lag — когда нажимаете на кнопку, а персонаж начинает двигаться через какое-то время, например, полсекунды. Для каких-нибудь игр в жанре моба это нормально. Но в шутере вы хотите, чтобы герой тут же стрелял и наносил урон.
Почему происходит Input Lag? Клиент собирает ввод игрока (input) и отправляет его на гейм-сервер (отправка занимает время). Дальше гейм-сервер его обрабатывает (снова время) и отправляет результат назад (опять же, время). Это и есть задержка. Как ее убрать? Есть штука под названием prediction (предсказание) — клиент не ждет ответа от сервера и сразу начинает пытаться сделать то же самое, что делает гейм-сервер, т.е. симулирует. Берет ввод игрока и начинает симуляцию. Мы симулируем только локального клиента, потому что не знаем ввода других игроков — они нам не приходят. Поэтому мы запускаем системы симуляцию только на нашем игроке.
Во-первых, это позволяет сократить время симуляции. Клиент начинает симуляцию сразу же, как только получил ввод и находится на несколько шагов вперед, относительно гейм-сервера. Допустим, на этой картинке он симулирует тик №20. В этот момент гейм-сервер симулирует тик №15 в прошлом. Клиент видит весь остальной мир, опять же, в прошлом, себя — в будущем. Пока он пошлет 20-й тик на сервер, пока этот ввод дойдет, гейм-сервер уже начнет симулировать 18-й тик или уже 20-й. Если 18-й, то он положит его в буфер, дойдет до 20-го, обработает и вернет результат назад.
Допустим, сейчас он симулирует тик №15. Обработал, возвращает результат на клиент. У клиента есть какой-то просимулированный 15-й тик, 15-й гейм-стейт и игровой мир, который он предсказал. Начинается сравнение с серверным. На самом деле он сравнивает не весь мир, а только своего клиента, потому что за весь остальной мир мы не отвечаем. Мы отвечаем только за себя. Если игрок совпал — все хорошо, значит мы правильно просимулировали, физика отработала верно и никаких столкновений не возникло. Дальше продолжаем симулировать 20-й тик, 21-й и так далее.
Если клиент/игрок не совпал, значит, мы где-то ошиблись. Пример: так как физика не детерминированная, она посчитала неправильно нашу позицию или что-то произошло. Может быть просто баг. Тогда клиент берет стейт с гейм-сервера, потому что гейм-сервер его уже подтвердил (он доверяет серверу — если бы не доверял, игроки бы читерили), и ресимулирует все остальные с 15-го по 20-й. Потому что эта ветка времени теперь ошибочная.
Создаем новую ветку времени, т.е. параллельные миры. Ресимулируем эти пять тиков за один тик. Когда-то у нас симуляция занимала 5 миллисекунд, но если нам нужно ресимулировать 10 тиков — это уже 50 миллисекунд и мы не попадаем в наши 30 миллисекунд. Оптимизировали и получили одну миллисекунду — теперь 10 тиков обрабатываются за 10 миллисекунд. Потому что там еще есть рендеринг.
Все эти вещи работают на клиенте, а мы отдали это человеку без нужного опыта. Минус — у нас был факап, а плюс — что программист теперь знает, как делать правильно.
Такая схема имеет свои особенности. Клиент на левой картинке пытается выследить противника. Он находится в 20-м тикe, противник находится в 15-м тикe. Потому что пинг и клиент опережает сервер на 5 тиков. Клиент стреляет и должен точно попасть и нанести урон, может быть даже хэдшот. Но на сервере картина другая — когда сервер начнет симулировать 20-й тик, враг может уже сместиться. Например, если противник двигался. По идее, мы не должны попасть. Но если это так работало, то никто не играл бы в сетевые шутеры из-за постоянных промахов. В зависимости от пинга вероятность попадания тоже менялась: чем хуже пинг — тем хуже попадаете. Поэтому делают по-другому.
Сервер берет и откатывает весь мир в тот тик, в котором видел мир игрок. Сервер знает, когда это было, откатывает его на 15-й тик и видит левую картинку. Видит, что игрок должен был попасть, и наносит урон его противнику уже в 20-м тикe. Всё хорошо. Почти. Если противник бежал и забежал за препятствие, то мы хэдшотим уже через стену. Но эта известная проблема, игроки о ней знают и не парятся. Так оно работает, ничего с этим не поделаешь.
Итак, мы достигли 30 тиков в секунду, 30 кадров в секунду. Сейчас на нашем сервере играет примерно 600 игроков одновременно. В матче 6 игроков, т.е. около 100 матчей. У нас нет серверного программиста, он нам не нужен. Всю логику клиентщики пишут в редакторе Unity, Rider’e, на С# и оно работает на гейм-сервере. Почти всегда. Мы сократили размер пакета в 17 раз и уменьшили аллокации памяти в 80 раз — теперь даже меньше килобайта на клиенте и сервере. Средний пинг был 200-250 мс, сейчас — 150. 200 — это стандарт для мобильных сетевых игр, в отличие от PC, где все происходит намного быстрее, особенно по локальной сети.
Мы планируем выделить написанное в отдельный фреймворк, чтобы использовать его на других проектах. Но пока об Open Source речи не идет. И добавим туда интерполяцию. Сейчас у нас 30 тиков в секунду, можем рисовать так, как тикает. Но есть игры, где хватает 20 тиков в секунду или 10. Соответственно, если мы будем рисовать 10 раз в секунду — персонажи будут двигаться рывками. Поэтому нужна интерполяция. Мы написали собственную сетевую библиотеку вместо Photon — аллокаций памяти там нет.
Есть еще части, которые можно не писать руками, а генерировать код. Например, когда мы отправляем состояние мира клиенту, то вырезаем те данные, которые ему не нужны. Пока мы делаем это руками и когда появляется новая фича, а мы забываем вырезать эти данные, то что-то идет не так. На самом деле это можно генерировать, пометив каким-нибудь атрибутом.
Вопросы из зала
— Для кодогенерации вы что используете? Свое собственное решение?
— Всё просто — руки. Мы думали заюзать что-то готовое, но быстрее оказалось просто написать своими руками. Пошли по этому пути, это хорошо работало тогда и сейчас.
— Вы отказались от серверного разработчика, но вы же не просто сократили время разработки за счет того, что один и тот же код переиспользуется. Unity же не поддерживает последнюю версию С#, у него собственный под капотом движок. Вы не можете использовать .NET Core, вы не можете использовать последние фичи, определенные структуры и прочее. Разве от этого не страдает производительность где-то на треть?
— Когда начинали все это делать, мы думали, чтобы использовать не классы, а структуры, это должно было намного быстрее работать. Написали прототип, как это будет выглядеть в коде, как эти структуры будут программисты использовать для того, чтобы писать логику. И оно оказалось жутко неудобным. Мы остановились на классах и той производительности, которая сейчас есть, нам достаточно.
— Как вы живете сейчас без интерполяции? И как вы симулируете игрока, если снэпшот не пришел в нужный кадр?
— У нас есть интерполяция, только она не визуальная, а на те пакеты, которые приходят по сети. Допустим у нас есть 18-й,19-й и 20-й стэйт. Пришел 18-й, пришел 20-й, а 19-й либо потерялся, либо еще не дошел — вот его мы интерполируем. Как раз используем кодогенерацию для того, чтобы не писать код интерполяции.
— Есть ли еще лайфхаки, чтобы сильнее сжать кватернионы?
— Я говорил про 2D — там просто угол альфа, а на новый проектах кватернионы есть и там свои проблемы. Из лайфхаков могу сказать еще такой: так как мы используем UDP, чтобы ввод не потерялся, мы отправляем его пачками: с нулевого по пятый ввод, потом с первого по шестой и так далее. Это дешевле и доставка лучше.
— Но ведь порядок воспроизведения ввода на сервер играет роль?
— Да, конечно. Если какой-то ввод вдруг потерялся (хотя это маловероятно, у вас либо очень плохая сеть, либо она совсем отвалилась), то у нас 2 варианта: либо копировать предыдущий ввод, либо брать нулевой ввод.
— Вы не симулируете на клиенте других игроков. А как это выглядит визуально? Их не телепортирует? Если пинг больше 1000 в районе секунды, что произойдет? Просто переместит, при приходе следующего потока?
— Сейчас интерполяции нет, соответственно бывает дерганье. Но у нас сейчас стоит интервал в секунду, если клиент на секунду ушел дальше гейм-сервера, то мы просто разрываем соединение, он пытается переконнектиться. Это дешевле, чем ресимулировать все 30 тиков.
— Бывает ли у вас расхождение стейтов, как вы их находите и как боритесь?
— Конечно. Изначально, когда мы сравнивали очень многие вещи (сейчас мы сравниваем только локального игрока), у нас постоянно были мисспредикшены. Это порождает перепредикшены — ресимуляции, а это нагрузка на обработку, процессор. В какой-то момент мы поняли, что некоторые вещи не нужно симулировать, повырезали их и сейчас все стало намного проще. А если, например, взять выстрелы, то мы их фактически предиктим только для того, чтобы визуально отобразить. Там есть свои хитрости.
— Насколько большая часть игры у вас именно на ECS, насколько жирные системы, насколько загружены сущности и компоненты? Как у вас организован этот баланс?
— 30 систем на клиенте крутится, мы же только локального игрока симулируем. 80 на сервере на данный момент, может быть уже больше. По сущностям сейчас точно не вспомню.
— Вопрос по расхождению в prediction. Когда мы на 20-м кадре что-то предсказали, а нам пришел стейт с сервера, что мы не могли стрелять и у нас есть какой-то пул команд — как вы потом накатываете, как мержите? Ощущение, что там для каждого отдельного случая свое специфическое решение. Или есть какие-то общие решения?
— Общее решение простое: берешь стейт с сервера и подставляешь его как последний заапрувленный. И на его основе (допустим, 15-го) ресимулируешь 16-й,17-й,18-й и так далее.
— На основе команд своего пула команд?
— Да, мы храним историю инпутов клиента и храним историю состояния мира. Но есть некоторые вещи, например, выстрелы. Выстрелы генерируют много сущностей Entity (на каждый выстрел практически), которые мы там отображаем. И так как эта генерация объектов происходит на клиенте — на сервере таких выстрелов может быть больше, от других игроков в том числе. ID могут расходиться, у нас есть свои хаки на этот счет.
— Если какой-то выстрел из базуки — мы хотим нарисовать эту ракету, а потом оказывается, что мы не могли стрелять, мы должны ее удалять? Я так понимаю, в каждом отдельном случае отдельное решение?
— Да, например, есть граната. В 3D когда ты кидаешь гранату, там какие есть хаки — ты начинаешь ее кидать, ее не видно еще на экране и только когда она создастся на сервере, пройдет какое-то время. Но ты ее не будешь видеть, потом она появится, и вроде бы все хорошо. У нас top-down, поэтому сложнее — там гранату видно сразу. Мы тоже написали со своими хитростями. На клиенте ее создаем сразу, туда же запускаем, но когда она приходит с сервера реально, мы интерполируем ее путь. В итоге все выглядит нормально.
— А если нельзя было запустить эту гранату?
— Она просто исчезает. Такое тоже бывает.
— Вопрос по поводу момента, когда все клиенты посылают на сервер свой инпут, он собирается в какой-то буфер. В какой-то момент сервер берет инпут со всех клиентов и симулирует. Но если какой-то клиент отстает, но отстает не на целую секунду, а просто скачек, 500 миллисекунд, задержка, и у сервера в какой-то конкретный момент нет инпута какого-то клиента. Как это резолвится?
— Сейчас покажу.
Клиент забрасывает инпут в будущее, т.е. он симулирует 20-й тик и берет 20-й инпут, затем кидает его в будущее серверу. Насколько в будущее — это отдельная сложная штука, число варьируется в зависимости от пинга. Он пытается предугадать: если я сейчас 20-й тик пошлю, сервер в этот момент дойдет до этого тика или нет? Есть опять же инпут буфер на сервере, куда эти инпуты складываются. Соответственно, если он послал чуть дальше — когда-нибудь сервер до него просто дойдет. Если он послал за этот буфер, то инпут потеряется. Клиент потом от сервера получит акк, что «я сейчас обработал твой такой-то инпут, не 21-й, а 18-й». Сервер: «ага, значит мне нужно чуть-чуть ужаться». Такая гибкая система.
— Т.е. они могут иногда пропадать, но потом он подгоняет клиента и клиент посылает более свежий инпут?
— Да, клиент пытается адаптироваться в этой ситуации.
— Вы мельком упомянули reliable UDP — у вас какая-то своя реализация?
— Это Photon, в Photon есть reliable UDP, unreliable, c гарантированной и без гарантированной доставкой.
— Гарантированно доставляются все пакеты?
— У нас не гарантированно, просто посылаются куда-то. Дойдут они или не дойдут, для сетевой игры не так важно. Например, клиент отправляет инпуты. Чтобы они не потерялись, мы отправляем их пачками. Если мы отправляем пачками по пять, то как минимум пять раз он туда будет послан. Если пакет потерян на 100%, то естественно ничего не дойдет, если на 80%, то скорее всего дойдет.
— А повторно?
— Нет, повторно не отправляем, Photon это делает сам, если размер пакета больше MTU.
— Правда ли от кодогенерации польза больше? Чем его поддерживать?
— Когда человек, который его писал, только начал, у нас с ним были дискуссии. Надо нам это или нет. Как оказалось, действительно надо. Тогда это было неэффективно, а сейчас наоборот, потому что практически ничего не нужно делать, ты просто атрибуты шлепаешь.
— А пример, где можно выиграть время, где можно управляемость получить?
— Ты увидел параметр, который тебе не нужно отсылать. Если бы у тебя кодогенератора не было, ты бы пошел писать код. Забыл, всё, он посылается. Потом, когда-нибудь ты это обнаружишь. А здесь ты атрибут повесил, всё автоматом работает. Или же параметр, который можно посылать с меньшим размером.
— Просто из моей практики написать код запись/чтение из стрима — это раз в месяц где-то пять минут. И если я забуду написать код, то и атрибут забуду поставить.
— Игра постоянно развивается, соответственно таких компонентов появляется довольно много и постоянно приходится шлепать это дело. Кстати, помимо сериализации (мы генерируем еще и сериализацию), у нас есть некий вьювер на гейм-сервере, который позволяет видеть мир таким, какой он есть на гейм-сервере. Все объекты, все их поля. Поля можно менять — эта часть, она тоже генерируется.
— У вас физика не детерминированная, соответственно какие-то проблемы возникают. Вы не думали сделать ее детерминированной?
— Сделать детерминированной не собираемся, хотя идеи такие были. Но в начале работы над проектом у нас был выбор по трем пунктам: ECS, физика и сетевая библиотека. Физику и сетевую библиотеку мы взяли готовые, ECS написали сами. Сейчас очень рады, что написали ECS сами. Очень больно, что не написали сетевую библиотеку и физику. Как оказалось, ту физику, которую мы взяли, писал студент (мы тогда посмотрели, вроде бы нормально, а когда начали работать, глубже копать, оказалось не совсем то, что нам нужно). Плюс у нас 2D игра, но бывают вещи, которые нужно делать в 3D — мы их пишем сами. Фактически 3D физику для этих вещей, объектов, мы пишем сами. И детерминизм в такой архитектуре не нужен. Там будут какие-то расхождения, но они будут минимальными. Если есть детерминизм, тогда и не нужно посылать с гейм-сервера гейм-стейт, достаточно просто инпутов от всех игроков.
— В докладе был тезис, что ECS позволяет запускать код и на клиенте, и на сервере. Насколько я понял, позволяет то, что везде язык C#?
— В первую очередь — да.
— Т.е. в принципе это к EСS не относится? Как я понимаю, ECS — это просто способ написать игровую модель, в итоге она выплевывает стейт мира и не важно, как я это написал, если на выходе получаю то же самое. Т.е. ECS — это правильный способ писать модель, чтобы в ней не запутаться.
— Я не скажу, что правильное, это просто один из способов. Есть у него и плюсы, и минусы. Основной минус — человека надо научить, как правильно это делать, как на нем готовить. Потому что те, кто раньше использовали OОП или какие-то другие подходы, им тяжело будет понять, как и что делать.
— Получается, вы использовали ECS-подход как парадигму?
— Если по-хорошему, ECS это не только парадигма, которую мы использовали, это еще (если писать на структурах) довольно сильная оптимизация — и по памяти, и по многим другим вещам. Мы оптимизацию не делали, не стали писать на структурах — писали на классах. Главное — это подход и то, что у нас есть данные, которые отделены от логики. Мы можем с ними работать, посылать по сети, принимать, сравнивать и т.д.
Еще доклады с Pixonic DevGAMM Talks
- Использование Consul для масштабирования stateful-сервисов (Иван Бубнов, DevOps в компании BIT.GAMES);
- CICD: бесшовный деплой на распределенные кластерные системы без даунтаймов (Егор Панов, системный администратор Pixonic);
- Практика использования модели акторов в бэкэнд-платформе игры Quake Champions (Роман Рогозин, backend-разработчик Saber Interactive);
- Архитектура мета-сервера мобильного онлайн-шутера Tacticool (Павел Платто, Lead Software Engineer в PanzerDog);
- Как ECS, C# Job System и SRP меняют подход к архитектуре (Валентин Симонов, Field Engineer в Unity);
- Принцип KISS в разработке (Константин Гладышев, Lead Game Programmer в 1C Game Studios);
- Cucumber в облаке: использование BDD-сценариев для нагрузочного тестирования продукта (Антон Косякин, Technical Product Manager, ALICE Platform).
Автор: Никита Гук