WebAssembly Text rendering to canvas

June 25, 2021

WebAssembly can not render directly to an HTML canvas element. However, it can draw colors into an ImageData object. A call to putImageData can then be used to render that object to the canvas. The ImageData object can contain all the data necessary to render a frame to the canvas. You can use this technique to create games with tiny download sizes. This tutorial will show you how to draw a very simple sprite to the canvas and move it around.

The HTML and CSS

The first thing we will do is create a file called sprite.html. This will be the web page that runs our Wasm module. The web page will have some CSS that will resize our canvas while keeping the pixels on the canvas 128x128. We will render tiny 8x8 pixel sprites, the same sprite size used by the original Nintendo Entertainment System. Create the sprite.html file and add the following HTML header code:


  <!DOCTYPE html>
  <html lang="en">

  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sprite</title>
    <style>
      canvas {
        width: 512px;
        height: 512px;
        margin-left: auto;
        margin-right: auto;
        image-rendering: -moz-crisp-edges;
        image-rendering: -webkit-crisp-edges;
        image-rendering: pixelated;
        image-rendering: crisp-edges;
      }
    </style>
  </head>

Now that we have the header let’s add the body. The HTML body is going to be simple. It will just contain a centered canvas element with 128x128 pixels. Add the following code to sprite.html:


  <body>
    <center>
    <canvas id="cnvs" width="128" height="128"></canvas>
    </center>
  </body>

The JavaScript

Inside the HTML, we will add a script tag and the JavaScript code that will call our WebAssembly module and render the Wasm memory buffer to the canvas once per frame render. First, we need to follow the closing script tag with the closing HTML tag. Here is the code:


  <script>
    const canvas = document.getElementById("cnvs");
    const ctx = canvas.getContext("2d");
    const cnvs_size = 128;
    const img_buf_size = cnvs_size * cnvs_size * 4;
    ctx.clearRect(0, 0, cnvs_size, cnvs_size);

    const memory = new WebAssembly.Memory({ initial: 1 });

    const importObject = {
      env: {
        buffer: memory,
        cnvs_size: cnvs_size,
        img_buf_size: img_buf_size,
      }
    };

    const image_data =
      new ImageData(new Uint8ClampedArray(memory.buffer, 0, img_buf_size), cnvs_size, cnvs_size);

    var animation_wasm;

    function animate() {
      animation_wasm();
      ctx.putImageData(image_data, 0, 0);
      requestAnimationFrame(animate);
    }

    (async () => {
      let obj = await WebAssembly.instantiateStreaming(fetch('sprite.wasm'),
        importObject);

      animation_wasm = obj.instance.exports.main;
      requestAnimationFrame(animate);
    })();
  </script>
  </html>

The first thing the JavaScript does is call getElementById to get the canvas element. It then creates a context from the canvas element by calling the getContext function. The constant cnvs_size is set to the canvas size (128). A constant containing the image buffer size (img_buf_size) is set to the canvas size squared multiplied by 4 (32-bits / pixel). This works out to exactly 64K of memory, which in Wasm is a single page. When we create the memory, we do so allocating a single page with new WebAssembly.Memory. An importObject is created to pass into the WebAssembly module when we create it. That importObject is used to pass the memory object, the canvas size and the image buffer size to the WebAssembly module. The code then creates an ImageData object from WebAssembly linear memory to render to the canvas.

Rendering a frame

The next chunk of code renders the image data onto the canvas. It declares a variable to hold the function in our Wasm module called animation_wasm. It also declares an animate function that is called on each animation frame render. Here is that code:


  var animation_wasm;

  function animate() {
    animation_wasm();
    ctx.putImageData(image_data, 0, 0);
    requestAnimationFrame(animate);
  }

The animation_wasm variable will be set to a function when the Wasm module when we initialize it. Inside the animate function we call anation_wasm, which at this point is the function inside of the Wasm module. That function will change linear memory to display what we write to it in the Wasm module. The call to putImageData takes the data in linear memory and writes it to the canvas. Finally, the call to requestAnimationFrame calls the animate function the next time a frame is rendered.

Initializing the Wasm module

We use an asynchronouse IIFE to instantiate the WebAssembly module. We set the amimation_wasm variable to the main function exported by the WebAssembly module, and then run the animate function on the next animation frame render:


  (async () => {
    let obj = await WebAssembly.instantiateStreaming(fetch('sprite.wasm'),
      importObject);

    animation_wasm = obj.instance.exports.main;
    requestAnimationFrame(animate);
  })();

After that, we have to end the script and html tags at the end of the file:


  </script>
  </html>

The WebAssembly Text

The WebAssembly module will have a single 8x8 smiley face sprite. That doesn’t allow for much detail but is the same size as sprites on the original NES. To get larger game elements, the NES would render multiple sprites next to each other. Our sprites are a single 64-bit integer, which is one bit per pixel. But before we define our sprite, we need to define the start of the module and the values we are importing from JavaScript. Create a new file called sprite.wat and add the following code:


  (module
    (global $cnvs_size (import "env" "cnvs_size") i32)
    (global $img_buf_size (import "env" "img_buf_size") i32)
    (import "env" "buffer" (memory 1))

After defining the imports, we need to define a global y position for the sprite and a 64-bit integer to define the sprite itself. I’ve included a comment that shows the binary which makes it easier to visualize the sprite data. The 1 bits display in color, and the 0 bits are transparent. The $smile i64 variable is the sprite data.


  (global $yval (mut i32) (i32.const 60))

  (;
    0 0 1 1 1 1 0 0         0x3c
    0 1 0 0 0 0 1 0         0x42
    1 0 1 0 0 1 0 1         0xa5
    1 0 0 0 0 0 0 1         0x81
    1 0 1 0 0 1 0 1         0xa5
    1 0 0 1 1 0 0 1         0x99
    0 1 0 0 0 0 1 0         0x42
    0 0 1 1 1 1 0 0         0x3c
  ;)

  (global $smile i64 (i64.const 0x3c_42_a5_81_a5_99_42_3c))

Clearing image memory

Next, we need to define the functions in this module. The first function will clear the linear memory we are using for our image data to the color black:


  (func $clear_canvas
    (local $i i32)

    (loop $pixel_loop
      (i64.store (local.get $i) (i64.const 0xff_00_00_00_ff_00_00_00))
      (i32.add (local.get $i) (i32.const 8))
      local.set $i

      (i32.lt_u (local.get $i) (global.get $img_buf_size))
      br_if $pixel_loop
    )
  )

The function uses a loop to loop over every 8th byte storing a 32-bit color for every pixel in memory. A call to i64.store stores two pixels at a time to speed up the process.

Setting a pixel to a color

The following function sets a pixel at a specific x and y coordinate to a given color. The function checks the x and y coordinates and verifies that the pixel’s location is on the canvas. If it is, it calculates the location in memory to write the pixel color.


  (func $set_pixel
    (param $x i32)
    (param $y i32)
    (param $c i32)
    
    (i32.ge_u (local.get $x) (global.get $cnvs_size))
    if
      return
    end

    (i32.ge_u (local.get $y) (global.get $cnvs_size))
    if
      return
    end

    local.get $y
    global.get $cnvs_size
    i32.mul

    local.get $x
    i32.add

    i32.const 4
    i32.mul

    local.get $c

    i32.store
  )

Rendering the sprite

The $render_sprite function loops over every bit in the 64-bit integer we are using as sprite data. If the bit in the integer is 1 the function writes $color to the linear memory buffer. If it is 0, the function moves on to the next bit. Here’s the code:


  (func $render_sprite 
    (param $sprite_data i64)
    (param $color i32)
    (param $x i32)
    (param $y i32)

    (local $i i32)

    (loop $pixel_loop
      local.get $sprite_data 
      i32.const 63 
      local.get $i  ;; [$sprite_data, 64, i]
      i32.sub ;; 64 - $i [$sprite_data, 64 - i]
      i64.extend_i32_u
      i64.shr_u ;; $sprite_data >> (64 - $i)
      i64.const 1
      i64.and ;; ($sprite_data >> (64 - $i)) & 1
      i32.wrap_i64
      if ;; if pixel data is 1
      
        local.get $x
        local.get $i
        i32.const 0x07
        i32.and ;; last 8 bits of $i
        i32.add ;; $x + ($i & 0000 1111) 
        ;; now you have the x value for the pixel

        local.get $y
        local.get $i
        i32.const 3
        i32.shr_u ;; $i / 8
        i32.add ;; $y + ($i / 8)
        ;; now you have the y value for the pixel

        local.get $color

        call $set_pixel
      
      end

      local.get $i  
      i32.const 1   
      i32.add       ;; $i++
      local.tee $i

      i32.const 64
      i32.lt_u
      br_if $pixel_loop
      
    )
  )

The main function

The final function in the Wasm module is the main function exported for JavaScript. The function clears the canvas, updates the y position of the sprite and renders the sprite to linear memory:


    (func (export "main")
      call $clear_canvas
      global.get $smile
      i32.const 0xff_00_ff_ff ;; abgr
      i32.const 60
      global.get $yval
      i32.const 1
      i32.add
      global.set $yval
      global.get $yval
      i32.const 128
      i32.ge_s
      if
        i32.const -7
        global.set $yval
      end
      global.get $yval
      call $render_sprite
    )
  )

After you’ve added all of the WAT code to sprite.wat, you can compile it with the following wat2wasm command:


  wat2wasm sprite.wat -o sprite.wasm

When you load the web page, it should look something like this:

The Art of WebAssembly

The Art of WebAssembly
Author and expert Rick Battagline eases the reader through Wasm's complexities using clear explanations, illustrations, & plenty of examples.
Learn More

Hands-On Game Development with WebAssembly

Hands-On Game Dev with Wasm
Author and expert Rick Battagline teaches 2D game development fundamentals in C++ using the Emscripten WebAssembly toolchain.
Learn More

Classic Solitaire

ClassicSolitaire.com
Are you bored right now? Play Classic Solitaire and be slightly less bored. Also, it's how I earn a living, so it would really help me out if you wasted time on my site. :-)
Play Solitaire