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: