Verlet Physics Playground

I recently started playing around with the p5.js web editor and I created what I called the “Verlet Physics Playground”. That got me interested in other libraries in other languages that could be used for building real-time simulations like this.

The rabbit hole I ended up in first took me to Common Lisp’s Sketch library. I love using Common Lisp and I’m especially enamoured with the interactive/REPL-driven development workflow. I had a lot of fun rebuilding the p5.js version using Sketch.

In both versions however I started encountering performance issues. For example, in the p5.js version I start noticing frame rate drops at around 300 objects. The Sketch version is slightly better but still can’t handle much more than that. I therefore decided to see what I could do in Rust.

Once I’d decided to try Rust, I knew I was really in deep. I ended up writing versions using the following frameworks/libraries: -

The links above are to the web versions of each project where applicable.

This post details my experiences with each of these different options and compares each of them too.

General overview

In order to discuss the specific versions of the project more easily, I’ll give a general overview of what the project actually is.

The “Verlet Physics Playground” is a simple physics simulation using Verlet integration. Users can add “Verlet objects” which are circular objects with a radius and colour. These objects accelerate due to gravity and are constrained within a circular region. Objects also collide with other objects. Objects can be “pinned” which means that they are stuck in place (not affected by gravity).

Objects can be attached to each other with “sticks”. A stick is an object referencing two Verlet objects, one at each end. The stick has a length and this is used to constrain the object positions. Updating a stick will cause the two objects at the endpoints to be moved accordingly to make sure that the distance between them matches the stick length. The Verlet integration will take care of object momentum caused by this constraint which produces fairly realistic behaviour.

The application also contains a UI allowing users to add/remove objects and sticks, to spawn random objects and to adjust object and stick properties. The mouse or touch screen can be used to select objects, move them, etc.

Nannou

My first investigations led me to the Nannou library. I was searching for a creative coding framework similar to p5.js and Sketch and this was the closest I could find in Rust.

I was very impressed with how simple Nannou was to use. Nannou uses a model to store the application state, an update function to update this state per frame and a view function to render that state to the output window. Converting my existing p5.js and Sketch versions was pretty simple and I ran into very few issues.

The main challenge I faced during the conversion was how to allow sticks to reference the two objects they have at each endpoint. In p5.js and Sketch this was simple as I only needed to store a reference to those two objects within the stick. However due to Rust’s strict borrowing rules this was not so simple. I fully expected this due to my experience in Rust and I was actually happy to encounter it knowing that the solution would result in a more “correct” implementation in the end. Often Rust makes you rethink some possibly poor design decisions that are allowed in other languages. While this might hinder rapid prototyping, going through this process often produces more sensible solutions.

My first approach to this was simply to store each object’s index with the stick. This worked fine as the stick only needed to store two integers. The downside to this was that if an object was deleted then all of the indices would need to be adjusted.

My second approach was to store the object references directly, using reference counting for those references via Rc. This alone was not enough as sticks need to mutate their referenced objects in order to constrain their positions. Because of this I used a RefCell to allow for dynamically checked mutable borrow rules. This meant that the stick contains two Rc<RefCell<VerletObject>> references, one for each end of the stick.

The benefit of this second approach was that I didn’t need to worry about indices matching the correct objects. I could also access the referenced objects directly when updating the stick without having to give the stick access to the entire list of objects.

Another challenge I faced was during collision checks. Each object needs to check against every other object (from that index onwards) in order to check for and handle collisions. During handling of each collision, each object needs to be mutated in order to adjust its position. This of course caused problems due to borrowing two elements from the same vector mutably at the same time. I managed to solve this using the following nested loop: -

for i in 1..self.objects.len() {
    let (a, b) = self.objects.split_at_mut(i);
    let o1 = a.last_mut();
    if let Some(o1) = o1 {
        let mut o1 = o1.as_ref().borrow_mut();
        for o2 in b.iter() {
            let mut o2 = o2.as_ref().borrow_mut();
            let mut norm = o2.pos - o1.pos;
            let dist = norm.length();
            if dist < o1.radius + o2.radius {
                let overlap = (o1.radius + o2.radius) - dist;
                norm = norm.normalize();
                if !o1.pinned {
                    o1.pos -= norm * overlap * 0.5f32;
                }
                if !o2.pinned {
                    o2.pos += norm * overlap * 0.5f32;
                }
            }
        }
    }
}

As objects only need to check against objects after the current object in the list, the split_at_mut() function allowed me to explain to the borrow checker that the vector is split and so elements can be borrowed mutably at the same time.

One other aspect of the conversion process that required a bit of investigation was building a simple UI. Fortunately Nannou comes with Egui support, so I went ahead and learned a bit about it. I was very happy with the UI that I was able to build in the end.

Performance-wise I was glad I tried Rust: this version of the project easily handled 1,500 objects, five times more than the p5.js and Sketch versions!

On the whole the Nannou version of the project went very well and I was very pleased with the results. There was one problem however; I wanted the project to run both natively and on the web via WebAssembly, but Nannou did not support that target. Therefore, I continued searching for another library…

Bevy

To address the WebAssembly limitation of Nannou, I decided next to try a Rust game engine. I decided on Bevy due to its maturity as well as its WASM support.

The biggest challenge when using Bevy was that it’s based on an Entity Component System (ECS) model. This made converting from the Nannou version more difficult as the overall application structure I used in Nannou had to be redesigned to fit into the ECS model.

Having not used ECS before I spent a bit of time playing around and trying to find the best structure I could. I eventually ended up writing plug-ins for: -

  • Verlet objects;
  • Sticks;
  • The UI;
  • Mouse input;
  • Touch input; and
  • The application itself.

Plug-ins are convenient as they encapsulate all systems, events, etc. which would otherwise have to be added manually to the Bevy application. By breaking it down into these plug-ins, the main() function ended up being simply: -

fn main() {
    App::new()
        .add_plugins(VPAppPlugin)
        .run();
}

VPAppPlugin in turn handled adding the other plug-ins required for the application.

I built the initial version and I was pleased to see that it ran natively as well as on the web via WASM. I was a little disappointed at the WASM file size though; even when building with size optimisations and using wasm-pack I could not get the WASM size lower than 10Mb, which seemed a lot for my simple project.

Another issue I encountered was very poor performance. I was disappointed at first because I knew that one of the main advantages of using an ECS was that performance should be much better due to better usage of CPU caches, etc. To see performance much worse than the Nannou version was disappointing.

I ran some tests and found out that the bottleneck was with the object-to-object collision checks. In Bevy, the query iterator comes with an iter_combinations_mut method which is a simple way to perform the same nested collision checks on groups of two objects. However the performance of this method was much worse than with my split_at_mut() solution in Nannou. Because of this I raised an issue and one of the Bevy developers was able to improve the performance slightly by inlining an internal function call.

The performance however was still worse than with Nannou so I continued investigating. I wanted to see if my own solution would work better in Bevy so I asked if split_at_mut() would be possible on a query iterator. The lead Bevy maintainer suggested simply collecting the query results to a Vec and then iterating like I did in Nannou. I had dismissed this solution initially as I assumed this would be way too much work to do on each update, but trying it actually yielded much better performance! I assume that the compiler optimised away the actual collection and allocation of a new Vec on each update, but I haven’t been able to verify this.

Following on from that discussion, another developer merged a pull request which adds “Query Reborrowing”. Once released this will avoid the need to collect to a Vec and will allow a solution very similar to split_at_mut(). I am looking forward to trying this out.

When implementing the UI I was happy to see that the bevy_egui crate existed. Using this I was able to transfer the Egui code I had written for the Nannou project over to Bevy quite easily. Handling UI events was a little bit different however due to the ECS. I ended up using Bevy events to fire events whenever something in the UI changed and then handling those events in separate systems.

Another ECS-related issue I had was with running multiple updates per frame. With my other projects I had been running eight physics updates for every frame and this was as simple as dividing the frame delta time by eight and then looping eight times to do the physics updates. In Bevy though, the update system is simply called by Bevy’s scheduler at the desired frame rate. The solution to this was to insert a Time<Fixed> resource with the desired fixed update frequency. As my target frame rate was 60fps I used a fixed update frequency of 360Hz. This meant that only six physics updates would be performed per frame (360 ÷ 60) however this seemed to be enough during testing.

Yet another ECS-related issue then emerged: how to highlight selected and pinned objects. With Nannou, each object had a draw method which could change the way the object was rendered based on whether it was selected or pinned. In Bevy though each object has a mesh component which is automatically rendered by Bevy. In order to show whether an object is pinned or selected I had to spawn a separate entity with its own mesh to represent this visually. I made use of Bevy’s parent/child relationships to despawn these entities automatically when the object is deleted and to allow the transformation of these entities to be relative to their parents.

Despite being a lot of work, the end result was good. I was happy to see the Bevy project easily handling 1,000 objects both natively and while running in the browser via WebAssembly.

Performance however is interesting. While Bevy produces very smooth results with 1,000 objects on both targets, the performance drops off very quickly when going past around 1,500 objects. This is especially interesting as I am only doing six physics updates per rendered frame in Bevy rather than the eight updated in Nannou. So with less than 1,500 objects I would say the performance is on par with Nannou, however when going higher than 1,500 objects the performance becomes considerably worse. I have a feeling this might be due to the collection of all objects to a Vec during each update so again I will be interested to see if the new “Query Reborrowing” system resolves that.

Raylib

Since starting this journey I subsequently heard about Raylib. While not a Rust library (it’s written in C), there are Rust bindings available for it. For this reason though I knew that a Rust-only WASM build (the wasm32-unknown-unknown target) would not be possible, however I read that it was possible to run Rust Raylib projects on the web via Emscripten.

Converting the project to Raylib was quite refreshing in a way as I was back to the simpler non-ECS architecture. In fact I took most of the code from the Nannou version as this was a closer match than the Bevy version.

Once I had a basic version working I tried building it for the web via Emscripten. This worked quite well as I was able to get it running quite quickly. However I was a bit disappointed in the performance when running in the browser.

Another issue for me was the lack of Egui. While I’m sure there would be a way of getting this working, I instead decided to use Raylib’s native UI library. This is much simpler than Egui and while I managed to get a fully functional UI in place I wasn’t as satisfied with it. Due to this and the poor WASM performance I decided to abandon this version at this stage.

Macroquad

My final (as of writing) version used Rust’s Macroquad library. Macroquad is a wrapper around Miniquad which itself provides a nice graphics abstraction. Macroquad has “batteries included”, quoting from their site: “UI, efficient 2D rendering, sound system—everything needed to make a 2D game included!”. I was also interested to read “HTML5: First class browsers support, WebGL1 and iOS Safari supported.”

I had come across Miniquad very early on in my adventure but somehow had glossed over Macroquad, so this seemed like the missing link I needed when I finally found it.

My first experiences were very positive. I was able to write the project using the simpler non-ECS architecture as with Nannou and Raylib and when running it performance on both the native and web platforms seemed excellent.

Like with Raylib, I initially decided to use Macroquad’s built-in UI library. As with Raylib though I found this to be a bit too simplistic. I therefore tried getting Egui to work with Macroquad.

I found the existing egui-macroquad crate which seemed to do exactly what I needed. However as of writing this is not compatible with the latest version of Macroquad. I tried using it with a compatible version of Macroquad (0.3.6) and it worked, but multisampling was not working when running natively on Linux. I therefore created a couple of forks (https://codeberg.org/polaris64/egui-macroquad and https://codeberg.org/polaris64/egui-miniquad) which worked with Macroquad 0.4.5.

I was quite pleased with the compiled WASM size too. When using the native UI the WASM file size was around 600kb. With the added Egui dependency this jumped up to around 2Mb, but that was still five times smaller than the WASM file produced with Bevy.

Performance-wise I would say it seems to match the Nannou version when running natively on Linux. The WASM performance is very good but worse than the Bevy version, although when going past that 1,500 object limit mentioned earlier the relative performance is better with Macroquad.

Summary

As expected, moving to Rust drastically improved performance over the JavaScript and Common Lisp implementations. Overall I was quite surprised how easy the project was to convert to Rust. There were a few tricky situations with the borrow checker but nothing too terrible and the project was more robust as a result.

The main issue I found when using Rust was choosing the library to use. There are lots of options for graphics rendering in Rust at the moment without a clear choice as to the best option.

For creative coding and where running on the web is not an issue I would highly recommend giving Nannou a try. For games Bevy is a very solid choice however migrating to an ECS-based system can add some complexity. There are also some strange performance issues here and there, but hopefully they will be resolved by the changes in the upcoming release (> 0.14.1). For something similar to Nannou which has equivalent performance and can also run on the web, I would recommend Macroquad.

There are of course other libraries that I have not tried yet, so I might update this post when I (inevitably) do try them!



Links