Embedding Rust WASM in Jekyll Posts
So you’re probably curious how this little animation was embedded into this
post. The journey from winit
application to HTML canvas element is a long
one.
Why bother?
Honestly just for the novelty of it. I know I could’ve ported this to a small
JavaScript script on my own, but given winit
’s commitment to support multiple
platforms it only seemed right to use winit
’s inbuilt support for targeting
WASM
.
Recommendations
This section includes personal recommendations that solved issues I had porting
my winit
application to WASM
but also made the development easier.
Compatibility Crates
A lot of my issues come down to the Rust std
library not being available on
the web. This should be surprising to no one but luckily a ton of crates exist
as a compatibility layer.
Just a few examples for reference:
getrandom
with thejs
feature provides access to the rand crateInstant
provides a compatibility layer forstd::time
because it stops working when using wasm-bindgen or stdweb- Install it and replace all uses of
std::time
withinstant::Instant
. Don’t worry, if you aren’t building forWASM
it’ll alias tostd::time
seamlessly
- Install it and replace all uses of
Fix for web-gpu
If you are doing any GPU rendering or one of your dependencies offloads its
rendering to the GPU and it supports web-gpu
you may encounter an issue
where the canvas is properly set up but there is no rendering happening.
The fix is to make sure your Cargo.toml
includes a web-gpu
feature. Find
what version you need and add this line to your Cargo.toml
.
[target.'cfg(target_family = "wasm")'.dependencies] # If you have other targets too.
wgpu = { version = "0.12.0", features = ["webgl"]}
Trunk
I recommend installing trunk
which is a cargo utility that allows for
automatic building and deployment to WASM
for rust projects. This’ll make
development and debugging a lot easier.
You can install it using:
cargo install trunk
No Trunk
Proceeding without trunk is possible but not recommended. You can still build your project without it but you won’t be able to test your program without deploying it to a website manually. This blog post will assume you are using it so anytime you see
trunk build
you can use
cargo build --target wasm32-unknown-unknown
unless you specify the wasm32-unknown-unknown
target
in your .cargo/config
file. If you specify the target manually cargo build
works just fine.
WASM
Module
This tutorial will include writing a lot of functions only needed when
compiling to WASM
. This is simple to set up in Rust using the #[cfg()]
attribute macro.
#[cfg(target_arch)]
fn foo() {
}
This can get incredibly tedious to write for every function but luckily
you can put all the functions in a mod wasm
like this:
#[cfg(target_arch)]
mod wasm {
fn foo() {
}
}
And now the whole module will only be compiled to the WASM
target. This
tutorial assumes you have done this so any function calls to functions we
create include the wasm::
namespace.
Using wasm-bindgen
to build the WASM executable
This section goes over how to set up your rust project to be compiled to wasm for web.
New Dependencies
When targeting web alone just add the following dependencies using cargo
add [dependency]
. If you have multiple targets make sure to only include
these dependencies for the WASM
target by putting them under a cfg
tag
like this:
[target.'cfg(target_family = "wasm")'.dependencies]
foo = "0.1.0"
What dependencies you need really depend on your use case but I recommend at least adding the following:
console_log
allows you to useconsole_log()
as an equivalent to JS’sconsole.log()
wasm-bindgen
gives you procedural macros to make the inter op between JS andWASM
much simpler.wasm-bindgen-futures
allows you to use JS’s event loop to handle async code.web-sys
gives you access to the DOM of the webpage in order to manipulate it (and add the canvas thatwinit
will draw to).
Lastly as a personal recommendation, a lot of the crates in use tend to change a lot and you’ll find your head against the wall as you see dependency chains break themselves. When it comes to these crates using a *
version may be advised to have cargo do the heavy lifting as far as finding the version of the crate that happens to work with all your other dependencies
Refactoring for WASM
for web
Your first question hopefully is,
Okay okay, I installed these new dependencies but what am I actually using them for?
Well I’m glad you asked!
Turns out that web assembly does not have a defined start function like you
would expect coming from the variety of languages that use a main
function.
Luckily wasm-bindgen
has an attribute macro that allows us to tag a
function to start our WASM
program.
It looks like:
#[wasm_bindgen(start)]
pub fn run() {
}
Now you’re wondering,
But wait? Couldn’t I use that macro on my
main
function and call it a day?
Well yes! Of course you can!
But wait! You’re not seeing why defining a new function is useful. See when we
compile for WASM
we lose a lot of things like the ability to print to a
console, or even a panic handler. This is where defining a different entry
point can be useful to allow you to set these things up.
Okay you’ve convinced me… But then how do we set those things up?
Well that’s actually exactly where the console_log
crate comes in. It defines
two functions to set both those things up.
To set up printing to a console:
console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
And to set up a panic handler:
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
Altogether our function looks like:
#[wasm_bindgen(start)]
pub fn run() {
console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
#[allow(clippy::main_recursion)]
super::main();
}
And we’re done. If you aren’t doing any asynchronous code you can head over to the next section. Otherwise stick around for the next subsection.
Using wasm-bindgen-futures
for async code
Hopefully you are using tokio
for your async code as this
will make this section much easier. You most likely are using tokio
’s attribute
macro for your async main
function aren’t you?
You caught me! But why does that matter?
Well that macro under the hood sets up tokio
’s async runtime, which won’t be
available in WASM
. This means we need to use JavaScript’s async runtime
leveraging wasm-bindgen-futures
to do so.
This is not too difficult although it requires unraveling tokio
’s macro. This
is because we’ll want to use Rust’s #[cfg()]
attribute to set up the
appropriate runtime.
Under the hood setting tokio
’s macro creates a new main
function with the
structure:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.enable_time()
.build()
.unwrap()
.block_on(async { run().await });
}
where the body of what was in your async main
function is put inside the async block you see in the block_on()
function.
But before we move on let’s see how wasm-bindgen-futures
sets up their
runtime.
wasm_bindgen_futures::spawn_local(run());
Well that’s easy enough.
Now once we’ve rewritten the async main
function like this we can add our
#[cfg()]
attributes and we’re running async code again!
fn main() {
#[cfg(target_arch = "wasm32")]
{
wasm_bindgen_futures::spawn_local(run());
}
#[cfg(not(target_arch = "wasm32"))]
{
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.enable_time()
.build()
.unwrap()
.block_on(run());
}
}
Deploying to GitHub Pages using Jekyll
The following tutorial really only applies to projects built with
winit
. Feel free to continue the tutorial
if it applies to you or see if it helps with your application anyway. If it
does, let me know and I’ll consider adding it your discoveries to future
editions of this blog post!
Testing your WASM
Project
At this point you’ve most likely tried learning more of the trunk
utility I
recommended installing and encountered the trunk serve
command.
If you haven’t I’ll fill you in. trunk serve
builds your project and then
serves an index.html
file in your cargo project root modified to include your
WASM
executable and the JavaScript script used to start it.
But I don’t have an
index.html
file in my project root!
Then make one! Here’s a good enough template.
<!doctype html>
<html lang=en>
<head>
<meta charset=utf-8>
<title>Super Descriptive Title!</title>
</head>
<body>
</body>
</html>
Okay I’ve added the
index.html
but I still don’t see my app on the webpage!
Well that’s because our application does not add itself to the page’s DOM yet!
Luckily it’s an easy fix using the web-sys
crate to handle our DOM
manipulation.
pub fn create_canvas(window: &Window) {
use winit::platform::web::WindowExtWebSys;
let canvas = window.canvas();
canvas.style().set_css_text(
"background-color: crimson; margin: auto; width: 100%; aspect-ratio: 4 / 3;",
);
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
body.append_child(&canvas).unwrap();
}
Woah! Woah! Woah! What’s all this?
Okay I know a lot of stuff is happening so let’s break it down.
let canvas = window.canvas();
This takes our winit
window and creates an HTML canvas that we can now use to
put on the website.
canvas.style().set_css_text(
"background-color: crimson; margin: auto; width: 100%; aspect-ratio: 4 / 3;",
);
This lines sets the CSS of the canvas so that we can see where in the canvas
winit
is not drawing to. This will give you a hint about how to make sure
the canvas is the proper size.
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
body.append_child(&canvas).unwrap();
And finally this block of code is the actual DOM manipulation. What impresses
me is how similar this looks like to the equivalent JS but that’s just an
artifact of web-sys
’s diligence in their APIs.
Now that we’ve set up this function make sure to call it at some point after
creating your winit
window and before starting your event loop, like this
to ensure the call only happens when compiling to WASM
.
#[cfg(target_arch = "wasm32")]
wasm::create_canvas(&window);
Adding Event Loop Logging for Debug Builds
This part is optional since it is not needed for the final setup on your Jekyll
page, but it helps for debugging your build should it have issues on WASM
.
Now that you know the basics of DOM manipulation using web-sys
, we’ll quickly
go over how to add a log for events in your event loop to the web-page. This
makes it easier to debug your mobile website since access to the console is
less convenient on mobile.
Here’s the code for creating the log list. Using the #[cfg(debug_assertions)]
tags we ensure that this code is only compiled for debug builds.
#[cfg(debug_assertions)]
pub fn create_log_list(window: &Window) -> web_sys::Element {
create_canvas(window);
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
let log_header = document.create_element("h2").unwrap();
log_header.set_text_content(Some("Event Log"));
body.append_child(&log_header).unwrap();
let log_list = document.create_element("ul").unwrap();
body.append_child(&log_list).unwrap();
log_list
}
Now in the code where we add our canvas we want to change it slightly to set up the DOM correctly when using debug or release builds. This’ll do fine:
#[cfg(all(target_arch = "wasm32", not(debug_assertions)))]
wasm::create_canvas(&window);
#[cfg(all(target_arch = "wasm32", debug_assertions))]
let log_list = wasm::create_log_list(&window);
Now let’s setup the logging function:
#[cfg(debug_assertions)]
pub fn log_event(log_list: &web_sys::Element, event: &Event<()>) {
log::debug!("{:?}", event);
// Getting access to browser logs requires a lot of setup on mobile devices.
// So we implement this basic logging system into the page to give developers an easy alternative.
// As a bonus its also kind of handy on desktop.
if let Event::WindowEvent { event, .. } = &event {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let log = document.create_element("li").unwrap();
log.set_text_content(Some(&format!("{:?}", event)));
log_list
.insert_before(&log, log_list.first_child().as_ref())
.unwrap();
}
}
And add logging in the event loop:
#[cfg(all(target_arch = "wasm32", debug_assertions))]
wasm::log_event(&log_list, &event);
Feel free to trunk serve
and see the ever expanding log!
Building and Including your Assets
At this point in the tutorial I bet you weren’t expecting me to ask you to
install one more tool but this is only important for making running your WASM
executable easier. Go ahead and type
cargo install wasm-bindgen-cli
This tool lets you generate all the files you need to host your WASM
project
on your Jekyll page. Create a directory to put the files into in your project,
and run the following.
wasm-bindgen --no-typescript --out-dir out/ --target web target/wasm32-unknown-unknown/{release||debug}/{application}.wasm
Inside the out
directory you should see:
$ ls out/
.
..
{application}_bg.wasm
{application}.js
Inside your Jekyll project you can put both files inside the assets/js/
folder or separate them into assets/js/
and assets/wasm/
. Just make sure to
remember what choice you made.
Header Links
Well now that we have our files uploaded to our website we need1 to tell Jekyll our blog post needs to link the two files we generated in the last section to make them available.
Hopefully your Jekyll configuration/theme allows you to add stuff to the page’s
<head>
tag. Assuming you do, make sure that in the <head>
tag you include
these two <link>
tags.
<link rel="modulepreload" src="/assets/js/{application}.js">
<link rel="preload" src="/assets/wasm/{application}_bg.wasm" as="fetch" type="application/wasm" crossorigin="">
Scripts in Markdown
Now we need to add a script to start the WASM
project. Luckily, if you were
unaware, you can embed any HTML tags straight into Markdown. So make sure you
add this anywhere in your blog post.
<script type="module">
import init from '/assets/js/{application}.js';init('/assets/wasm/{application}_bg.wasm');
</script>
Placing the Canvas in your Post
You probably immediately noticed that your HTML canvas was put right at the bottom of your whole website!
Well fret not! This is technically by design! Our code from this
section manipulates the DOM and adds our canvas to
the very end of our <body>
tag. Let’s refactor it to add it
exactly where we want it.
First start by adding a <div>
with and id exactly where you want
the canvas to be placed in the website. Something like this:
<div id="{application}_canvas"></div>
Next go to your create_canvas
function and refactor it to something like
this:
pub fn create_canvas(window: &Window) {
use winit::platform::web::WindowExtWebSys;
let canvas = window.canvas();
canvas.style().set_css_text(
"background-color: crimson; margin: auto; width: 100%; aspect-ratio: 4 / 3;",
);
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
let canvas_div = document
.get_element_by_id("{application}_canvas")
.unwrap_or(From::from(body)); //Get the div
canvas_div.append_child(&canvas).unwrap();
}
The notable line of code is:
let canvas_div = document
.get_element_by_id("{application}_canvas")
.unwrap_or(From::from(body));
Here we are grabbing the <div>
we put in our website and using the <body>
tag
as a fallback, in case we are debugging using trunk serve
, and placing the
canvas inside that.
And Voila! after all this effort you should have a winit
application
running within your blog post!
Wrapping Up
I hope this blog post helps anyone trying to do anything remotely like I did. If this post did not manage to help you enough feel free to consult both my application repository and my blog repository.
-
I’m not actually completely sure this step is necessary but I found these in the source for the HTML that
trunk
serves, so I assume it at least is worth including. ↩