Skip to main content

One post tagged with "docusaurus"

View All Tags

Bevy WASM rendering

· 5 min read
A Bevy "lava lamp"

Added an experiment of rendering with Bevy using WASM. It's nothing too exciting as I'm new to Bevy, WASM, and still relatively inexperienced with Rust!

Demo page here.

A few notes on the development:

Letting the browser decide the host Element ID

I wanted to host this experiment (and future ones) within the context of pages within the Docusaurus app. I certainly do not know the "best" way to do this yet, but one definite constraint to pass the canvas id to the WASM module at startup rather than hard-coding it in the WASM and browser.

As far as I can tell, the WASM init function does not take arguments, therefore the startup is exposed via a separate wasm_bindgen exported function called start.

In the Rust snippet below, you can see we do nothing in main and explicitly pass in an id to the WindowPlugin in a separate start function.

fn main() {
// The run() entrypoint does the work as it can be called
// from the browser with parameters.
}

#[wasm_bindgen]
pub fn start(canvas_id: &str) {
let id = format!("#{}", canvas_id);

App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
canvas: Some(id.into()),
..default()
}),
..default()
}))
.add_systems(Startup, setup)
.add_systems(
Update,
(
move_ball, //
update_transforms,
),
)
.run();
}

The JavaScript code to bootstrap this looks like this:

const go = async () => {
let mod = await import(moduleName);
await mod.default();
await mod.start(canvasID);
};
go();

But admittedly the above is not the actual JavaScript code for hosting the WASM module...

Running the WASM code in Docusaurus

Disclaimer

This is workaround code. It "works" but I'm sure there is a correct way to handle this that I was not able to discover!

(1) Idempotence

There's something I don't understand about WASM module loading and, more importantly, reloading/reuse. This is problematic in the context of a Single Page Application (SPA) like Docusaurus where if you navigate to page ABC, then to page XYZ, then back to ABC, any initialization that happened on the first visit to page ABC will happen again on the second visit. In other words, I'm not sure how to make the WASM initalization idempotent.

If there's a correct way to...

  • Reuse a WASM module that's already loaded
  • Unload a WASM module on navigation away from the page
  • Reload a WASM module

...I'd enjoy learning how!

(2) Docusaurus script building/bundling

Docusaurus also has logic for renaming, bundling, rewriting, etc. JavaScript code used on the pages. I'm not sure what the exact logic of what it does, but end of the day, I did not want Docusaurus manipulating the JS generated by the WASM build process.

Admittedly this is a bit of laziness on my part for not really understanding what Docusaurus does and how best to circumvent it.

Workaround

I worked around the reload problems and script mangling with a custom MDX Component in Docusaurus that:

  1. Directly injects a <script> element so Docusaurus can't modify what it does
  2. Uses a hacky exception handler to reload the page if the WASM seems to fail (usually what happens on the second visit to the page without an intermittent reload)
  3. Sets a retry limit in case the logic for (2) is not right in all cases (because this is a hacky workaround, not robust code so precautions against an infinite loop are important!)
export function CanvasWASM({
id,
module,
width,
height,
style,
}: {
id: string,
module: string,
width: number,
height: number,
style?: React.CSSProperties,
}) {
React.useEffect(() => {
// We create a a DOM element since Docusaurus ends up renaming /
// changing the JS file to load the WASM which breaks the import
// in production. This is pretty hacky but it works (for now).
const script = document.createElement('script');
script.type = 'module';
script.text = `
let key = 'wasm-retry-count-${module}-${id}';
let success = setTimeout(function() {
localStorage.setItem(key, "0");
}, 3500);

const go = async () => {
try {
let mod = await import('${module}');
await mod.default();
await mod.start('${id}');
localStorage.setItem(key, "0");
} catch (e) {
if (e.message == "unreachable") {
clearTimeout(success);
let value = parseInt(localStorage.getItem(key) || "0", 10);
if (value < 10) {
console.log("WASM error, retry attempt: ", value);
setTimeout(function() {
localStorage.setItem(key, (value + 1).toString());
window.location.reload();
}, 20 + 100 * value);
} else {
throw e;
}
}
}
};
go();
`.trim();
document.body.appendChild(script);
}, []);

return <canvas id={id} width={width} height={height} style={style}></canvas>;
}

An aside of GitHub Large File Storage (LFS)

In case anyone on the internet runs into this and stumbles upon this page...

I also wasted quite a bit of time on this problem:

  1. I publish this site using GitHub Pages
  2. I generally set a .gitattributes file in all my repos to store generated files using git LFS
  3. I have an entry for *.wasm to store those in LFS
  4. GitHub Pages doesn't use LFS when serving the files

This meant my code was working locally but when I was trying to load the WASM files on the published site, the LFS "pointer" text file was being served rather than the binary WASM file itself. It took me a while to figure this out. Ultimately the fix was to remove the .gitattributes from the GitHub Pages repo so LFS is not used on the published site. (Aside: this might be a good reason to consider hosting this site via another platform, but I'll leave that for another day!)