Rodney Lab
Posted on May 15, 2024
Unit of Measurement in Game Dev
In this post, we will take a quick look at Rapier physics with units of measurement (UOM). In a recent post, trying out the Rust Rapier physics engine with Macroquad, we noted that Rapier recommends using full-scale measurements for more realistic simulations. We opted for SI units (metres for distance and metres per second for velocities). There was necessary conversion from these physical units to pixels, for rendering.
This got me thinking about leveraging Rust’s types for conversion between the physics and graphics units. I discovered the Rust uom crate, which has applications in Aerospace. In his Rust Nation UK talk, Lachezar Lechev mentioned how confusion over units might have been behind a costly financial loss in a real-world Aerospace project.
If professional teams working full-time on a project with such high financial stakes can make mistakes, then, probably, anyone is prone to making similar mistakes. So, I considered adding UOM to my Rust game stack and talk about how I set it up in this post.
📏 uom
The uom
crate, has applications in Aerospace and Aeronautical Engineering. By defining quantities with a unit of measurement, it can catch basic errors at compile time. As an example, you might define the speed of a ball in metres-per-second, and its mass in kilograms. Now, if you try (erroneously) to add the mass to the velocity, in your Rust code — something that doesn’t make sense physically — you will get a compile-time error, potentially saving you debugging an error, which might be hard to catch.
uom
also helps with conversions, so you can safely add a displacement in kilometres to a diameter in metres.
⚙️ Adding uom
to your Project
You can just add uom
to your Cargo.toml
:
[dependencies]
# ...TRUNCATED
rapier2d = { version = "0.19.0", features = ["simd-stable"] }
uom = "0.36.0"
For my use case, this worked just fine (with default features), though you might need to tweak the uom
features, depending on your use case.
To define a custom pixel
unit for conversion between the physics and rendering systems (using the uom
unit
macro), I also needed to add this snippet to my Rust source (last two lines):
use uom::{
si::{
f32::{Length, Velocity},
length, velocity,
},
unit,
};
#[macro_use]
extern crate uom;
We come to the full definition of the custom unit later.
In this post, we use the example from the earlier Rapier Physics with Macroquad floating bubble post. In the following section, we see some snippets where I added units to that code. Find a link to the full code repo further down.
Domain Units of Measurement
The demo features floating bubbles or balls. For the demo domain, I use uom
to define the ball with typed values in SI units. For rendering, I will need to convert these to pixels, and for use with Rapier, I will need a raw float value.
Here is the ball struct Rust code:
#[derive(Debug)]
struct Ball {
radius: Length,
position: Vector2<Length>,
physics_handle: Option<RigidBodyHandle>,
colour: Color,
}
uom
has a predefined Length
type alias using standard SI units for length quantities (metres). I use it here to set the type for the ball radius and current displacement within the simulation world.
🗡️ Rapier Physics Units
I kept things simple, and used SI units (with uom
) within my own code, and converted the values to f32
s whenever I needed to pass the value to Rapier. You could go a step further and use type-driven development, where (by design) only validated quantities can get passed to the Rapier physics engine.
Here is a code snippet, defining a new ball’s velocity and initializing it with Rapier:
let x_velocity: Velocity =
Velocity::new::<velocity::meter_per_second>(pseudo_random_value);
let y_velocity: Velocity = Velocity::new::<velocity::meter_per_second>(1.0);
let linear_velocity = vector![x_velocity.value, y_velocity.value];
let rigid_body = RigidBodyBuilder::dynamic()
.translation(vector![ball.position.x.value, ball.position.y.value])
.linvel(linear_velocity)
.build();
Velocity
is a predefined uom
type aliases (like Length
).
- In the first line, above, I defined
x_velocity
to be some random float, and associated metres per second as units, using theuom
types. - For
rapier2d
, I need to pass the velocity components asf32
values, so extract the raw value from the two, typed velocities via the.value
field. - Finally, in the last line we pass the
linear_velocity
, as annalgebra
Vector2
of 32-bit floats (expected by Rapier).
The example might seem a little contrived, as I convert a 32-bit float to a uom
velocity, and then immediately convert it back to a float for consumption by Rapier. We shall see in a later section, though, that you can tweak this slightly to define a value in one unit, and then extract a converted value in another unit for passing to Macroquad for rendering.
🖥️ Macroquad Render Units of Measurement
For rendering, I am using Macroquad, which works with pixels. In the previous post, I set a scale of 50 pixels per metre. I formalized that here using a uom
custom unit.
Custom Pixel Unit
uom
provides the unit
macro for defining custom units, needed in your domain. I used that macro to define a new pixel
unit as a length measurement:
unit! {
system: uom::si;
quantity: uom::si::length;
// 1 metre is 50 px
@pixel: 0.02; "px", "pixel", "pixels";
}
Remember to include the snippet mentioned above if you use this macro.
Here:
-
system
adds the new unit to theuom
in-built SI units; -
quantity
defines the unit as a length; and -
@pixel
, in the final line, gives the abbreviation, singular and plural names for the unit.
Now, we can define variables using this new unit as a type, and convert between other units. As an example, the get_max_balls
function uses quantities in both pixels and metres to determine the maximum number of balls that can fit across the app window, given the window has a pre-determined width:
fn get_max_balls() -> u32 {
let window_width = Length::new::<pixel>(WINDOW_WIDTH);
let ball_radius = Length::new::<length::meter>(BALL_RADIUS);
(window_width / (2.0 * ball_radius)).value.floor() as u32
}
Here, window_width
is defined in pixels and ball_radius
, in metres. Notice, we use .value
(as in the previous example) to extract the raw float value. WINDOW_WIDTH
and BALL_RADIUS
are raw f32
constants.
To convert a length between different length quantities (for example metres to pixels), we can call the get
method on the quantity. For example, here is a snippet for rendering the ball where we need to convert the internal metre lengths to pixels:
fn draw_balls(balls: &[Ball]) {
for ball in balls {
let Ball {
colour,
position,
radius,
..
} = ball;
draw_circle(
position.x.get::<pixel>(),
-position.y.get::<pixel>(),
radius.get::<pixel>(),
*colour,
);
}
}
The position and radius are all stored in metre values internally, yet there is no need to make sure we have the right conversion factor to get pixels out; the custom uom
does that for us. Although the calculations are relatively simple to perform manually, converting automatically can save you making a simple mistake when revisiting code you haven’t seen in a while.
🙌🏽 Rapier Physics with Units of Measurement: Wrapping Up
In this post on Rapier Physics with Units of Measurement, we got an introduction to working with units of measurement with Rapier. In particular, we saw:
- how you can use the
uom
Rust crate to add SI units of measurement to game physics; - how you can define custom quantities and convert between units; and
- interface with code that does not expect units.
I hope you found this useful. As promised, you can get the full project code on the Rodney Lab GitHub repo. I would love to hear from you, if you are also new to Rust game development. Do you have alternative resources you found useful? How will you use this code in your own projects?
🙏🏽 Rapier Physics with Units of Measurement: Feedback
If you have found this post useful, see links below for further related content on this site. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on X, giving me a mention, so I can see what you did. Finally, be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on X (previously Twitter) and also, join the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Game Dev as well as Rust and C++ (among other topics). Also, subscribe to the newsletter to keep up-to-date with our latest projects.
Posted on May 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.