Rendering 2D Graphics from WebAssembly: Canvas, OpenGL, and SDL2 Options

Porting native graphics code to the web often means dealing with a new set of APIs. Emscripten provides several paths for drawing 2D content to an HTML <canvas> element from C or C++ compiled to WebAssembly, each with different trade-offs.

Direct Canvas Access with Embind

For entirely new projects, Emscripten's Embind binding system offers the most direct route. It lets you interact with arbitrary JavaScript values, including the Canvas API, from C++. A standard JavaScript canvas drawing example can be transliterated fairly directly:

// JavaScript original
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
// ... drawing commands ...

The C++ version uses emscripten::val to hold references to the canvas and its context, allowing you to call methods like fillRect() on the context object. To compile this, you must pass the --bind flag to the Emscripten linker to enable Embind:

emcc example.cpp -o example.html --bind

When you use Emscripten's generated HTML shell, the canvas is set up automatically. For a custom page, you need to provide a canvas element through the Module.canvas property. In ES6 module mode (using the EXPORT_ES6 setting), this is passed during initialization:

import Module from "./example.mjs";
const module = await Module({ canvas: document.querySelector("canvas") });

For regular script output, declare the Module object before loading the generated JavaScript:

<script>
  var Module = { canvas: document.querySelector("canvas") };
</script>
<script src="example.js"></script>

Using OpenGL and SDL2 for Porting

Porting an existing application is a different problem. Many projects rely on OpenGL. Emscripten converts supported OpenGL ES 2.0 and 3.0 commands to WebGL. Higher-level libraries built on OpenGL, such as SDL2, have been ported to WebAssembly as well, providing a more familiar and feature-complete API for tasks like handling input and audio.

With SDL2, you can write fairly standard code to open a window and draw a rectangle. The key change is linking with the SDL2 library specifically compiled for Emscripten:

emcc sdl2-rectangle.cpp -o sdl2-rectangle.html -s USE_SDL=2

This simple version has flaws beyond just missing cleanup. On the web, the program's completion doesn't close the browser tab, so the image stays visible. However, when the same code is compiled natively, the window appears and closes instantly, leaving no time to see the result. A more correct version adds an event loop to wait for the user to quit.

Event Loops and WebAssembly

The idiomatic SDL2 event loop is an infinite loop that checks for and processes events until it receives an SDL_QUIT event. While this works for native builds, it fails on the web. The browser runs an implicit event loop, and your C/C++ code occupies a single turn of that loop. By never returning, the inner event loop blocks the browser's, preventing it from processing user events or repainting the page, which causes the page to appear frozen.

There are two standard ways to resolve this deadlock.

Pausing with Asyncify

Emscripten's Asyncify feature can pause and resume C/C++ execution. You can give control back to the browser's event loop from within your loop by calling emscripten_sleep(0), which requests an asynchronous operation that completes immediately. This yields control to the browser on each iteration, keeping the page responsive:

int running = true;
while (running) {
  // process SDL events
  SDL_Event event;
  while (SDL_PollEvent(&event)) { /* handle */ }
  // draw frame here
  emscripten_sleep(0);
}

Compiling this requires enabling Asyncify:

emcc -s ASYNCIFY -s USE_SDL=2 ...

The main downside is the code size overhead Asyncify imposes.

Restructuring with Main Loop Callbacks

An alternative free of that overhead is to restructure your logic to fit the browser's model. The emscripten_set_main_loop API takes a callback function that is invoked on each frame. You pass the callback, a frame rate (0 for the native refresh rate), and a boolean to simulate an infinite loop:

emscripten_set_main_loop(loop_callback, 0, true);

Because the callback function has no access to the main() function's stack, you must move variables like the SDL window and renderer into global static storage. This requires more manual code modification but maintains a smaller output binary. This approach pays off when you add animation or interactivity, such as a rectangle that moves in response to keyboard input.

SDL2_gfx for More Complex Shapes

SDL2 is low-level; while it can draw lines, points, and rectangles, more complex shapes need additional library support. SDL2_gfx is a companion library that fills this gap, providing routines like filledCircleColor(). Linking it is similar to linking SDL2 itself:

emcc sdl2-gfx.cpp -o sdl2-gfx.html -s USE_SDL=2 -s USE_SDL_GFX=2

This library can generate shapes like circles that render across both native and web platforms. Documentation for its full set of primitives is available from the project. Which approach you choose—Embind, raw OpenGL, or an SDL2-based stack—depends on whether you are starting fresh or porting existing code, and how you plan to structure the application's main loop.