Borrow Checker
Rust has a famous borrow checker, that became a sort of horror story for newcomers. It usually treated like an enemy, that prevents your from writing anything useful as you may get used in other languages. In fact, it is a very useful part of Rust that proves correctness of your program and does not let you doing nasty things like memory corruption, data races, etc. This chapter explains how Fyrox solves the most common borrowing issues and makes game development as easy as in any other game engine.
Multiple Borrowing
When writing a script logic there is often a need to do a multiple borrowing of some data, usually it is other scene nodes. In normal circumstances you can borrow each node one-by-one, but in other cases you can't do an action without borrowing two or more nodes simultaneously. In this case you can use multi-borrowing:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct MyScript { some_node: Handle<Node>, some_other_node: Handle<Node>, yet_another_node: Handle<Node>, } impl ScriptTrait for MyScript { fn on_update(&mut self, ctx: &mut ScriptContext) { // Begin multiple borrowing. let mbc = ctx.scene.graph.begin_multi_borrow(); // Borrow immutably. let some_node_ref_1 = mbc.try_get(self.some_node).unwrap(); // Then borrow other nodes mutably. let some_other_node_ref = mbc.try_get_mut(self.some_other_node).unwrap(); let yet_another_node_ref = mbc.try_get_mut(self.yet_another_node).unwrap(); // We can borrow the same node immutably pretty much infinite number of times, if it wasn't // borrowed mutably. let some_node_ref_2 = mbc.try_get(self.some_node).unwrap(); } } }
As you can see, you can borrow multiple nodes at once with no compilation errors. Borrowing rules in this case are enforced at runtime. They're the same as standard Rust borrowing rules:
- You can have infinite number of immutable references to the same object.
- You can have only one mutable reference to the same object.
Multi-borrow context provides detailed error messages for cases when borrowing has failed. For example, it will tell you if you're trying to mutably borrow an object, that was already borrowed as immutable (and vice versa). It also provides handle validation and will tell you what's wrong with it. It could be either invalid index of it, or the generation. The latter means that the object at the handle was changed and the handle is invalid.
The previous example looks kinda synthetic and does not show the real-world code that could lead to borrowing issues. Let's fix this. Imagine that you're making a shooter, and you have bots, that can follow and attack targets. Then the code could look like this:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct Bot { target: Handle<Node>, absm: Handle<Node>, } impl ScriptTrait for Bot { fn on_update(&mut self, ctx: &mut ScriptContext) { // Begin multiple borrowing. let mbc = ctx.scene.graph.begin_multi_borrow(); // At first, borrow a node on which this script is running on. let this = mbc.get_mut(ctx.handle); // Try to borrow the target. It can fail in two cases: // 1) `self.target` is invalid or unassigned handle. // 2) A node is already borrowed, this could only happen if the bot have itself as the target. match mbc.try_get_mut(self.target) { Ok(target) => { // Check if we are close enough to target. let close_enough = target .global_position() .metric_distance(&this.global_position()) < 1.0; // Switch animations accordingly. let mut absm = mbc .try_get_component_of_type_mut::<AnimationBlendingStateMachine>(self.absm) .unwrap(); absm.machine_mut() .get_value_mut_silent() .set_parameter("Attack", Parameter::Rule(close_enough)); } Err(err) => { // Optionally, you can print the actual reason why borrowing wasn't successful. Log::err(err.to_string()) } }; } } }
As you can see, for this code to compile we need to borrow at least two nodes simultaneously: the node with Bot
script and the target
node. This is because we're calculating distance between the two nodes to switch
animations accordingly (attack if the target is close enough).
As pretty much any approach, this one is not ideal and comes with its own pros and cons. The pros are quite simple:
- No compilation errors - sometimes Rust is too strict about borrowing rules, and valid code does not pass its checks.
- Better ergonomics - no need to juggle with temporary variable here and there to perform an action.
The cons are:
- Multi-borrowing is slightly slower (~1-4% depending on your use case) - this happens because the multi-borrowing context checks borrowing rules at runtime.
Message Passing
Sometimes the code becomes so convoluted, so it is simply hard to maintain and understand what it is doing.
This happens when code coupling get to a certain point, which requires very broad context for the code to
be executed. For example, if bots in your game have weapons it is so tempting to just borrow the weapon
and call something like weapon.shoot(..)
. When your weapon is simple then it might work fine, however when
your game gets bigger and weapons get new features simple weapon.shoot(..)
could be not enough. It could be
because shoot
method get more and more arguments or by some other reason. This is quite common case and in
general when your code become tightly coupled it becomes hard to maintain it and what's more important - it
could easily result in compilation errors, that comes from borrow checker. To illustrate this, let's look at
this code:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct Weapon { bullets: u32, } impl Weapon { fn shoot(&mut self, self_handle: Handle<Node>, graph: &mut Graph) { if self.bullets > 0 { let this = &graph[self_handle]; let position = this.global_position(); let direction = this.look_vector().scale(10.0); // Cast a ray in front of the weapon. let mut results = Vec::new(); graph.physics.cast_ray( RayCastOptions { ray_origin: position.into(), ray_direction: direction, max_len: 10.0, groups: Default::default(), sort_results: false, }, &mut results, ); // Try to damage all the bots that were hit by the ray. for result in results { for node in graph.linear_iter_mut() { if let Some(bot) = node.try_get_script_mut::<Bot>() { if bot.collider == result.collider { bot.health -= 10.0; } } } } self.bullets -= 1; } } } impl ScriptTrait for Weapon {} #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct Bot { weapon: Handle<Node>, collider: Handle<Node>, health: f32, } impl ScriptTrait for Bot { fn on_update(&mut self, ctx: &mut ScriptContext) { // Try to shoot the weapon. if let Some(weapon) = ctx .scene .graph .try_get_script_component_of_mut::<Weapon>(self.weapon) { // !!! This will not compile, because it requires mutable access to the weapon and to // the script context at the same time. This is impossible to do safely, because we've // just borrowed the weapon from the context. // weapon.shoot(ctx.handle, &mut ctx.scene.graph); } } } }
This is probably one of the typical implementations of shooting in games - you cast a ray from the weapon
and if it hits a bot, you're applying some damage to it. In this case bots can also shoot, and this is where
borrow checker again gets in our way. If you try to uncomment the
// weapon.shoot(ctx.handle, &mut ctx.scene.graph);
line you'll get a compilation error, that tells you that
ctx.scene.graph
is already borrowed. It seems that we've stuck, and we need to somehow fix this issue.
We can't use multi-borrowing in this case, because it still enforces borrowing rules and instead of compilation
error, you'll runtime error.
To solve this, you can use well-known message passing mechanism. The core idea of it is to not call methods immediately, but to collect all the needed data for the call and send it an object, so it can do the call later. Here's how it will look:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct Weapon { bullets: u32, } impl Weapon { fn shoot(&mut self, self_handle: Handle<Node>, graph: &mut Graph) { // -- This method is the same } } #[derive(Debug)] pub struct ShootMessage; impl ScriptTrait for Weapon { fn on_start(&mut self, ctx: &mut ScriptContext) { // Subscribe to shooting message. ctx.message_dispatcher .subscribe_to::<ShootMessage>(ctx.handle); } fn on_message( &mut self, message: &mut dyn ScriptMessagePayload, ctx: &mut ScriptMessageContext, ) { // Receive shooting messages. if message.downcast_ref::<ShootMessage>().is_some() { self.shoot(ctx.handle, &mut ctx.scene.graph); } } } #[derive(Clone, Debug, Reflect, Visit, Default, TypeUuidProvider, ComponentProvider)] #[type_uuid(id = "a9fb15ad-ab56-4be6-8a06-73e73d8b1f49")] #[visit(optional)] struct Bot { weapon: Handle<Node>, collider: Handle<Node>, health: f32, } impl ScriptTrait for Bot { fn on_update(&mut self, ctx: &mut ScriptContext) { // Note, that we know nothing about the weapon here - just its handle and a message that it // can accept and process. ctx.message_sender.send_to_target(self.weapon, ShootMessage); } } }
The weapon now subscribes to ShootMessage
and listens to it in on_message
method and from there it can
perform the actual shooting without any borrowing issues. The bot now just sends the ShootMessage
instead of
borrowing the weapon trying to call shoot
directly. The messages do not add any one-frame delay as you might
think, they're processed in the same frame so there's no one-or-more frames desynchronization.
This approach with messages has its own pros and cons. The pros are quite significant:
- Decoupling - coupling is now very loose and done mostly on message side.
- Easy to refactor - since the coupling is loose, you can refactor the internals with low chance of breaking existing code, that could otherwise be done because of intertwined and convoluted code.
- No borrowing issues - the method calls are done in different places and there's no lifetime collisions.
- Easy to write unit and integration tests - this comes from loose coupling.
The cons are the following:
- Message passing is slightly slower than direct method calls (~1-7% depending on your use case) - you should keep message granularity at a reasonable level. Do not use message passing for tiny changes, it will most likely make your game slower.