More Emscripten: the shared file system in a web application
Previously, I covered how to build native code (a game-like sample written in C++) for the web. That post covered Emscripten, a powerful tool for transforming native code into WebAssembly. That sample contained asset files such as the images, music and sounds a game typically includes.
What if you wanted to read or write non-asset files in your application? Suppose a game that has a high-score table for instance. With Emscripten you can do this too! Not just from the native side, but also from the frontend's JavaScript code.
The project: a breakout clone with kittens
For today's post, I've written a small game using Raylib that runs on most computers. The game in question is a clone of the Atari classic, Breakout. I won't go into detail about the game here, but it was a fun game to build for the purposes of this demo.
Our goal is to take this code, and leverage Emscripten's file system from the frontend's side to dynamically alter the running game.
Specifically, we'll retrieve an image from placekitten.com, write it to /tmp
, and then load it into the game.
To build the project, we can use a similar build process to the previous post to get this into the browser. Here is an excerpt of linker flags we pass to the compiler:
-lraylib \
-lm \
-s USE_GLFW=3 \
-s GL_ENABLE_GET_PROC_ADDRESS \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s ENVIRONMENT=web \
-s EXPORTED_RUNTIME_METHODS=['FS'] \
--embed-file ./assets \
-O3
Like last time, we link against the GLFW, Raylib and standard math libraries. Then, we specify a few flags that handle the generated JavaScript module. There's two flags here that are particularly important:
-s ENVIRONMENT=web
This flag ensures the module generated is intended to run in the browser.
This is important if you leverage a frontend build system. In the sample project, we use Parcel.
-s EXPORTED_RUNTIME_METHODS=['FS']
This flag ensures the module generated contains the
FS
object.We need this if we want our JS code to interact with the Emscripten File System.
Reading files from native code
Just like last time, we specified an --embed-file
flag containing a directory of assets to ship inside of our wasm file.
Once specified, this takes care of the work needed for us to leverage standard io functions on our asset files.
What about other files though? It turns out, Emscripten does the lifting for us to have a POSIX-like environment to read and write files from.
To demonstrate this, here's a peek at our loadCat
method:
void Game::loadCat() {
UnloadTexture(this->bg);
this->bg = LoadTexture("/tmp/cat.png");
}
This method removes the stock background and replaces it with a PNG file located within /tmp
. What is /tmp
though?
Per the API docs:
File data in Emscripten is partitioned by mounted file systems. Several file systems are provided. An instance of MEMFS is mounted to / by default. The subdirectories /home/web_user and /tmp are also created automatically, in addition to several other special devices and streams (e.g. /dev/null, /dev/random, /dev/stdin, /proc/self/fd); see FS.staticInit() in the FS library for full details.
Essentially, the FS API provides enough of an abstraction that our native code can read/write to an in-memory file system that looks like a typical POSIX one. This is particularly handy, since our C++ game doesn't need to worry about where it's running.
Indeed, if you were to build and run this game on a Linux or MacOS system, you could place a PNG file in /tmp
and call this method successfully!
Writing files from JavaScript
How do we get the file into /tmp
in a browser? The generated wasm module has an FS
object that we can use for this purpose.
Here's a peek at our kitten()
function from the JavaScript side:
async function kitten() {
if (!gameModule) {
return;
}
// download the cat as a binary blob
const catto = await (await fetch('https://placekitten.com/g/640/480')).blob();
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// convert the blob to an img-src
const cattoImg = await blobToImage(catto);
// set up our temporary canvas for drawing into it
canvas.height = cattoImg.naturalHeight;
canvas.width = cattoImg.naturalWidth;
context.drawImage(cattoImg, 0, 0, cattoImg.naturalWidth, cattoImg.naturalHeight);
const canvasBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 1));
const bufferView = new Int8Array(await canvasBlob.arrayBuffer());
gameModule.FS.writeFile(
'/tmp/cat.png',
bufferView
);
// inform native code it is time to load the cat!
gameModule['_loadCat']();
}
There's a few things going on here. Let's break it down:
- First we fetch an image from the cat placeholder website.
- Next, because that image is a JPEG file, we need to convert it to a PNG for our game.1
- We do this on the JavaScript side by writing the asset to a canvas and then converting the canvas to a PNG blob.2
- With our canvas PNG blob, we need to get it into a format the FS API can write.
- This means converting it to an array buffer, and then a byte array.
- We call
FS.writeFile
specifying the path to write our file into. - Lastly, with the file written, we invoke the exported function
loadCat
. This calls into our game's loadCat method referenced above to read back the written image.
With our file writing and file reading handlers in place, all that's left to do is provide a way for the user to invoke all this code. That means wiring up a button on the page, and presto:
Final thoughts
A breakout clone is something we could have written entirely in JavaScript. Why go through all of this effort? For simple games such as this one, it might have made sense to do just that. As with many things in computers, there are tradeoffs to consider. More complex applications might have performance or porting considerations that make choosing JavaScript impractical.
Furthermore, the benefits of the web environment to enable creating new experiences cannot be discounted. We took a game and extended it for the web in a unique way. There are other ways to extend this sample. Consider a drag-and-drop area that allows the user to change the blocks or paddle. The sky's the limit!
Raylib does support loading JPEGs, but it is good form to normalize the content we load. Consider if our image service returned a format that was incompatible with our graphics library 💥
We chose to do this on the JavaScript side for the purposes of demonstrating the power of the Emscripten file system API. We could have chosen to do this from our native code using a library like ImageMagick, though.