Tristan Penman's Blog

Porting an Asteroids clone to JavaScript

08 January 2018

This post shares my experience porting an SDL-based Asteroids clone to the web, using Emscripten, an LLVM-to-JavaScript compiler.

The original code was written over the course of several weekends, as a way to procrastinate while studying for exams. The aim was to get the game running quickly, taking a ‘less-is-more’ approach. Graphics were implemented using legacy OpenGL, while window management, audio and input were all handled by SDL. Although the game was never quite complete, it was still very playable!

Inspired by Paul Colby’s Emscripten talk at the Melbourne C++ meetup, I later decided to update the code so that it could be compiled to JavaScript, and played in a web browser. Of course, this exercise was not without its challenges.

With the right build flags, I was able to get the code to compile. But when first loaded in Safari, nothing worked. No graphics, no audio, and therefore no gameplay. What follows is a brain-dump of the issuses I had to resolve to get the game working properly.

For those who are feeling nostalgic, a playable demo can be found here. And the code can be found here.

Contents

Emscripten

New to Emscripten and wondering what it is, exactly? Check out this CppCon talk from Alon Zakai, the creator of Emscripten. Alternatively, you can skim through the slide deck.

Okay, let’s dive in!

Main Loop

Perhaps the most fundamental change to support Emscripten was the correct implementation of the ‘main loop’. In its original implementation, my Asteroids clone used a simple main loop, as described in the SDL_PollEvent documentation:

while (1) {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
        /* handle your event here */
    }
    /* do some other stuff here -- draw your app, etc. */
}

This body of the loop consumes all pending events from the event queue, before moving on to game logic. SDL_PollEvent blocks if there are no events to consume, and the outer while (1) ensures that this loop repeats indefinitely.

In a browser, we cannot simply block while waiting for events. Instead, the body of the outer loop is provided as a callback for window.requestAnimationFrame, which requests that the browser invokes a specific callback function before the next repaint. In this case, that function updates Emscripten’s canvas with the latest frame. Emscripten allows you to set that callback function using emscripten_set_main_loop. We’ll come back to this a bit later.

One gotcha here is that calling emscripten_set_main_loop multiple times (i.e. to switch between various event loops) would cause the Emscripten runtime to write an excessive number of warnings to the JS console. This was easily resolved using an internal API that also served to abstract out differences between native and Emscripten builds:

typedef void (*main_loop_fn_t)();

/**
 * Set the function that will be called on the next iteration of the event loop
 *
 * @param  main_loop_fn  pointer to the function to be called
 */
void set_main_loop(main_loop_fn_t main_loop_fn);

/**
 * Start running the main event loop
 *
 * Execution will continue until cancel_main_loop is called and the final
 * iteration is complete, at which point the program will terminate.
 */
void run_main_loop();

/**
 * Cancel next iteration of the event loop
 *
 * Execution will continue until the current iteration of the event loop has
 * completed, at which point the program will terminate.
 *
 * @param exit_code exit code that be returned to OS when progrma terminates
 */
void cancel_main_loop(int exit_code);

For Emscripten builds, this API adds a layer of indirection that ensures emscripten_set_main_loop is only ever called once. And for native builds, it reverts to the standard while(1) behaviour.

It is worth noting that this API is asynchronous. Whether compiled to native code or JavaScript, changes to the main loop do not take effect until the current iteration through the loop is complete.

Graphics

Legacy OpenGL

The original code used legacy OpenGL for simple 2D line- and point-based rendering. This could have been problematic, given that Emscripten relies on WebGL’s shader-based pipeline for OpenGL support. However, Emscripten also provides a rudimentary OpenGL 2.x compatibility layer, which can be enabled use the LEGACY_GL_EMULATION build flag, e.g:

emcc ... -s LEGACY_GL_EMULATION=1

For further optimisations, the GL_FFP_ONLY build flag can be used. As described in the Emscripten documentation:

-s GL_FFP_ONLY=1 tells the GL emulation layer that your code will not use the programmable pipeline/shaders at all. This allows the GL emulation code to perform extra optimizations when it knows that it is safe to do so.

Although Emscripten’s compatiblity layer was complete enough to make the game playable, some minor changes were needed in the rendering code.

Phaser shots and explosion effects rendered using GL_POINTS mode had to be re-written to use GL_LINES. There were also some inconsistencies in the behaviour of glColor3f, with calls to glColor3f between glBegin and glEnd causing various glitches. It was easy to work around the issue in this project, since colours do not change from one vertex to the next. But this one issue could be enough to force other applications to use OpenGL 3.x or 4.x, or to use Regal (https://github.com/p3/regal), a portable OpenGL 2.x compatibility layer.

High DPI Support

The native build of my Asteroids clone supports high-DPI displays, thanks to SDL. Since Asteroids uses line-based rendering, scaling the graphics is simply a matter of setting line width based on pixel density.

Emscripten includes support for a high-DPI canvas, but I encounted two issues. First is that CPU usage was unreasonably high when rendering to a high-DPI canvas - to the point of frame skipping. Native builds do not suffer from this issue, and I was not able to determine the cause.

Second involved moving a browser window between displays with different DPIs. Even after handling the relevant window events, updating the size of the HTML canvas, and recreating the OpenGL context, the canvas would continue to render at its initial DPI. I suspect that the only fail-safe solution may be to use inline JavaScript to programatically replace the canvas element.

I consider both of these issues to be show-stoppers, so the Emscripten build disables high-DPI support.

Swap Interval

Native builds require a call to SDL_GL_SetSwapInterval(1) to enable vertical sync. But with Emscripten, SDL_GL_SetSwapInterval is just a wrapper for emscripten_set_main_loop_timing, which will complain when called outside of the main loop:

emscripten_set_main_loop_timing: Cannot set timing mode for main loop since a main loop does not exist! Call emscripten_set_main_loop first to set one up.

As mentioned earlier, the default behaviour in Emscripten is that the body of our main loop is invoked by window.requestAnimationFrame. The argument passed to SDL_GL_SetSwapInterval is the number of vsyncs to wait between frames, the default between one. So we can simply exclude it in Emscripten builds.

Assets

Emscripten supports two modes for packaging files: preloading and embedding. Embedding puts the specified files inside the generated JavaScript, while preloading packages the files in a separate bundle.

emcc can be instructed to package the contents of a specific directory:

emcc ... --use-preload-plugins --preload-file assets

And if you need to map that directory to an alternate location in the packaged file system, you can append the mapped location like so:

emcc ... --use-preload-plugins --preload-file assets@data

The --use-preload-plugins flag tells Emscripten to use the browser’s codecs to automatically load and decode audio and image files at startup. For example, an image file will be loaded by creating an ‘Image’ element. Several SDL functions assume that this feature is enabled, but we will see later that this flag may be unnecessary.

Audio

Audio provided a few unexpected challenges…

Mix_LoadWAV issues

SDL2_mixer is used to play various sound effects, which are stored in WAV files. In the original code, samples were loaded using Mix_LoadWAV, as illustrated by this example:

Mix_Chunk *sample = Mix_LoadWAV("sample.wav");
if (NULL == sample) {
    printf("Mix_LoadWAV: %s\n", Mix_GetError());
    // handle error
}

This worked fine for native builds, but would cause a null pointer exception when running in a browser.

Digging into the source for Emscripten’s SDL port, it turns out that Mix_LoadWAV is simply a wrapper for Mix_LoadWAV_RW and SDL_RWFromFile. When unwrapped, these functions work without any issues:

SDL_RWops *rw = SDL_RWFromFile("sample.wav", "rb");
if (NULL == rw) {
    printf("SDL_RWFromFile: %s\n", SDL_GetError());
    // handle error
}

Mix_Chunk *sample = Mix_LoadWAV_RW(rw, 1);
if (NULL == sample) {
    printf("Mix_LoadWAV_RW: %s\n", Mix_GetError());
    SDL_FreeRW(rw);
    rw = NULL;
    // handle error
}

SDL_RWops is kind of interesting.

Here, the SDL_RWops structure provides an interface to read, write and seek data in a stream, without the caller needing to know where the data is coming from. Mix_LoadWAV_RW reads the audio file using that interface. The second argument (1), tells Mix_LoadWAV_RW to free the SDL_RWops structure when it is done.

Using this approach, Emscripten’s implementation of Mix_LoadWAV_RW (found in src/library_sdl.js) will happily load audio files decoded using the preload plugin feature. Note that this requires the --use-preload-plugins build flag mentioned earlier.

Network Traffic…?

While inspecting network traffic in Safari, I noticed that every time the mixer played a sample with Mix_PlayChannel, a data blob was requested via a URL such as:

blob:http://localhost:6931/30686c96-88fd-44f1-b925-221496a85b84

Each sample corresponded to a distinct URL, so I assumed that these were the data blobs loaded by Emscripten’s preload plugins. Those blobs were being fetched (possibly from cache) every time the mixer played a sample. But why?

Time to dive into ‘library_sdl.js’, Emscripten’s SDL shim layer. This code, from the JavaScript version of Mix_PlayChannel, is telling:

if (info.webAudio) {
    // Create an instance of the WebAudio object.
    audio = {};
    // This new object is an instance that refers to this existing resource.
    audio.resource = info;
    audio.paused = false;
    audio.currentPosition = 0;
    // Make our instance look similar to the instance of a <media> to make
    // api simple.
    audio.play = function() { SDL.playWebAudio(this); }
    audio.pause = function() { SDL.pauseWebAudio(this); }
} else {
    // We clone the audio node to utilize the preloaded audio buffer, since
    // the browser has already preloaded the audio file.
    audio = info.audio.cloneNode(true);
    audio.numChannels = info.audio.numChannels;
    audio.frequency = info.audio.frequency;
}

Setting breakpoints on each branch, I found that samples had not been loaded using the Web Audio API. Therefore, each time a sample is played, Safari would clone the audio element used to preload the sample.

After some investigation, I found that SDL’s mixer can be coerced to use the Web Audio API by loading audio data with SDL_RWFromMem rather than SDL_RWFromFile. SDL_RWFromMem simply takes the pointer and length for a buffer and returns an SDL_RWops structure for reading its contents.

After making these changes, the superfluous network traffic was gone. And using the JS debugger, I confirmed that the Web Audio API was being used.

A nice side effect of this change is that the mixer no longer depended on Emscripten’s preload plugins, making the --use-preload-plugins compiler flag unnecessary. This means that image and audio resources do not need to be loaded at startup, and instead be loaded only when needed.

Inline JavaScript

Emscripten’s JavaScript API is comprehensive, but there will be times when you want to run JavaScript from your C++ code. There are two ways to do this. First is emscripten_run_script, which returns void, and its variations for other return types. This approach is slower, but allows for dynamic code generation.

Second is an equivalent set of macros: EM_ASM, EM_ASM_INT, etc. Both approaches store the JavaScript as a string, and execute it using eval, but only the macro approach allows arguments to be specified. These can be accessed using $0, $1, etc. in the inline code.

Just one example is this snippet, which retrieves the client-width of the HTML document body:

const int client_width = EM_ASM_INT({
    return document.body.clientWidth;
});

Some interesting notes from the Emscripten documentation:

As of Emscripten 1.30.4, the contents of EM_ASM code blocks appear inside the normal JS file, and as result, Closure compiler and other JavaScript minifiers will be able to operate on them. You may need to use safety quotes in some places (a['b'] instead of a.b) to avoid minification from occurring.

The C preprocessor does not have an understanding of JavaScript tokens, and as a result, if the code block contains a comma character ,, it may be necessary to wrap the code block inside parentheses. For example, code EM_ASM(return [1,2,3].length); will not compile, but EM_ASM((return [1,2,3].length)); does.

Emscripten Container

Emscripten provides a default HTML template, that serves as the container for an application. But it is not necessarily what you would want to show to your end users:

Default container

Custom Container

This default container is great for debugging and development, and is what will be generated if you specify an ‘.html’ file as the linker output path or ‘emcc’. If you instead specify a ‘.js’ file as the linker output path, the default container will not be generated.

This is the custom container I ended up with for Asteroids:

Custom container, with nice rounded corners

Closing Thoughts

I am pleased to say that there were some features that Just Worked. Keyboard input worked perfectly, out-of-the-box. As did console output.

As I write this, there are still some improvements that I would to pursue, to really consider this project complete. The code is littered #ifdef __EMSCRIPTEN__ blocks. This is one of the costs of cross-platform development in C and C++, but could be improved with more modularisation.

Moving to non-legacy OpenGL would make the code more resilient to potential changes to Emscripten’s legacy compatibility layer. And it would make the code-base a more useful as a reference, or a starting point for future projects.

A playable demo can be found here.