Часть 1
Часть 2
Часть 3

Dynamic borrow checking вызывает неожиданные вылеты после рефакторинга

В процессе написания статьи я обнаружил ещё один случай вылета нашей игры из-за пересекающегося World::query_mut. Я работаю с hecs уже около двух лет, такие проблемы — это не тривиальные «ой, я случайно сделал вложенными два запроса», с которыми сталкиваешься, только начав работать с библиотекой. Скорее, это ситуация, когда одна часть кода, находящаяся на верхнем уровне, запускает выполняющую что-то систему, а затем независимая часть кода делает что-то простое с ECS где-то глубоко внизу; после крупномасштабного рефакторинга они неожиданно оказываются пересекающимися.

Такое у меня случается не впервые; обычно советуют такое решение: «твой код просто плохо структурирован, поэтому ты сталкиваешься с такими проблемами; необходимо его отрефакторить и спроектировать правильно». Спорить с такими аргументами довольно сложно, потому что по сути своей они правдивы — это происходит, потому что какие-то части кодовой базы спроектированы неоптимально. Проблема в том. что это ещё один случай, когда Rust вынуждает делать рефакторинг там, где бы этого не требовал никакой другой язык. Пересекающиеся архетипы — не всегда преступление, и ECS-решения не на основе Rust (например, flecs) вполне их допускают.

Но эта проблема возникает не только в ECS. У нас она много раз возникала при использовании RefCell<T>, когда два .borrow_mut() создают пересечение и вызывают неожиданный вылет.

Дело в том, что это не всегда вызвано «плохим кодом». Люди говорят, что обойти эту проблему можно, «выполняя заимствование на кратчайшее время», но за это приходится расплачиваться. Очевидно, что это тоже зависит от правильного структурирования кода, но, как мы уже определили, геймдев — это не разработка серверов, а код в нём не всегда организуется оптимальным образом. Иногда в коде может быть цикл, которому нужно использовать что-то из RefCell, и бывает очень логично расширить заимствование на весь цикл, а не заимствовать только там, где это необходимо. Если цикл достаточно большой и вызывает систему, которой та же ячейка может понадобиться где-то ещё (обычно для условной логики), то это способно сразу создать проблему. Кто-то снова может сказать «просто используй косвенность и выполняй условную логику через событие», но в таком случае мы снова идём на компромисс: геймплейная логика не будет двадцатью строками понятного читаемого кода, а окажется разбросанной по всей кодовой базе.

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

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

Контекстные объекты недостаточно гибки

Так как Rust накладывает на программистов достаточно уникальные требования, в конечном итоге он создаёт множество порождаемых им самим проблем, решения которых не всегда часто встречаются в других языках.

Примером этого может служить передаваемый контекстный объект. Почти в любом другом языке введение глобального состояния не становится особой проблемой, будь то глобальные переменные или синглтоны. Однако по всем приведённым в моей статье примерам Rust, к сожалению, сильно усложняет этот процесс.

Первое решение, которое может прийти в голову — это просто хранить ссылки на всё, что понадобится в дальнейшем, но те, кто пользуется Rust уже какое-то время, знают, что это будет попросту невозможно. Borrow checker потребует, чтобы отслеживалось время жизни каждого поля ссылки, а поскольку времена жизни становятся дженериками, отравляющими каждую точку применения типа, с ними не так легко экспериментировать.

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

struct Thing<'a>
  x: &'a i32
}

Проблема в том, что теперь если мы захотим сделать fn foo(t: &Thing) , то... разумеется, не сможем: Thing — это дженерик, так что это превратится в fn foo<'a>(t: &Thing<'a>), а то и во что-то похуже. То же самое справедливо, если мы попробуем хранить Thing в другой struct; теперь мы получим следующее:

struct Potato<'a>,
  size: f32,
  thing: Thing<'a>,
}

и даже если Potato не интересует Thing, времена жизни в Rust воспринимаются со всей серьёзностью, так что мы не можем их игнорировать. На самом деле всё гораздо хуже, чем кажется: представим, что вы решили пойти по этому пути и попытались решать задачи при помощи времён жизни.

Rust не допускает неиспользуемые времена жизни, так что, допустим, если у вас есть

struct Foo<'a> {
    x: &'a i32,
}

но при рефакторинге кодовой базы вам в конечном итоге захочется поменять это на

struct Foo<'a> {
    x: i32,
}

А это, разумеется, абсолютно запрещено, потому что возникнет неиспользуемое время жизни, а их быть не должно. Возможно, это кажется незначительным, а в в некоторых языках это в самых простых случаях иногда даже желательно, но проблема времён жизни часто требует тратить много времени на «решение проблем» и «отладку», когда ты пробуешь разное. Эксперименты со временами жизни службы часто подразумевают их добавление или удаление, а удаление времени жизни часто означает «теперь это не используется, нужно удалить его отовсюду», что приводит к огромным каскадным рефакторингам. За годы я много раз пытался по этому пути и если откровенно, самое раздражающее в нём то, что попытка внесения очень простого изменения с временами жизни приводит к изменению ещё в десяти разных местах.

Но даже если такого не происходит, во многих случаях мы всё равно не можем просто «хранить ссылку на что-то», потому что не сможем разобраться с временем жизни.

Одна из предоставляемых Rust альтернатив — shared ownership вида Rc<T> или Arc<T>. Разумеется, это сработает, но сильно не приветствуется. Проработав с Rust какое-то время, я осознал, что на самом деле это может сохранить вам немало нервных клеток, но при этом вам придётся больше не рассказывать своим пользующимся Rust друзьям о коде, который вы пишете, или, по крайней мере, скрывать его и притворяться, что его не существует.

К сожалению, во многих случаях shared ownership всё равно остаётся плохим решением, например, из соображений производительности, но иногда у вас просто нет контроля над владением и вы можете только получить ссылку.

Главная хитрость в разработке игр на Rust такова: «Если вы в каждом кадре передаёте ссылки сверху вниз, все ваши проблемы с временами жизни/ссылками пропадают». И эта хитрость работает очень хорошо, она схожа с передачей сверху вниз props в React. Есть только одна проблема: теперь вам нужно передавать всё в каждую функцию, которой это требуется.

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

К счастью для нас, существует реальное решение — создавать передаваемую контекстную struct, содержащую все эти ссылки. В конечном итоге у неё будет время жизни, но только одно, а выглядеть это всё будет примерно так:

struct Context<'a> {
  player: &'a mut Player,
  camera: &'a mut Camera,
  // ...
}

После этого каждая функция в вашей игре может принимать простой c: &mut Context и получать всё, что ей необходимо. Здорово, правда?

Ну, вообще, только в том случае, если вы ничего не заимствуете. Представьте, что вам нужно выполнять систему игрока, но также управлять камерой. player_system, как и всё остальное в игре, требует c: &mut Context, потому что нужно обеспечивать согласованность и стараться не передавать десяток разных параметров. Но если попытаться сделать так:

let cam = c.camera;

player_system(c);

cam.update();

то вы получите привычное «нельзя заимствовать c, потому что её уже позаимствовали», так как мы затронули поле, а правило частичного заимствования гласит, что если затрагиваешь элемент, он целиком становится заимствованным.

Не имеет значения, что player_system затрагивает только c.player, Rust не волнует, что находится внутри, его волнует только тип, а тип говорит, что ему требуется c, поэтому он должен получить c. Этот пример может показаться глупым, но в крупных проектах с большими контекстными объектами, к сожалению, достаточно часто некое подмножество полей требуется в одном месте, при этом остальную часть полей нужно передать куда-то ещё.

К счастью для нас, Rust не так уж туп и он позволит нам выполнить player_system(c.player), потому что частичные заимствования позволяют заимствовать несвязанные поля.

На этом моменте защитники borrow checker скажут, что вы просто спроектировали свой контекстный объект неправильно и что нужно разбить его на несколько контекстных объектов или сгруппировать поля по способу их использования, чтобы можно было использовать частичные заимствования. Допустим, всё, что связано с камерой, должно находиться в одном поле, а всё, связанное с игроком — в другом, после чего мы можем просто передавать в player_system это поле, а не c целиком, и все вроде бы будут довольны, ведь так?

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

Проблема здесь в том, что код меняется не из-за изменений в бизнес-логике, он меняется, потому что компилятор недоволен чем-то, что в фундаментальном отношении корректно. Возможно, это не соответствует тому, как работает borrow checker, потому что он смотрит только на типы, но корректно в том смысле, что если бы мы просто передали все поля, то всё бы замечательно скомпилировалось. Rust заставляет нас делать выбор между передачей семи разных параметров или рефакторингом struct каждый раз, когда что-то меняется; оба эти варианта раздражают и заставляют впустую тратить время.

У Rust нет структурной системы типов, в которой мы могли бы сказать «тип, имеющий эти поля», как нет и любого другого решения этой проблемы, которое бы работало без необходимости переопределения struct и всего, что её использует. Язык просто заставляет программиста делать всё «корректно».

Положительные стороны Rust

Несмотря на то, что вся моя статья в большой степени направлена против Rust, мне бы хотелось перечислить те стороны, которые я считаю положительными и которые очень помогли нам в разработке.

Если код компилируется, то часто он просто работает. Это в какой-то степени мем, но не полностью. Много раз для меня оказывалось неожиданностью то, насколько далеко можно развить концепцию «compiler driven development» и при этом преуспевать. Самое большое преимущество Rust в том. что если ты пишешь подходящий для Rust код, всё идёт очень хорошо, а язык направляет пользователя по верному пути.

С моей точки зрения, самая сильная сторона здесь — это инструменты CLI, манипуляции с данными и алгоритмы. Я потратил достаточно много времени на, по сути, написание «скриптов Python на Rust». То есть я писал на Rust небольшие утилиты, для создания которых большинство обычно использует Python или Bash (я делал это и для обучения, и чтобы проверить, сработает ли). Достаточно часто для меня оказывалось сюрпризом, что это действительно срабатывало. Я определённо не хотел бы повторять то же самое на C++.

Производительность по умолчанию. Когда мы возвращались к C#, я более подробно сравнивал производительность Rust и C#, пытаясь сопоставить конкретные алгоритмы один к одному между двумя языками; при этом я старался сделать производительность максимально схожей. Но даже после усилий по оптимизации C# код на Rust обгонял его примерно в 1,5-2,5 раза. Вероятно, это не стало неожиданностью для тех, кто внимательно следит за бенчмарками, но пройдя этот путь самостоятельно и приложив действительно серьёзные усилия, я был очень приятно удивлён тому, насколько естественно коду Rust удаётся быть очень быстрым.

Стоит отметить, что компилятор Burst движка Unity достаточно сильно повышает производительность C#, но у меня недостаточно A/B-данных, чтобы привести конкретные числа; я наблюдал только существенное ускорение самого C#.

Тем не менее, за все годы работы с Rust меня постоянно приятно удивляло качество исполнения кода, даже несмотря на то, что я делал очень глупые вещи, которые часто люблю делать. Стоит отметить, что сужу я, основываясь на следующих параметрах в Cargo.toml

[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 1

Очень многие люди спрашивали, почему всё работает так медленно, но потом узнавали, что они просто выполняют отладочную сборку. Rust очень быстр при включенных оптимизациях, и он столь же медленен при отключенных оптимизациях. Я использую opt-level = 1 вместо 3, потому что при тестировании я не заметил разницы в скорости, но 3 немного медленнее; по крайней мере, тот код, на котором я выполнял тестирование.

Enum реализованы очень красиво. Наверно, это знают все, кто работает с Rust: лично я постепенно перехожу к более динамической структуризации, отказываясь от строгой структуризации на основе enum и сопоставления с образцом, но в тех случаях, когда применение enum обосновано, с ними очень удобно оказывается работать; наверно, это самая любимая их реализация из всех языков, с которыми я работал.

Анализатор Rust. Я не знал точно, к положительным или к отрицательным сторонам его отнести. Отнесу к положительным, потому что я точно больше не смогу писать на Rust без него. Я начал работать с Rust примерно в 2013 году, и должен сказать, что инструментарий языка сильно улучшился и стал крайне полезным.

К отрицательным сторонам я думал отнести его потому, что это всё ещё один из самых поломанных языковых серверов, которыми я пользовался. Я понимаю, что это вызвано тем, что Rust — это сложный язык, а поговорив об этом со многими людьми, я решил, что мои проекты, возможно, просто немного прокляты (и, наверно, в этом моя вина): у меня он постоянно ломается и не работает (да, я обновлялся, это происходило больше года на разных машинах/проектах). Но несмотря на всё это, он невероятно полезен и очень помогает в написании кода на Rust.

Трейты. Хотя я не совсем фанат полного избавления от наследования, я считаю систему трейтов довольно удобной и хорошо подходящей Rust. Она бы была просто замечательной, если бы иногда давались послабления правилу сироты. Тем не менее, возможность применения расширяемых трейтов — одна из моих любимых функций языка.

В заключение

Мы использовали Rust практически во всех наших играх с середины 2021 года. BITGUN, начинавшийся как проект только на Godot/GDScript, столкнулся с проблемами поиска пути Godot (с точки зрения как производительности, так и функциональности). Я начал искать альтернативы, нашёл gdnative, а затем мне порекомендовали godot-rust. Это был не первый для меня случай работы с Rust, но первое серьёзное его применение в разработке игр (до этого мы создавали на нём проекты в стиле геймджемов).

С тех пор я начал использовать Rust для всего. Меня восхитила возможность создания собственного рендерера/фреймворка/движка, так и родились первые версии Comfy. За ним последовало многое другое: маленькие прототипы трассировки лучей на CPU для геймджема, эксперименты с простой двухмерной инверсной кинематикой, попытки написать физический движок, реализация деревьев поведений, реализация однопоточного асинхронного исполнителя с упором на корутины, создание симуляторного NANOVOID и, наконец, Unrelaxing Quacks, — наша первая и последняя игра на движке Comfy, которую мы недавно выпустили в Steam.

Источником вдохновения для этой статьи в большей мере стали наши трудности при работе с NANOVOID и Unrelaxing Quacks, потому что эти проекты не были отягощены нехваткой знаний о Rust, как это было при работе над BITGUN. В этих проектах мы также многократно пробовали работать с большой частью экосистемы разработки игр на Rust. Мы несколько раз пробовали Bevy: BITGUN стала первой игрой, которую мы попытались на него портировать, а Unrelaxing Quacks — последней. На протяжении двух лет разработки того, что позже превратится в Comfy, рендерер был переписан с OpenGL на wgpu, потом снова на OpenGL и опять на wgpu. На момент написания статьи я пишу код уже примерно двадцать лет: начал с C++, а затем переходил на всевозможные языки — PHP, Java, Ruby, JavaScript, Haskell, Python, Go, C#. Выпустил в Steam игры на UnityUnreal Engine 4, и Godot. Я тот человек, который любит экспериментировать и пробовать различные подходы, чтобы убедиться, что я ничего не упускаю. Возможно, наши игры неидеальны, зато мы тщательно исследовали возможные варианты, надеясь найти наиболее предпочтительное решение.

Я говорю это, чтобы рассеять всяческие сомнения в том, что я вложил в Rust достаточно усилий, и чтобы дать понять, что эта статья не написана с точки зрения невежи. Когда кто-то указывает на проблемы Rust как языка, ему первым делом шутливо говорят «у тебя просто недостаточно опыта, чтобы оценить это». Но в нашем случае это не так. Мы многократно пробовали и более динамические, и статические подходы. Мы пробовали чистую ECS и пробовали полное отсутствие ECS.

Тем, кто волнуется о будущем Comfy, я скажу следующее.

По большей части Comfy «завершён» с точки зрения 2D-игр. Свидетельством этого может быть то, что мы выпустили на нём готовую игру; стоит также сказать, что наша игра работает на основной ветке движка. Если вы хотите создать что-то с таким же уровнем сложности и качества, это доказывает, что такое возможно.

Тем не менее, по-прежнему есть желательная функциональность, которую Comfy пока не предоставляет, а именно: улучшения в области пользовательских шейдеров и проходов постобработки. Также есть вопрос «будущего поддержки», потому что я больше не собираюсь работать над играми на Rust.

Те, кто активен в нашем Discord, уже знают, что мы планируем портировать рендерер Comfy на Macroquad, то есть полностью удалить все следы wgpu и winit, а для работы с окнами, вводом и рендерингом использовать Macroquad. На то есть несколько причин:

  • Можно будет просто удалить большую часть кодовой базы, а также множество странных пограничных случаев. Пользователи не получают ничего полезного от «собственного wgpu-рендерера», самое главное — это функциональность, а она не изменится.

  • Шейдеры и постобработка становятся гораздо более гибкими, просто потому, что в Macroquad уже всё готово.

  • Большее количество поддерживаемых платформ, потому что Macroquad/Miniquad широко поддерживаются сообществом, а Comfy сейчас работает только на платформах, где работает wgpu.

  • Стабильное будущее, в котором Comfy может стать удобным высокоуровневым слоем поверх чего-то, поддерживаемого сообществом.

Возможно, кто-то спросит, почему этого не сделали изначально. Первоначально некоторые из наших проектов на Comfy писались поверх Macroquad, но в какой-то момент я захотел добавить HDR и Bloom, которые Macroquad не поддерживал. Comfy был создан копипейстингом API Macroquad и добавлением в него z-индексирования и y-сортировки с повторной реализацией всего внутреннего рендерера.

Но недавно Miniquad/Macroquad начал поддерживать текстуры f16, то есть мы можем избавиться от всего этого без необходимости собственного рендерера. Уже ведутся работы по портированию, но они были не очень активны, потому что мы стремились вовремя выпустить Unrelaxing Quacks. Однако я планирую продолжить работу над этим, а учитывая, что основное уже работает, надеюсь, что порт не будет слишком сложным.

Комментарии (2)


  1. domix32
    30.05.2024 10:09

    Пользователи не получают ничего полезного от «собственного wgpu-рендерера»,

    Конечно несколько странное утверждение. Wgpu нужен фактически для кроссплатформенного API для графики - opengl/directx/metal/web gpu


  1. Mingun
    30.05.2024 10:09
    +1

    Вроде автор опытен в Rust, но какие-то странно проблемы находит. Если для эксперимента нужно удалить все lifetime-ы, то можно добавить в структуру PhantomData<&'a ()>. Обычно мест конструирования гораздо меньше, чем мест, где потенциально придется удалить 'a.

    Хочется контекст со всеми данными, но не позволяют его разделить? Ну, если нам и так borrow checker мешает, то почему бы с помощью преобразования в *mut и обратно в &mut не поменять время жизни (или совсем его удалить)? Можно любое выбрать, для экспериментов сойдет.