Create particle effect with Rust WASM & HTML Canvas

Rust wasm tutorial - Create particle system using Canvas in a browser

The previous post was a step-by-step tutorial on how to use wasm using Rust and run it in a browser. We built a simple web page with only an input field and a button. In this post, we will make something more interesting by creating a particle system in which 1000 randomly colored particles move in random directions at varying speeds. We will draw particles on HTML Canvas with all the logic done using the Rust wasm module.

Note: This article used Rust 1.62 (July 2022) on Windows 10.

A few years ago, I created the same particle effects using JavaScript and I thought it would be a nice way to replicate this with WebAssembly using Rust.

The JavaScript version can be seen here:

See the Pen
Simple Particle System
by HowToSolutions (@HowToSolutions)
on CodePen.

1) Requirements

This tutorial assumes you are somewhat familiar with creating a wasm module in Rust. If not, check the How to create a simple Rust wasm module in Windows article. But in short, we will need the following:

  • We will need to have Rust installed and because we will be creating a wasm module, we also need to add a wasm target to the Rust compiler.
  • Next, we need to have the wasm-binder tool installed. This tool is used to bind the JavaScript part with the Rust module by generating the necessary JavaScript files.
  • The finished web page will run on the local web server and we can create one quickly with a Python command. Check installing Python on Windows if you don't have Python installed yet.
  • Even though it's not required, it's a good idea to have a coding editor that supports Rust, such as VS Code. If you don't already have it yet, consider installing VS Code on Windows.

2) Creating Rust library

The first step is to create a library-type project:

cargo new --lib rustparticles

Go inside the created project folder and let's get started.

3) Modifying Cargo.toml

Our Cargo.toml configuration file will contain the following:

[package]
name = "rustparticles"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
wasm-bindgen = "0.2.83"
lazy_static = "1.4.0"
rand = "0.8.5"
getrandom = { version="0.2.8",  features = ["js"] }

[lib]
crate-type = ["cdylib"]

[profile.dev]
opt-level = "s"

Let's go through each section:

  • Lines 8-12 - [dependencies]

    [dependencies]
    wasm-bindgen = "0.2.83"
    lazy_static = "1.4.0"
    rand = "0.8.5"
    getrandom = { version="0.2.8",  features = ["js"] }
    • wasm-bindgen
      The wasm-bindgen crate is needed so we can annotate some of the functions with #[wasm_bindgen] macro. These are the functions we want to share between JavaScript and the Rust wasm module.
    • lazy_static
      The wasm module will have a static global variable that will store a struct with a vector of particles. Because statics in Rust is limited to constant functions, tuple structs, and tuple variants, we use the lazy_static crate that will allow us to have the initialization executed at runtime.
    • rand
      The rand crate is a well-known Rust library for generating random numbers. Each randomly colored particle will begin at a random location and will move in a random direction and speed.
    • getrandom
      The wasm target wasm32-unknown-unknown is not automatically supported by rand crate, so we also add a direct dependency to getrandom crate with the ["js"] feature. This feature tells the getrandom crate that we are building for JavaScript environments, such as browsers.
  • Lines 14-15 - [lib]

    [lib]
    crate-type = ["cdylib"]

    Here we set the library to be built as a dynamic system library so that we get the .wasm file after the compilation.

  • Lines 17-18 - [profile.dev]

    [profile.dev]
    opt-level = "s"

    Because the .wasm file will be used on the front-end, we use size optimization for the dev profile (default profile for debug and development) to reduce the size of the file.

4) Modifying src/lib.rs - creating particle system

A code in src/lib.rs will consist of several parts.

Let's learn more about each part, but first, let's take a look at the complete code inside src/lib.rs for the particle system. It contains about 100 lines of code.

use std::sync::Mutex;
use wasm_bindgen::prelude::*;
use rand::Rng;
#[macro_use]
extern crate lazy_static;
//Global variable
lazy_static! {
    static ref PARTICLES: Mutex<Particles> = {
        let p = Particles { particles: Vec::new(), canvas_width: 0.0, canvas_height: 0.0 };
        Mutex::new(p)
    };
}
//Particle
pub struct Particle {
    x: f64,
    y: f64,
    vx: f64,
    vy: f64,
    color: String,
}

impl Particle {
    fn new(x_starting_range: f64, y_starting_range: f64) -> Particle {
        let mut rng = rand::thread_rng();

        Particle {
            x: x_starting_range * rng.gen::<f64>(),
            y: y_starting_range * rng.gen::<f64>(),
            vx: 4.0 * rng.gen::<f64>() - 2.0,
            vy: 4.0 * rng.gen::<f64>() - 2.0,
            color: get_random_rgb(),
        }
    }
}
//Particles
pub struct Particles {
    particles: Vec<Particle>,
    canvas_width: f64,
    canvas_height: f64,
}

impl Particles {
    fn create(&mut self, num: i32) {
        for _ in 0..num {
            self.particles.push(Particle::new(self.canvas_width, self.canvas_height));
        }
    }

    fn draw(&self) {
        for p in self.particles.iter() {
            draw_particle(p.x, p.y, &p.color, 2);
        }
    }

    fn update(&mut self) {
        for p in self.particles.iter_mut() {
            p.x += p.vx;
            p.y += p.vy;

            if p.x < 0.0 || p.x > self.canvas_width {
                p.vx = -p.vx;
            }

            if p.y < 0.0 || p.y > self.canvas_height {
                p.vy = -p.vy;
            }
        }
    }
}
//Helper function
fn get_random_rgb() -> String {
    let mut r = 0;
    let mut g = 0;
    let mut b = 0;
    let mut rng = rand::thread_rng();

    while r < 100 && g < 100 && b < 100 {
        r = rng.gen_range(0..256);
        g = rng.gen_range(0..256);
        b = rng.gen_range(0..256);
    }
    format!("rgb({r},{g},{b})").to_string()
}
//Imported JS function from index.html
#[wasm_bindgen]
extern "C" {
    fn draw_particle(x: f64, y: f64, s: &str, size: i32);
}
//Exported Rust functions used by index.html
#[wasm_bindgen]
pub fn create_particles(w: f64, h: f64, num: i32) {
    PARTICLES.lock().unwrap().canvas_width = w;
    PARTICLES.lock().unwrap().canvas_height = h;
    PARTICLES.lock().unwrap().create(num);
}

#[wasm_bindgen]
pub fn render_particles() {
    PARTICLES.lock().unwrap().draw();
    PARTICLES.lock().unwrap().update();
}

We will build the project in the next step, but first, let's examine the code in more detail.

Importing the dependencies

In the beginning, we import the dependencies we declared in Cargo.toml file.

use std::sync::Mutex;
use wasm_bindgen::prelude::*;
use rand::Rng;
#[macro_use]
extern crate lazy_static;

We also import the Mutex from the standard library to use in a global variable.

Global Static mutable variable

Next, we declare a static global mutable variable.

lazy_static! {
    static ref PARTICLES: Mutex<Particles> = {
        let p = Particles { particles: Vec::new(), canvas_width: 0.0, canvas_height: 0.0 };
        Mutex::new(p)
    };
}

Here we make use of lazy_static! macro that allows the code to be executed at runtime in order to be initialized.

The static variable will hold the Particles struct wrapped in Mutex, which makes the variable mutable in a "safe way" as it only lets one process change it at a time.

Before we look at Particles struct, let's examine the Particle struct first.

Particle struct

A single particle is represented by the Particle struct. It contains the x,y coordinates, the movement for both directions, and also a color string.

pub struct Particle {
    x: f64,
    y: f64,
    vx: f64,
    vy: f64,
    color: String,
}

For Particle struct, we implement a single method, fn new(), which is used to construct new instances of Particle objects that will have a random starting position, random direction & speed, and also a random color.

impl Particle {
    fn new(x_starting_range: f64, y_starting_range: f64) -> Particle {
        let mut rng = rand::thread_rng();

        Particle {
            x: x_starting_range * rng.gen::<f64>(),
            y: y_starting_range * rng.gen::<f64>(),
            vx: 4.0 * rng.gen::<f64>() - 2.0,
            vy: 4.0 * rng.gen::<f64>() - 2.0,
            color: get_random_rgb(),
        }
    }
}

To ensure that the Particle starting position starts at a random position on the Canvas, we provide it with maximum possible random values with the x_starting_range & y_starting_range parameters which in our case will be the HTML5 Canvas width & height.

The vx & vy represent the particle's direction and velocity for each animation frame, which in our case will range between -2 to 2.

The color will be the random color from the get_random_rgb() helper function in the rgb(x,y,z) string format.

Particles struct

The Particles struct contains the vector of Particle instances as well as the values of Canvas width and height. They are passed as the arguments when a new Particle instance is created.

pub struct Particles {
    particles: Vec<Particle>,
    canvas_width: f64,
    canvas_height: f64,
}

The Particles struct has three methods implemented.

  • fn create(&mut self, num: i32)
    fn create(&mut self, num: i32) {
        for _ in 0..num {
            self.particles.push(Particle::new(self.canvas_width, self.canvas_height));
        }
    }

    Here, num Particle instances are created and added to the Particles.particles vector.

  • fn draw(&self)
    fn draw(&self) {
        for p in self.particles.iter() {
            draw_particle(p.x, p.y, &p.color, 2);
        }
    }

    And this function iterates through all of the Particle instances in a vector and calls JavaScript draw_particle() function that draws a particle on a Canvas. The last parameter 2 is the size in pixels. This function will be inside the index.html file that will be created in step 6.

  • fn update(&mut self)
    fn update(&mut self) {
        for p in self.particles.iter_mut() {
            p.x += p.vx;
            p.y += p.vy;
    
            if p.x < 0.0 || p.x > self.canvas_width {
                p.vx = -p.vx;
            }
    
            if p.y < 0.0 || p.y > self.canvas_height {
                p.vy = -p.vy;
            }
        }
    }
    

    Here we are iterating through all the Particle instances and updating their x & y coordinates with the velocity of vx & vy. If the particle will go over the Canvas boundaries, we inverse the value of the velocity to redirect it in an opposite direction. This way, the particles will bounce off from the Canvas border.

Get random color helper function

fn get_random_rgb() -> String {
    let mut r = 0;
    let mut g = 0;
    let mut b = 0;
    let mut rng = rand::thread_rng();

    while r < 100 && g < 100 && b < 100 {
        r = rng.gen_range(0..256);
        g = rng.gen_range(0..256);
        b = rng.gen_range(0..256);
    }
    format!("rgb({r},{g},{b})").to_string()
}

When we create a new Particle object, we use the get_random_rgb() function to assign it a random color. It generates R, G, B colors at random and returns a string in the format "rgb(R,G,B)". If the color is too dark, the randomization is repeated (line 77).

Importing JavaScript draw_particle() function

#[wasm_bindgen]
extern "C" {
    fn draw_particle(x: f64, y: f64, s: &str, size: i32);
}

Using #[wasm_bindgen] attribute and extern keyword, we import a JavaScript function draw_particle(), so that it is accessible when we call it from the Particles.draw() method.

This custom JavaScript function will be created in step 6 when working on index.html file.

Exporting Rust functions that will be called from JavaScript

#[wasm_bindgen]
pub fn create_particles(w: f64, h: f64, num: i32) {
    PARTICLES.lock().unwrap().canvas_width = w;
    PARTICLES.lock().unwrap().canvas_height = h;
    PARTICLES.lock().unwrap().create(num);
}

#[wasm_bindgen]
pub fn render_particles() {
    PARTICLES.lock().unwrap().draw();
    PARTICLES.lock().unwrap().update();
}

If we annotate the public function with #[wasm_bindgen] attribute, it can be imported by JavaScript. We have two of such functions. They will be called from the index.html file.

With create_particles(), we create num number of particles that are randomly placed at Canvas. we get the Canvas width and height from the w and h parameters to ensure that the particle starting position is not placed outside the Canvas.

The render_particles() is called in each frame of the animation loop. First it draws the particles and then updates their position in a Canvas.

5) Building library & binding wasm module with JavaScript

We now need to compile the library project and tell the Rust compiler to use wasm as a target:

cargo build --target wasm32-unknown-unknown

After the built is complete, we should have the .wasm file in target/wasm32-unknown-unknown/debug/ folder.

Next, we need to bind the wasm module with JavaScript and we do that by using the wasm-bindgen-cli tool. Check the installing wasm-binder tool on Windows if you don't have it installed yet on your system.

Run the following command:

wasm-bindgen target/wasm32-unknown-unknown/debug/rustparticles.wasm --out-dir ./web/ --target web

This will create the generated JavaScript files in the web folder.

Next, we will build the web page that uses the generated JavaScript and add custom JavaScript code that calls the functions in wasm module.

6) Creating index.html

In the web folder, create a new file index.html with the following content:

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
    <style>
        body {background-color: #000;margin: 0;padding: 0;}
    </style>
  </head>
  <body>
    <script type="module">
      import init, { create_particles, render_particles } from './rustparticles.js';
      async function run() {
        await init();
        // From here on we use the functionality defined in wasm.
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
        create_particles(canvas.width, canvas.height, 1000);

        function loop() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            render_particles();
            requestAnimationFrame(loop);
        }
        loop();        
      }
      run();
    </script>
    <canvas id="canvas"></canvas>
    <script>
        var canvas = document.getElementById("canvas");
        var ctx = canvas.getContext("2d");

        function draw_particle(x, y, color, size) {
          ctx.fillStyle = color;
          ctx.fillRect(x, y, size, size);
        }
    </script>       
  </body>
</html>

Let's check the highlighted parts of the page.

  • Line 10
    import init, { create_particles, render_particles } from './rustparticles.js';
    

    In this line, we are importing the functions from the wasm module, which are public Rust functions with #[wasm_bindgen] attribute macro.

  • Line 14-16
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    create_particles(canvas.width, canvas.height, 1000);
    

    Here, we call one of the imported functions create_particles() and pass it Canvas width and height as well as the number of particles we want to create.

  • Line 18-23
    function loop() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        render_particles();
        requestAnimationFrame(loop);
    }
    loop();  
    

    And here, we have JS loop() function used as a callback to requestAnimationFrame() method that tells the browser we want to run animation and to call the same loop() function before the next repaint.

    Within this callback function, we first erase the entire Canvas before calling the imported render_particles() from wasm module which renders and updates the particles.

  • Line 29-30
    var canvas = document.getElementById("canvas");
    var ctx = canvas.getContext("2d");
    

    Canvas is a drawing surface and we need to get its context to be able to draw graphics into it.

  • Line 32-35
    function draw_particle(x, y, color, size) {
        ctx.fillStyle = color;
        ctx.fillRect(x, y, size, size);
    }
    

    Here, we make use of the Canvas context by setting a color and drawing a rectangle of the same size at a specific position in a Canvas with 0,0 being in the top left corner. The draw_particle() is called from the wasm module.

7) Running the page from a local server

In previous steps, we have created a web folder and added an index.html file, but we can't just open index.html in a browser, because we will get the "Cross-Origin Request Blocked" error. This is due to the generated JS file specifying the wasm module as a local file.

To test the index.html, we need to run a local web server and the easiest way is to run the following Python command from a terminal.

py -m http.server 8080

Now we need to open a browser at the following address.

http://127.0.0.1:8080/web/

The webpage should open with particles flying in all directions at different speeds.

Conclusion

In this Rust WebAssembly tutorial, we created a particle system that runs in a browser. This particle effect uses HTML Canvas to draw the particle, but all of the logic is written in the Rust wasm module. First, 1000 of particles with random starting position, direction and speed are generated. Then we use the JavaScript requestAnimationFrame() method to play the animation of particles moving around the Canvas.

Write a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.