Unit 6.1 - Embedded wasm apps

Exercise 6.1.1: Web Assembly animation

Embed a wasm animation into a static web page.

We will use wasm-bindgen to interface with javascript.

Setup

cargo install wasm-pack

or get it here: https://rustwasm.github.io/wasm-pack/installer/.

Project skeleton

wasm-animation/
├── Cargo.toml
├── description.md
├── src
│   ├── lib.rs
│   └── animation.rs
└── static
    └── index.html

At the beginning, the project contains a rust wasm library exporting a function draw_play_button which does nothing but to pop up an alert box. The source tree also contains src/animation.rs, but this file is not used by lib.rs at the beginning. To compile the project (here, without using npm), use

wasm-pack build --target web --release --no-pack --no-typescript -d public/pkg

The file static/index.html serves as the entry point, but currently does not yet load the wasm bundle. Instead it displays "Click to start" on a <canvas> element. If the user clicks on the canvas, the elapsed time after loading the page will be displayed in an animation loop. The animation loop is implemented by using requestAnimationFrame().

Note: You cannot directly open index.html in your web browser due to CORS limitations. Instead, you can set up a quick development environment using either Python's built-in HTTP server:

python -m http.server -d static/

or by using miniserve:

cargo install miniserve
miniserve static --index "static/index.html" -p 8080

Step 1: Integrate the wasm bundle into index.html

  • At the beginning of <script type="module">, add
    import init, { draw_play_button } from "./pkg/wasm_animation.js";
    
  • Call await init(); as the first statement of async function run() {,
  • Call draw_play_button(); as the 2nd statement,
  • Symlink or copy static/index.html to public/,
  • Compile / test the result in the browser. A alert popup should appear when loading the page.

Step 2: Replace the alert popup by actual logic

Here is javascript code which draws a play button on the canvas element:

function drawPlayButton() {
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  const cx = canvas.width/2
  const cy = canvas.height/2;
  const r = 0.4*Math.min(canvas.width, canvas.height);
  ctx.lineWidth = 0.2 * r;
  ctx.arc(cx, cy, r, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.stroke();
  ctx.fillStyle = "black";
  ctx.beginPath();
  const r_triangle = 0.7 * r;
  const sin_60 = Math.sqrt(3)/2;
  const cos_60 = 0.5;
  ctx.moveTo(cx-r_triangle*cos_60, cy+r_triangle*sin_60);
  ctx.lineTo(cx-r_triangle*cos_60, cy-r_triangle*sin_60);
  ctx.lineTo(cx+r_triangle, cy);
  ctx.closePath();
  ctx.fill();
}

Translate it to rust, replacing alert("TODO");.

  • Remove alert("TODO"); and the extern "C" block,
  • Start with
    let document = web_sys::window().unwrap().document().unwrap();
    let canvas = document.get_element_by_id("canvas").unwrap();
    let canvas = canvas.dyn_into::<web_sys::HtmlCanvasElement>().unwrap();
    let ctx = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()
        .unwrap();
    
  • Translation involves:
    • ctx.fillStyle = ... -> ctx.set_fill_style(&JsValue::from_str(...)),
    • ctx.lineWidth = ... -> ctx.set_line_width(...),
    • canvas.width -> canvas.width() as f64 (same for height),
    • ctx.camelCase(...) -> ctx.snake_case(...),
    • ctx.arc needs an .unwrap().
  • In static/index.html: Replace the calls to draw_initial_text() with draw_play_button(); and remove its definition,
  • Compile / test it in the browser.

Step 3: Replace the js "animation" by the prepared rust animation

  • Make the animation module available to lib, by inserting
    // wasm independent logic:
    mod animation;
    
    at the top of src/lib.rs,
  • Wrap the animation::Animation struct in a new struct Animation in lib.rs - exporting it with wasm_bindgen:
    #[wasm_bindgen]
    pub struct Animation {
        animation: animation::Animation,
        context: web_sys::CanvasRenderingContext2d,
    }
    
    Here, we also persist a web_sys::CanvasRenderingContext2d instance, so we do not have to fetch it all the time from the DOM.
  • Add exported functionality to Animation:
    #[wasm_bindgen]
    impl Animation {
        pub fn new() -> Result<Animation, JsValue> {
            // Initialize context and animation
            Ok(Self { animation, context })
        }
    
        /// Advance one time step and draw
        pub fn step(&mut self, dt: f64) {
            // Call self.animation.step and self.render
        }
        
        /// Render the current state of the animation to the canvas
        pub fn render(&self) {
            // - Clear the canvas
            // - Draw a bounding box (canvas width / height)
            // - Draw all balls in `self.animation.balls`
        }
    }
    
  • In index.html, add Animation to the import statement (inside the curly braces),
  • Call const animation = Animation.new(); and animation.render(); directly after await init();,
  • Remove function animation(),
  • Replace the call animation(t) with animation.step(t - state.t0),
  • Test it in the browser.

Step 4 (challenge): Translate the rest of the js code to rust

Goal: the <script> element in index.html should look like:

<script type="module">
  import init from "./pkg/wasm_demo.js";
  init();
</script>

and all the initializing logic should go to

#[wasm_bindgen(start)]
fn run() {
    // init logic
}

Hint: Have a look at this example.