Learning WASM

Tags
  • wasm
  • rust
  • canvas
Publish date
Read time 8 min read

WebAssembly has always piqued my curiosity. Over the past few weeks, I’ve delved into numerous documentation sources to unravel the mysteries surrounding this fascinating technology. Not only that, but I’ve also ventured into the world of learning a new keyboard layout (Norman). The experience of exploring diverse topics is undeniably enjoyable. Surprisingly, even during my leisure time, I find myself immersed in the captivating realm of Rust documentation.

Disclaimer: I’m neither an expert in WASM nor in Rust. This post solely captures my learning journey, so approach my demos with an extra layer of caution. Let’s get started! 🚀

Wasm-bindgen

As mentioned earlier, I’ve taken up the challenge of learning Rust (again 🙄). The process of implementing WebAssembly programs in Rust has been remarkably simplified, all thanks to the wasm-bindgen library. It provides a high-level abstraction over WASM, allowing you to add just a couple of annotations here and there, and voila, your Rust function becomes readily available in JavaScript. What’s even more exciting is that you can write code that looks identical to JavaScript. Let’s explore this further with the following example.

use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
fn run() -> Result<(), JsValue> {
    let window = web_sys::window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    let body = document.body().expect("document should have a body");

    // Create a new paragraph with text and append to the body
    let val = document.create_element("p")?;
    val.set_text_content(Some("Hello from Rust!"));

    body.append_child(&val)?;

    Ok(())
}

All of this is made possible, thanks to the web_sys crate. In this scenario, nothing is needed from the JavaScript side, except for loading the binary. This is achieved through the start attribute of the wasm_bindgen macro.

However, it’s also possible to expose raw Rust functions to JavaScript. Consequently, the communication between JavaScript and Rust becomes the responsibility of the developer. The upcoming sections will delve into demonstrations of this intriguing capability.

Fibonacci

Writing a Fibonacci sequence is a breeze. However, for a beginner like me, using it via WASM can be a bit challenging. That’s where Wasm-bindgen comes to the rescue. By executing wasm-pack build --target web, we generate the Application Binary Interface, the JS to load the binary, and even the Typescript definitions. It’s like magic happening behind the scenes!

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: i32) -> i32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

Let’s load this in WebAssembly.

import init from "./pkg/fibonacci.js";
import wasm from "./pkg/fibonacci_bg.wasm?url";

async runWasm() {
  const module = await init(wasm);

  const list = document.createElement("ul");

  for (let i = 0; i < 11; i++) {
    const element = document.createElement("li");
    // Executing Rust code here...
    const addResult = module.fibonacci(i);

    element.textContent = `#${i}: ${addResult}`;
    list.appendChild(element);
  }

  document.appendChild(list);
}

runWasm();

…and the result:

Graphics

That was cool, but I’m more of a visual person. Beyond its myriad uses, Wasm truly shines in handling computation-heavy programs. So, let’s get creative and draw something on the <canvas>!

The concept revolves around crafting a linear memory buffer that serves as the canvas cells. This buffer acts as our bridge between JS and Rust. Rust writes into it, and JavaScript reads from it periodically. To add an extra layer of excitement, we’ll also spice things up by randomizing the colors.

use wasm_bindgen::prelude::*;

// Define the size of our "canvas"
const CANVAS_SIZE: usize = 11;

const OUTPUT_BUFFER_SIZE: usize = CANVAS_SIZE * CANVAS_SIZE * 4;
static mut OUTPUT_BUFFER: [u8; OUTPUT_BUFFER_SIZE] = [0; OUTPUT_BUFFER_SIZE];

// Function to return a pointer to our buffer
// in wasm memory
#[wasm_bindgen]
pub fn get_output_buffer_pointer() -> *const u8 {
    let pointer: *const u8;
    unsafe {
        pointer = OUTPUT_BUFFER.as_ptr();
    }

    return pointer;
}

pub trait Element {
    fn contains(&self, x: u8, y: u8) -> bool;
}

struct Cell(u8, u8);

struct Shape {
    cells: Vec<Cell>,
}

impl Element for Shape {
    fn contains(&self, x: u8, y: u8) -> bool {
        let item = self
            .cells
            .iter()
            .find(|&item| item.0 == y as u8 && item.1 == x as u8);

        item.is_some()
    }
}

#[wasm_bindgen]
pub fn generate_canvas(
    dark_value_red: u8,
    dark_value_green: u8,
    dark_value_blue: u8,
    light_value_red: u8,
    light_value_green: u8,
    light_value_blue: u8,
) {
    let alien_shape: Shape = Shape {
        cells: vec![
          // Alien shape rects
        ],
    };

    // Since Linear memory is a 1 dimensional array, but we want a grid
    // we will be doing 2d to 1d mapping
    for y in 0..CANVAS_SIZE {
        for x in 0..CANVAS_SIZE {
            // Set our default case to be dark squares
            let mut is_dark_square: bool = true;

            let search_result = alien_shape.contains(x as u8, y as u8);

            if search_result {
                is_dark_square = !is_dark_square;
            }

            // Now that we determined if we are dark or light,
            // Let's set our square value
            let mut square_value_red: u8 = dark_value_red;
            let mut square_value_green: u8 = dark_value_green;
            let mut square_value_blue: u8 = dark_value_blue;
            if !is_dark_square {
                square_value_red = light_value_red;
                square_value_green = light_value_green;
                square_value_blue = light_value_blue;
            }

            // Let's calculate our index, using our 2d -> 1d mapping.
            // And then multiple by 4, for each pixel property (r,g,b,a).
            let square_number: usize = y * CANVAS_SIZE + x;
            let square_rgba_index: usize = square_number * 4;

            // Finally store the values.
            unsafe {
                OUTPUT_BUFFER[square_rgba_index + 0] = square_value_red; // Red
                OUTPUT_BUFFER[square_rgba_index + 1] = square_value_green; // Green
                OUTPUT_BUFFER[square_rgba_index + 2] = square_value_blue; // Blue
                OUTPUT_BUFFER[square_rgba_index + 3] = 255; // Alpha (Always Opaque)
            }
        }
    }
}

…reading the buffer and putting the data directly onto the canvas:

async runWasm() {
  // Instantiate our wasm module
  const module = await init(wasm);

  // Get our canvas element from our index.html
  const canvasElement = document.querySelector("canvas");

  // Set up Context and ImageData on the canvas
  const canvasContext = canvasElement?.getContext("2d");
  if (!canvasElement || !canvasContext) {
    return;
  }

  const canvasImageData = canvasContext.createImageData(
    canvasElement.width,
    canvasElement.height,
  );

  // Clear the canvas
  canvasContext.clearRect(0, 0, canvasElement.width, canvasElement.height);

  // Write some functions to get a color value
  // for either the darker squares and lighter squares
  const getDarkValue = () => {
    return Math.floor(Math.random() * 100);
  };
  const getLightValue = () => {
    return Math.floor(Math.random() * 127) + 127;
  };

  const drawCanvas = () => {
    const width = 11;
    const height = 8;

    // Generate a new board in wasm
    module.generate_canvas(
      getDarkValue(),
      getDarkValue(),
      getDarkValue(),
      getLightValue(),
      getLightValue(),
      getLightValue(),
    );

    // Create a Uint8Array to give us access to Wasm Memory
    const wasmByteMemoryArray = new Uint8Array(module.memory.buffer);

    // Pull out the RGBA values from Wasm memory
    // Starting at the memory index of out output buffer (given by our pointer)
    const outputPointer = module.get_output_buffer_pointer();
    const imageDataArray = wasmByteMemoryArray.slice(
      outputPointer,
      outputPointer + width * height * 4,
    );

    // Set the values to the canvas image data
    canvasImageData.data.set(imageDataArray);

    // Clear the canvas
    canvasContext.clearRect(
      0,
      0,
      canvasElement.width,
      canvasElement.height,
    );

    // Place the new generated board onto the canvas
    canvasContext.putImageData(canvasImageData, 0, 0);
  };

  drawCanvas();
  setInterval(() => {
    // Redraw ...
    drawCanvas();
  }, 1000);
}

The canvas will flash rapidly, which may be disturbing for individuals with epilepsy. Viewer discretion is advised.

PathFinder

Around this time I built enough confidence that led me to build something heavier. So i was searching around and asking AI, etc. And i stumbled upon a path finder crate. This made me wonder if i can build a visual interface. For this I can reuse what i learnt from the previous excercises.

By this point, I had built enough confidence to tackle something more complex. So, I started exploring and consulting AI, among other things. That’s when I stumbled upon a pathfinder crate, sparking my curiosity about building a visual interface. Leveraging what I had learned from previous exercises, I wondered if I could bring this idea to life.

While I’ll skip most of the code, you can find the entire project on GitHub. This demo is a bit special because here, we’ll use web-sys to draw on the <canvas>. This shortcut saves a lot of time since we don’t have to implement a communication layer like we did before.

The WebAssembly program unfolds in two stages:

  1. It prompts the user to generate a maze instance.
  2. Then, a path can be requested between any two points using a custom algorithm.
impl Drawable for Maze {
    // Stage one
    fn draw(&self) {
        let (_canvas, context) = get_canvas();

        for y in 0..self.height {
            for x in 0..self.width {
                match self.get(x, y) {
                    Some(GridCell::Wall) => {
                        // Draw walls ...
                        context.set_fill_style(&WALL_COLOR.into());
                        context.fill_rect(
                            x as f64 * self.cell_size,
                            y as f64 * self.cell_size,
                            self.cell_size,
                            self.cell_size,
                        );
                    }
                    Some(GridCell::Path) => {
                        // Draw paths ...
                        context.set_fill_style(&PATH_COLOR.into());
                        context.fill_rect(
                            x as f64 * self.cell_size,
                            y as f64 * self.cell_size,
                            self.cell_size,
                            self.cell_size,
                        );
                    }
                    None => {}
                }
            }
        }
    }
}

#[wasm_bindgen]
pub fn draw_maze(size: usize) -> Maze {
    let mut maze = Maze::new(size, size);
    maze.set(1, 1, GridCell::Path);

    maze.generate_maze(Point { x: 1, y: 1 });
    maze.clear();
    maze.draw();
    maze
}
impl Drawable for Path {
    // Stage two
    fn draw(&self) {
        let start = self.steps.first().expect("path steps are empty");
        let goal = self.steps.last().expect("path steps are empty");
        let (_canvas, context) = get_canvas();

        let path_size = self.get_path_size();

        // Trail
        for point in self.steps.iter() {
            context.set_fill_style(&TRAIL_COLOR.into());
            let (x, y) = self.get_cell_position(&point);
            context.fill_rect(x, y, path_size.into(), path_size.into());
        }

        // Start
        context.set_fill_style(&START_COLOR.into());
        let (start_x, start_y) = self.get_cell_position(start);
        context.fill_rect(start_x, start_y, path_size.into(), path_size.into());

        // Goal
        context.set_fill_style(&GOAL_COLOR.into());
        let (goal_x, goal_y) = self.get_cell_position(&goal);
        context.fill_rect(goal_x, goal_y, path_size.into(), path_size.into());
    }
}

#[wasm_bindgen]
pub fn draw_path(maze: &Maze, start: Point, goal: Point, algorithm: Algorithm) -> Path {
    maze.redraw();
    let path = Path::new(&maze, start, goal, algorithm);
    path.draw();
    path
}

I’ve exposed the public draw_maze and draw_path functions to JavaScript. We’ll bind them to two separate forms.

Alright, let’s see it in action 🧐.

Grid

Conclusion

This brief post has ignited my passion for Rust and WASM programming. I hope it has brought the same excitement to you as it has to me. The learning journey continues!

References