![](https://habrastorage.org/getpro/habr/upload_files/2a4/429/f83/2a4429f83a3308fbc30fe046f9713f9e.png)
Предисловие: этот пост представляет собой очень длинный перечень мыслей и проблем, возникавших у меня за годы работы; также в нём рассматриваются некоторые из аргументов, которые мне часто говорили. В посте выражено моё мнение, сформировавшееся у меня в процессе разработки игр на Rust в течение многих тысяч часов на протяжении многих лет и после множества завершённых игр. Это не хвастовство и не показатель успеха, я просто хочу сказать, что вложил достаточно много усилий в Rust; здесь не получится сказать «когда наберёшься опыта, тебе всё станет понятно».
Пост не будет ни научной оценкой, ни A/B-исследованием. Это моё личное мнение после разработки игр на Rust маленькой инди-командой (два человека) в попытках заработать достаточно денег, чтобы финансировать процесс. Мы не одни из тех разработчиков с бесконечными финансами от инвестора и многолетним запасом времени. Если вы находитесь в этой категории и получаете удовольствие от многолетней разработки систем, то всё написанное ниже к вам не относится. Я рассматриваю всё с такой точки зрения: «Мне хочется создать игру максимум за 3-12 месяцев, чтобы люди могли сыграть в неё, а я — немного заработать». Статья не написана с точки зрения «Я хочу изучить Rust, а разработка игр — это весело», хотя это и вполне нормальная цель; просто она никак не согласуется с тем, чего хотим мы — заниматься разработкой игр коммерчески успешным и самодостаточным образом.
Мы выпустили несколько игр на Rust, Godot, Unity и Unreal Engine, и многие люди сыграли в них в Steam. Мы создали с нуля собственный игровой 2D-движок с простым рендерером, а также в течение нескольких лет использовали Bevy и Macroquad во многих проектах, некоторые из которых были очень нетривиальными. Кроме того, я бэкенд-разработчик на полную ставку и пишу код на Rust. Этот пост — не какое-то поверхностное мнение после изучения нескольких туториалов или разработки небольшой игры для геймджема. За три с лишним года мы написали сильно больше ста тысяч строк кода на Rust.
Задача этого поста — развеять популярные и часто повторяемые аргументы. Но это всё-таки субъективное мнение; по большей части я написал пост, чтобы не объяснять снова и снова одно и то же. Пусть это будет справочный материал о том, почему мы, скорее всего, откажемся от Rust как от инструмента для разработки игр. Мы ни в коем случае не планируем прекращать создавать игры, просто не будем делать это на Rust.
Если ваша цель — изучить Rust, потому что он кажется интересным и вам нравятся технические трудности, то это совершенно нормально. Однако в этом посте я в том числе хотел обсудить то, в каком свете часто представляют разработку игр на Rust и какие советы люди часто дают другим, даже не зная, создают ли те техническое демо или пытаются выпустить готовую игру. В массе своей сообщество слишком сосредоточено на технологиях, вплоть до того, что слово «игры» в словосочетании «разработка игр» оказывается вторичным. Например, я вспоминаю одну беседу на Rust Gamedev Meetup: вероятно, это было сказано в шутку, но, как мне кажется, демонстрирует проблему. Кто-то сказал: «Можно ли показать на встрече игру, допускается ли это вообще?» Не хочу сказать, что у всех должны быть те же цели, что и у нас, но считаю, что многие аспекты необходимо проговаривать чётче, а люди должны честнее говорить о том, что они делают.
Как только ты достаточно освоишь Rust, все эти проблемы исчезнут
Изучение Rust — интересный опыт, потому что хотя многие вещи кажутся какой-то особенной проблемой, «которая возникла только у меня», позже приходит осознание, что есть несколько фундаментальных паттернов, и что каждый обучающийся должен открыть их для себя, чтобы быть продуктивным. Это могут быть простые вещи, например различия между &str
и String
или между .iter()
и .into_iter()
, а также необходимость постоянного их использования, или просто осознание того, что partial borrow часто противоречит некоторым абстракциям.
Многие из этих вещей — это просто проблемы обучения, и после наработки опыта разработчик уже привыкнет к ним и станет продуктивным. Мне очень нравилось писать различные утилиты и инструменты CLI на Rust, когда выяснялось, что он более продуктивен, чем Python во всём, что выходит за рамки нескольких строк кода.
Тем не менее, в сообществе Rust существует серьёзная проблема: когда кто-то говорит, что у него возникают трудности с языком Rust на фундаментальном уровне, ему отвечают «ты просто ещё не разобрался, когда наберёшься опыта, всё станет понятно». Такое происходит не только с Rust: если вы попытаетесь работать с ECS, то вам будут говорить то же. Если вы попробуете работать с Bevy, то вам тоже так скажут. Если вы захотите создавать GUI на любом выбранном фреймворке, то услышите то же самое. Твоя проблема стала проблемой только потому, что ты недостаточно упорно старался.
Я верил этому многие годы. Я упорно пытался многие годы. Я видел, что такое происходило на разных уровнях языка, и в некоторых областях стал очень продуктивным, научившись угадывать, чего хочет язык и система типов, чтобы избежать таких проблем.
Но спустя примерно три года, написав более ста тысяч строк кода для игр в целой экосистеме фреймворков/движков и создав собственный движок, должен сказать, что многие проблемы (а может, и большинство) не исчезают, если только вы не готовы постоянно рефакторить свой код и не воспринимать программирование как процесс решения головоломок.
Самая фундаментальная проблема заключается в том, что borrow checker вынуждает выполнять рефакторинг в самые неудобные моменты. Разработчики на Rust считают это положительным моментом, потому что это заставляет их «писать хороший код», но чем больше я работаю с языком, тем сильнее сомневаюсь, что это так. Хороший код пишется благодаря итеративной работе с идеями и экспериментам, и хотя borrow checker может заставить увеличить количество итераций, это не означает, что таков желательный способ написания кода. Часто я обнаруживал, что именно невозможность просто пойти дальше, решить свою задачу, а проблему устранить позже, мешает писать хороший код.
На других языках можно писать код с мыслью «потом я это выкину»; для меня это один из самых полезных подходов для создания хорошего кода. Например, я реализую контроллер игрока. Мне нужно, чтобы игрок просто двигался и выполнял действия, после чего я мог бы приступить к созданию уровня и врагов. Мне не нужен хороший контроллер, мне просто нужно, чтобы он что-то делал. Позже я могу просто удалить его и написать более качественный. В Rust иногда просто что-то сделать невозможно, потому что действие, которое вам необходимо, недоступно в том месте, где вы хотите выполнить это действие; в конечном итоге компилятор заставляет вас выполнить рефакторинг, даже если вы знаете, что код потом отправится в мусорное ведро.
То, что Rust прекрасен в рефакторинге, в основном решает его собственные проблемы с borrow checker
Очень часто говорят, что одна из самых сильных сторон Rust — простота рефакторинга. Совершенно верно, и я много раз бесстрашно рефакторил существенные части своей кодовой базы, после чего всё работало без проблем. Значит, реклама права?
На самом деле, Rust — это ещё и язык, заставляющий пользователя выполнять рефакторинг намного чаще, чем в других языках. Вас очень легко может загнать в угол borrow checker, после чего вы осознаете: «А я ведь не могу добавить эту новую штуку, потому что код перестанет компилироваться, и обойти это без реструктурирования кода невозможно».
На этом моменте опытные люди часто говорят, что эта проблема становится менее серьёзной, когда вы лучше освоитесь в языке. Я считаю, что хотя это действительно правда, у игр есть фундаментальная проблема — это сложные конечные автоматы, требования которых постоянно меняются. Написание CLI или серверного API на Rust совершенно отличается от написания инди-игры. Если наша цель — создать хороший игровой процесс для игроков, а не нейтральный набор систем общего назначения, требования могут меняться ежедневно; люди просто играют в игру и вы понимаете, что некоторые вещи требуют фундаментальных изменений. Крайне статическая и чрезмерно контролируемая природа Rust полностью противоречит этому.
Многие люди возразят, что если в конечном итоге вам приходится сражаться с borrow checker или рефакторить свой код, это на самом деле хорошо, ведь код становится лучше. Мне кажется, это здравый аргумент в случае, когда вы знаете, что создаёте. Но в большинстве случаев мне нужен не «качественный код», а «быстрее сыграть», чтобы я мог быстрее протестировать игру и убедиться, что идея хороша. Часто приходится делать выбор между «нарушить ли своё состояние потока и потратить два часа на рефакторинг, чтобы проверить идею, или сделать кодовую базу объективно хуже?».
Я утверждаю, что удобство поддержки — это ошибочная ценность для инди-игр, потому что нам в первую очередь нужно стремиться к скорости итераций. Другие языки позволяют гораздо проще обходить непосредственно возникающие проблемы без того, чтобы жертвовать качеством кода. В Rust выбор всегда выглядит так: добавить ли одиннадцатый параметр к этой функции или добавить ещё один Lazy<AtomicRefCell<T>>
, или поместить ли это в ещё один божественный объект, или добавить косвенность и снизить удобство итераций, или потратить время на очередное перепроектирование этой части кода.
Косвенность решает только некоторые проблемы, и всегда ценой эргономики разработки
У Rust есть одно фундаментальное решение, которое он очень любит и которое часто срабатывает: добавление слоя косвенности. Канонический пример этого — события Bevy, которые стали рекомендованным решением для всего, что связано с проблемой «для выполнения своей задачи моей системе требуется 17 параметров». Я пробовал быть и на той, и на другой стороне: и активно использовал события, особенно в контексте Bevy, и пытался просто поместить всё в единую систему. Однако это лишь один пример.
Многие проблемы с borrow checker можно обойти, выполняя действия косвенно. Или если скопировать/переместить что-то, выполнить задачу, а затем вернуть всё обратно. Или если сохранить что-то в буфер команд и выполнить это позже. Часто это может привести к открытию чего-то нового в шаблонах проектирования, например, довольно удобным мне показалось то, что большую часть проблем можно решить, заранее зарезервировав id сущностей (например, World::reserve в hecs
, обратите внимание, что там &world
, а не &mut world
) в сочетании с буфером команд. Эти шаблоны потрясающи, когда работают, и они решают очень сложные проблемы. Ещё один пример — это кажущийся очень специализированным get2_mut в thunderdome
, который поначалу кажется странной идеей, пока не понимаешь, что это возникает постоянно и решает множество неожиданных проблем.
Я не буду вдаваться в споры о том, разумна ли кривая обучения по пути к продуктивности. Это определённо не так, но мой пост посвящён проблемам, существующим на фундаментальном уровне даже после получения достаточного опыта.
Вернёмся к нашей теме: хотя некоторые из приведённых выше решений позволяют избежать конкретных проблем, очень часто возникает ситуация, которую нельзя разрешить при помощи специализированной и хорошо продуманной библиотечной функции. Именно в таких случаях срабатывает решение проблем при помощи подхода «просто будем выполнять проблемные вещи позже» при помощи буфера команд и очереди событий, который предлагают многие.
Проблема конкретно игр заключается в том, что нам часто важны взаимосвязанные события, конкретные тайминги и одновременное управление большой частью состояния. Из-за перемещения данных через барьер событий логика кода для действия внезапно оказывается разделённой на две части; при этом даже если бизнес-логика может быть «одним блоком», когнитивно с ним нужно работать, как с двумя.
Всем, кто находится в сообществе Rust достаточно долго, обязательно говорили, что на самом деле это хорошо: разделение элементов, код становится «чище» и так далее. Видите ли, Rust спроектирован продуманным образом, и если что-то нельзя сделать, это значит, что дизайн плох, и язык просто заставляет вас двигаться по правильному пути... так ведь?
То, что могло быть тремя строками на C#, внезапно превращается в тридцать строк Rust, разбитые на две части. Самый каноничный пример: «пока я итеративно обрабатываю этот запрос, мне нужно проверить компонент другой вещи и затронуть несколько связанных систем» (создавать частицы, воспроизводить звук и так далее). Я представляю, что мне скажут: ну, так ведь это, очевидно, Event
, не нужно писать этот код линейно.
Представьте, какой ужас начнётся, если нам нужно будет сделать нечто подобное (крепитесь, ниже код для Unity; или просто сделайте вид, что это Godot):
if (Physics.Raycast(..., out RayHit hit, ...)) {
if (hit.TryGetComponent(out Mob mob)) {
Instantiate(HitPrefab, (mob.transform.position + hit.point) / 2).GetComponent<AudioSource>().clip = mob.HitSounds.Choose();
}
}
Это относительно простой пример, но он вполне может потребоваться в игре. И особенно при реализации новой механики или тестировании такой код можно просто написать. Тут не нужно думать о каком-то удобстве поддержки, мне просто нужно сделать очень простые вещи, и я хочу, чтобы они происходили там, где должны происходить. Мне не нужно MobHitEvent
, потому что, вероятно, для этого raycast мне придётся проверить ещё пять разных вещей.
Ещё я не хочу проверять, «есть ли у Mob
компонент Transform
» Разумеется, он есть, я ведь делаю игру, у всех моих сущностей есть transform. Но Rust не позволит мне иметь .transform
, не говоря уже о такой его реализации, которая никогда не поломается с double borrow error, если я случайно окажусь внутри запросов с пересекающимися архетипами.
Возможно, мне также не нужно проверять, существует ли источник звука. Разумеется, я могу сделать .unwrap().unwrap()
, но внимательный любитель Rust заметит отсутствие передачи world
— мы что, предполагаем глобальный мир? Разве мы не используем инъекцию зависимости для записи нашего запроса как ещё одного параметра в системе, где всё подготовлено заранее? Неужели .Choose
предполагает наличие глобального генератора случайных чисел? А как же потоки??? А где конкретно мир физики, неужели мы серьёзно допускаем, что он тоже будет глобальным?
Если вы думаете «но такой код не будет масштабироваться», или «позже он может начать вылетать», или «нельзя допускать наличие глобального мира по причине XYZ», или «а что если появится мультиплеер», или «это просто плохой код», то я с вами соглашусь. Но к тому времени, когда вы закончите мне объяснять, почему я неправ, я уже закончу реализовывать фичу и пойду дальше. Я написал свой код за один проход, не задумываясь о нём, и в процессе его написания я думал о реализуемой геймплейной фиче и о том, как она повлияет на игрока. Я не думаю «как будет правильно реализовать здесь генератор случайных чисел», или «могу ли я допустить, что этот код будет однопоточным», или «нахожусь ли я во вложенном запросе и не пересекутся ли мои архетипы». А ещё после этого я не получил ошибку компилятора и вылет borrow checker в среде исполнения. Я работал с тупым языком в тупом движке, и в процессе написания кода думал только об игре.
ECS решает проблему не того типа
Благодаря тому, как устроена система типов Rust и borrow checker, ECS кажется естественным решением проблемы «как заставить одни элементы ссылаться на другие». К сожалению, мне кажется, здесь возникла сильная путаница терминологии: не только разные люди подразумевают разное, но и большая часть сообщества связывает некоторые вещи с ECS, хотя они к ней не относятся. Давайте попробуем их разделить.
Для начала упомянем некоторые вещи, которые мы не можем делать по различным причинам (существуют определённые нюансы, но я упрощу, потому что статья и так вышла слишком длинной):
Указателеподобные данные и настоящие указатели. Проблема здесь проста: если персонаж A следует за B, а B удаляется (и освобождается), то указатель будет неверным.
Rc<RefCell<T>>
в сочетании со слабыми указателями. Хотя это и может работать, в играх важна производительность, и излишняя трата ресурсов при этом велика из-за локальности памяти.Индексация в массивах сущностей. В первом случае у нас получается неверный указатель, а в этом случае, если у нас есть индекс и мы удаляем элемент, то индекс по-прежнему может оставаться верным, но указывать на что-то ещё.
Теперь я расскажу о волшебном решении, позволяющем избавиться от всех этих проблем — generational arena; наилучшим образом его действие показывает thunderdome
. (Кстати, я крайне рекомендую эту библиотеку, потому что она маленькая и лёгкая, к тому же выполняет то, что должна, при этом сохраняя читаемость своей кодовой базы; последний пункт довольно редко встречается в экосистеме Rust.)
По сути, generational arena — это просто массив, только id не служит индексом, это кортеж из (index, generation)
. Сам массив при этом хранит кортежи (generation, value)
; чтобы не усложнять, можно просто представить, что каждый раз когда что‑то удаляется по индексу, мы просто увеличиваем счётчик generation
по этому индексу. Далее нам достаточно сделать так, чтобы индексация в arena всегда проверяла, совпадает ли generation указанного индекса с generation в массиве. Если элемент был удалён, то слот будет иметь более высокую generation, а индекс тогда будет «неверным» и действовать так, как будто элемент не существует. Существуют и некоторые другие проблемы, достаточно простые в решении, например хранение свободного списка слотом, куда можно выполнять вставку, чтобы она была быстрой, но ничего из этого совершенно нерелевантно для пользователя.
Самое главное в том, что это позволяет языку наподобие Rust полностью обойти использование borrow checker и обеспечить «ручное управление памятью при помощи arenas», не притрагиваясь к опасным указателям, и гарантировать полную безопасность. Если бы меня попросили сказать, что мне нравится в Rust, то я бы упомянул именно это. Особенно вместе с библиотекой наподобие thunderdome
; это очень хорошее сочетание, а эта структура данных хорошо подходит для языка.
А теперь мы переходим к интересному. То, что многие люди считают преимуществами ECS, на самом деле, по большей мере, оказывается преимуществами generational arena. Когда люди говорят: «ECS обеспечивает отличную локальность памяти», но их единственный запрос, связанный с mob, выглядит как Query<Mob, Transform, Health, Weapon>
, то создаваемое ими, по сути, оказывается эквивалентом Arena<Mob>
, где struct определена следующим образом:
struct Mob {
typ: MobType,
transform: Transform,
health: Health,
weapon: Weapon
}
Разумеется, подобные определения не имеют всех преимуществ ECS, но мне кажется, нужно чётко сказать: то, что мы работаем с Rust, и то, что мы не хотим, чтобы всё было Rc<RefCell<T>>
, не означает, что нам нужна ECS, это просто может значить, что на самом деле нам нужна generational arena.
Вернёмся к ECS: существует несколько интерпретаций ECS, которые сильно отличаются друг от друга:
ECS как динамическая композиция, обеспечивающая возможность совместного хранения, запрашивания и изменения комбинаций компонентов без необходимости привязки к одному типу. Очевидный пример: работая с Rust, многие люди помечают сущности с компонентами «state» (потому что никаких других правильных способов сделать это нет). Например, нам нужно выполнить запрос ко всем Mob
, но, возможно, некоторые из них были преобразованы в другой тип. Мы можем просто выполнить world.insert(entity, MorphedMob)
, и тогда в нашем запросе мы можем выполнить или запрос (Mob, MorphedMob)
, или что-то типа (Mob, Not<MorphedMob>)
, или (Mob, Option<MorphedMob>)
, или проверить присутствие нужного компонента в коде. В разных реализациях ECS может оказаться, что эти операции выполняют разные действия, но на практике мы используем их, чтобы «пометить» или «разделить» сущности.
Композиция может быть и гораздо разнообразнее. Представленный выше пример тоже сюда подходит: вместо создания одной большой struct Mob
можно создать отдельные Transform
, Health
, Weapon
, а может, и другие элементы. Mob без оружия может не иметь компонент Weapon
, а после того, как он подберёт оружие, мы вставим его в сущность. Это позволит нам итеративно работать со всеми mob с оружием в отдельной системе.
К динамической композиции я также отнесу методику EC из Unity; хотя она может и не быть традиционной чистой «ECS с системами», она активно использует компоненты для композиции, и, если не учитывать вопросы производительности, обеспечивает практически те же самые возможности, что и «чистая ECS». Также я бы хотел упомянуть систему нодов Godot, в которой дочерние ноды часто используются как «компоненты», и хотя это никак не связано с ECS, это целиком относится к «динамической композиции», так как позволяет вставлять/удалять ноды в среде исполнения и менять в зависимости от этого поведение сущностей.
Стоит также отметить, что подход «разбиение компонентов на как можно более мелкие части для максимально многократного использования» многие считают достоинством. Меня много раз пытались убедить отделить Position
и Health
от моих объектов, потому что иначе код превратится в спагетти.
Я много раз пробовал эти подходы и теперь настроен абсолютно против них (если, конечно, отсутствует необходимость в максимальной производительности); в этих спорах я уступаю только в случаях, когда для сущностей важна производительность. Попробовав другие подходы с «толстыми компонентами» (до и после попыток разделения компонентов), я считаю, что «толстые компоненты» гораздо лучше подходят для игр, ведь они имеют кучу логики, уникальной для происходящего. Например, моделирование Health
как механизма общего назначения может быть полезным в простой симуляции, но во всех моих играх мне нужна была разная логика для здоровья игрока и здоровья врага. Ещё мне часто оказывалась нужна разная логика для разных типов сущностей, например, здоровья стены и здоровья монстра. Если бы я попытался обобщить это до «одного здоровья», то пришлось бы писать в системе здоровья непонятный код с кучей if player { ... } else if wall { ... }
вместо того, чтобы сделать их частью большой толстой системы игрока или стены.
ECS как динамическая структура массивов, в которой благодаря способу хранения компонентов ECS мы можем получить преимущество, выполняя итерации, например, только для компонентов Health
и храня их в памяти рядом друг с другом. Это значит, что вместо Arena<Mob>
у нас было бы следующее:
struct Mobs {
typs: Arena<MobType>,
transforms: Arena<Transform>,
healths: Arena<Health>,
weapons: Arena<Weapon>,
}
Здесь значения по одному индексу будут принадлежать к одной «сущности». Выполнение этого вручную было бы мучительным процессом; возможно, вам рано или поздно приходилось этим заниматься. Но благодаря современному ECS мы можем получить это как бы «бесплатно», просто написав наши типы в виде кортежа, после чего внутренняя магия хранения объединит нужные элементы.
Можно также назвать этот сценарий использования ECS как способ обеспечения производительности, если смысл выполнения этого не в том. что «мы хотим обеспечить композицию», а «потому, что мы хотим больше локальности памяти». У такого подхода вполне могут быть области применения, но я думаю, что для подавляющего большинства выпускаемых их это необязательно. Я не просто так сказал «выпускаемых», потому что, разумеется, очень легко создать чудовищно сложные прототипы, которые потребуют этого, но они будут бесконечно далеки от игр, в которые люди играют, а значит, и не относятся к теме нашей статьи.
ECS как решение проблемы borrow checker Rust: на мой взгляд, именно это делает большинство людей, использующих ECS, а точнее, именно по этой причине они используют ECS. ECS — очень популярное решение, рекомендованное для Rust, потому что обычно она позволяет обойти многие проблемы. Нам не нужно заботиться о сроках жизни, достаточно передавать struct Entity(u32, u32)
, ведь всё это удобно, и выполнять Copy
, ведь именно это любит Rust.
Я выделил это отдельным пунктом, потому что часто люди используют ECS, потому что она решает проблему «куда поместить мои объекты» без применения ECS для композиции и без необходимости её производительности. Это абсолютно нормально, но такие люди зачастую начинают спорить, пытаясь убедить других, что их подход ошибочен, и что им нужно использовать ECS определённым образом из-за описанных выше причин, хотя это вообще не требуется.
ECS как динамически создаваемые generational arena: мне бы хотелось, чтобы такой подход существовал, и я попытался создать его, но осознал, что для того, чтобы получить нужное мне, придётся заново изобретать множество вещей, связанных с внутренней изменяемостью, чего я и стремился избежать; и всё это для того, чтобы обеспечить возможность одновременного выполнения вещей наподобие storage.get_mut::<Player>()
и storage.get_mut::<Mob>
. Rust обладает милым свойством: пока ты делаешь вещи нужным ему образом, всё интересно и красиво, но как только ты хочешь сделать то, что ему не нравится, всё быстро превращается в «нужно самостоятельно реализовать собственный RefCell
, выполняющий нужное мне действие», а то и хуже.
Этим пунктом я хочу сказать, что хотя generational arena и удобны, один из самых раздражающих их недостатков заключается в том, что пользователь должен определить переменную и тип для каждой arena, которую он хочет использовать. Разумеется, эту проблему можно решить при помощи ECS, если пользователь использует только один компонент в каждом запросе, но было бы здорово, если бы ему не нужна была полная архетипичная ECS для реализации arena для каждого типа. Разумеется, существуют способы сделать это, но я уже выгорел от того, что приходилось заново изобретать части экосистемы, чтобы делать это самостоятельно.
ECS, потому что Bevy: отчасти это шутка, но я считаю, что из-за популярности Bevy и его всеохватного подхода стоит выделить этот пункт как отдельный взгляд на ECS. Так как для большинства движков/фреймворков ECS является выбором, именно эту библиотеку выбирают для применения. Но когда дело касается игр на Bevy, это становится не чем-то опциональным, используемым для отдельных элементов: вся игра превращается в ECS.
Стоит также отметить, что несмотря на моё несогласие со многим, сложно отрицать, сколько полезного Bevy привнёс в ECS API и в эргономику самой ECS. Все, кто когда-либо пользовался чем-то наподобие specs
, понимает, насколько лучше и удобнее Bevy реализует ECS и насколько он улучшился за годы.
Тем не менее, я считаю, что в то же время это стало основной причиной проблемы взгляда на ECS в экосистеме Rust, и в особенности того, как её реализует Bevy. ECS — это инструмент, конкретный инструмент для решения конкретных задач, и это накладывает ограничения.
Отойду на минуту от нашей темы, чтобы поговорить о Unity. Что бы ни происходило с лицензиями, руководством или бизнес-моделью движка, было бы глупо воспринимать Unity не как одной из основных причин успеха инди-игроделов. Судя по статистике SteamDB, в Steam сейчас есть почти 44 тысячи игр на Unity; на втором месте Unreal c 12 тысячами игр, а остальные движки сильно от них отстают.
Все, кто следил за Unity в последние годы, знает о Unity DOTS, который, по сути, стал «ECS» движка (и другими вещами, ориентированными на данные). Как прошлый, настоящий и будущий пользователь Unity, я в восторге от этой системы, и одна из основных причин восторга заключается в том, что она существует параллельно со старой системой Game Object. Существует множество тонкостей, но по сути своей это именно то, чего можно было ожидать. В одной игре может для чего-то применяться DOTS, а для остального использоваться стандартное дерево сцен с Game Object, и они без проблем работают совместно.
Не думаю, что в мире Unity можно найти разбирающегося в DOTS человека, который бы считал, что это нечто плохое и его не должно существовать. Но я и не думаю, что можно найти человека, считающего, что DOTS — это всё, что понадобится нам в будущем, Game Object необходимо уничтожить, а Unity целиком нужно перенести на DOTS. Даже если забыть о поддерживаемости и обратной совместимости, это было бы очень глупым решением, поэтому существует множество процессов разработки, в которые естественным образом вписывается система с Game Object.
Те, кто работает с Godot, вероятно, имеют ту же точку зрения, особенно если они работали с gdnative
(например, при помощи godot-rust
): хотя деревья нодов могут и не быть идеальной структурой для всего, они крайне удобны для множества ситуаций.
Вернёмся к обсуждению Bevy: мне кажется, не многие люди осознают, насколько всеохватывающим оказывается подход «превратить всё в ECS». На мой взгляд, здесь есть очевидный пример: точка отказа в виде системы UI Bevy, которая долгое время была болевой точкой, особенно учитывая постоянные обещания вида «мы точно начнём работать над редактором в этом году!». Если посмотреть на примеры UI Bevy, сразу становится понятно, что возможностей в нём не очень много, а взглянув на исходный код чего‑то столь простого, как кнопка, меняющая свой цвет при наведении курсора и нажатии, становится понятно, почему. Я пробовал использовать UI Bevy для чего‑то нетривиального, и могу подтвердить, что мучения ещё сильнее, чем кажется, потому что количество церемоний, необходимых для того, чтобы ECS сделал нечто, связанное с UI, абсолютно безумно. Поэтому ближайшее, что походит в Bevy на редактор — это сторонний крейт, использующий egui
. Я сильно упрощаю ситуацию, и, разумеется, для создания редактора нужно гораздо больше, чем UI, но мне кажется, что упорные попытки запихнуть всё, включая UI, в ECS, определённо не помогают развитию.
ECS в Rust имеет плохую тенденцию: она превращается из того, что в другом языке было бы простым инструментом, в нечто, похожее на религиозное верование. В нечто, что необходимо использовать из-за его чистоты и корректности, из-за того, что это решает задачи правильным способом.
Сообщества пользователей языков программирования часто имеют определённые тенденции; за долгие годы я часто перепрыгивал с одного языка на другой, поэтому мне любопытно их сравнивать. Самое близкое к взгляду Rust на ECS — это Haskell; при этом должен сказать, что в целом сообщество Haskell гораздо более зрелое, люди разумнее относятся к существованию других подходов и рассматривают Haskell как «интересный инструмент для решения подходящих задач».
Общение с сообществом Rust же часто похоже на разговор с подростком о любых его предпочтениях. Заявления пользователей часто оказываются категоричными мнениями, не имеющими особых нюансов. Программирование — это занятие с огромным количеством нюансов, программисту часто приходится принимать неоптимальные решения, чтобы прийти к результату вовремя. Доминирование перфекционизма и одержимости «правильным способом» в экосистеме Rust часто заставляет меня думать, что этот язык привлекает новичков в программировании, легко поддающихся влиянию. Разумеется, это относится не ко всем, но я считаю, что общая одержимость ECS в каком‑то смысле стала результатом этого.