Programming Included

Writing a Particle Pixel Simulator in Rust

A Physics Particle Game

Charles Chen | 2023-08-23 17:31 PST

Table of Contents

Particle.rs

Below is a simple Sand-game like Simulator game that I wrote over last weekend using Rust and Bevy.

Left-click to emit particles. Press s to emit sand and w to emit water. r to reset the grid. On mobile, hold to spawn particle. Use two fingers and tap to get change particle spawn type.

What are Particle-Based Simulator Games?

Powder Game 2

Growing up, I remembered playing a game made from a Japanese Developer called Powder Game 2 made by Dan-Ball JP.

In it you can spawn different types of particles that each have unique properties and would interact with one another. There are basic particles like sand that drop easily and pile up. Then there were gases that would expand and go upwards. Little me was excited to see c-4 particles interact with fire particles to produce fascinating explosions!

Levels can be saved and shared among registered users. Even now the levels are saved and snapshotted by various users and produce some fascinating works / contraptions.

Here is one made by Billy G. based-off a Knight and Dragon.

Knight and Dragon in Powder Game 2
image: Knight and Dragon in Powder Game 2

The dragon's mouth have some emitters setup where fire expels from the dragon which burns the knight.

The complexity of the engine is such that users can create tensile based materials. Here is one called "Strength Block" that was one of the top rated creations since 2017 made by skyk:

Tensil Cube in Powder Game 2
image: Tensil Cube in Powder Game 2

Powder Game 2 really was quite an interesting type of game. Young me would attempt to build different structures like a child at a playground if said child was provided the opportunity to then ignite the sandbox with fuel and fire.

Sandspiel

Sandspiel was a project after Powder Game 2 and by the time it was released, I had not played it as a child. However, Sandspiel was worth a mention as I found it recently on their website and found the developer's journey to writing their own clone which inspired me to go on the same path.

To my surprise, they had written the game several times and the latest edition is in Rust, one which I had also set out to pursue. I had not read their code, however, as I wanted to come in with fresh eyes and try the problem myself.

The original creators actually wrote a blog post and you can find their game here Sandspiel.

Noita

Another honorable mention: Noita was a game produced in 2020 that is an action rogue-like with a unique twist: the world is based-off a simulated pixel environment. I bought the game during early access and have enjoyed it ever since.

Noita, every pixel is simulated
image: Noita, every pixel is simulated

The game is fully of chaotic interactions and there are secrets hidden in the world. It takes the idea of simulation and procedural generation of the sand-genre to another level. There are parallel universes, secret spells, and special particle interactions waiting to discover in the world of Noita. But be warned, this game is chaotic and hard in nature!

Writing Particle.rs

Setting out, I wanted to write in Rust to support Web-assembly and sharpen my own Rust skills. Below are some notable highlights in my implementation.

Pixel Metadata

Most online discussions and implementations seem to get into very lowlevel C++ struct. Given my weekend time constraint, I wanted to implement it as quickly as possible.

I knew early on through some research, the simulator I wanted to emulate should include some physics-esque drop and slide which would make the game feel snappier. The following is what I ultimately used to encode the pixels:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum PixelType {
    AIR,
    SAND,
    WATER
}

enum PixelColor {
    SANDA,
    SANDB,
    SANDC,
    WATER,
    AIR
}

pub struct PixelData {
    pixel_type: PixelType,
    color: PixelColor,
    velocity: Vec2
}

The compactness of the struct could be tighter, however the implementation is sufficiently efficient for my use-case. Furthermore, without any chunk-ing optimizations (where we only update areas where there is movement), the game runs buttersmooth on desktop, even in the browser!

The Basic Cellular Rules

Falling particle positions
image: Falling particle positions

The most basic of particle-based games is akin to a cellular automata game, starting the update loop from the bottom of the screen upwards:

  • Drop particle down one tile if available (DD).
  • Drop to bottom left if available (DL).
  • Drop to bottom right if available (DR).
  • Stop otherwise.

Water and fluids are similar however adds two more rules to this before terminating:

  • Move fluid left if left tile is available (LL).
  • Move fluid right if right tile is available (RR).
  • Stop otherwise.

Notice how the movement of the particle goes from left first then right. This behavior produces a zigzag pattern where sand must first occupy and slide left-ward. We can do better.

Sliding and Updating Randomly

A few updates to the rules help even out dropping behavior.

  • Rather than updating particles left and then right first, we randomly select a direction to check.

Now our hills form more naturally:

However, with just the rule above, water looks a bit off.

Notice how water flows to the right faster than the left. In this case, the update of the board is first done on the right before the left. As a result, the water flows more naturally in one direction. It is important that the global update per-particle also switches from left to right and vice versa.

  • Rather than to update particles from the bottom left first, we randomly select left or right updates.

With these two updates, sand flows naturally and water updates properly.

From this exercise, I found how to exchange for loop iterators conditionally using Rust's helper libraries:

1
2
3
4
5
6
7
8
9
fn screen_update_iterator(
    rev: bool,
) -> itertools::Either<impl Iterator<Item = usize>, impl Iterator<Item = usize>> {
    if !rev {
        itertools::Either::Left(0..WINDOW_SIZE)
    } else {
        itertools::Either::Right((0..WINDOW_SIZE).rev())
    }
}

This iterator returns an Either type. A special Either is necessary here as Rust treats 0..WINDOW_SIZE.rev() as a separate type than 0..WINDOW_SIZE. The reason for this is how traits are not concrete types and Iterators are traits. You can find more information here.

Simulating Acceleration

To make things snappier, each pixel is provided with a velocity component to track acceleration.

  • Add a velocity to each particle and update velocity for each move.

The velocity idea was based-off a video I found on Youtube. Once again, I didn't look at any source code but took the concepts and ran with it.

Diagonal Movement of a Particle
image: Diagonal Movement of a Particle

Once velocity is introduced, a particle can travel more than one tile per tick. Similar to the above video, we performance basic linear-collision path tracing to check if a particle could move among the tiles. If so, move the tiles, otherwise, stop at the collision point. Velocity is removed if collision occurs otherwise velocity is compounded for the next frame.

This idea seems simple on paper but bugs took a while to iron out. Here's one during development:

With some fine-tweaking:

  • Slow slide rate and fast fall for sand.
  • High slide rate and fast fall for water
  • Preserve x-axis velocity when transitioning from fall to slide rules.

Emergent behavior begins to appear. The sand in my implementation seems to be with a certain delay that I really find to be soothing, similar to real sand. Sand would often slide at the bottom first moving along large chunks above. The behavior emerges as sand has a high slide friction with fast drop-rate.

Water has low slide-friction and high drop-rate. This meant that water can easily fly farther away from the source and ends up producing particle-like droplets.

Here's a snippet of the code on how linear movement is calculated per pixel:

1
2
3
4
5
6
7
8
9
10
let m: f32 = velocity.y / velocity.x;
let b: f32 = src_y as f32 - (m * src_x as f32);

let mut start_x: usize = src_x;
let mut start_y: usize = src_y;

let dest_x: i32 = velocity.x.abs() as i32 + 1;
for dx in 1..(dest_x.abs()) {
  // We use m * dx + b to check all paths.
}

Most of the code is basic derivation of a linear equation and then checking each position of a quantized x input. Further optimizations can be done here using linear equations though the optimizations may be little compared to chunking algorithms.

Takeaways

Overall, the project was a fun weekend excursion. I've picked up a few Rust tricks like conditional iterators as well as apply basic linear equations on a discrete space. I am hoping to continue to iterate on this project in the future and seeing if I can add more features to it. Once more, Rust has demonstrated the agile development cycles and the ability to use WASM, its portability.

There are lots of things I wish to explore in this implementation however I ran out of time. Things like chunking (the reduction of rows to update to the top most rows of a particle body) as well as explosion dynamics. Perhaps we will leave this for next time.

comments powered by Disqus