Tutorial 81: Emscripten: Building for WebAssembly

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • Installing the Emscripten SDK and pointing CMake at its toolchain file.
  • Running the WEBGL2 renderer on WebGL 2, and the four other Emscripten-only renderers.
  • The web caveats: what changed about Game lifetime, how saves persist through IndexedDB, and that video needs an FFmpeg backend the web never gets.
  • How Game::Run() yields to the browser through Asyncify instead of blocking the page.
  • Customising the shell HTML and loading files asynchronously with Asyncify.

Before you start — Tutorial 80: Cross-Platform Build Guide (the WebAssembly build is one branch of that matrix) and Tutorial 05: The Game Loop (how the loop is driven). Web builds default to the WEBGL2 renderer on WebGL 2.

⚠

Read this before you write a line of web-specific code. Three things about CNA on the web are not obvious, not optional, and not in the Emscripten manual — and two of them changed since alpha.1.

1. Your Game may now live on the stack — if you link Asyncify. At the alpha.1 tag, Game::Run() ended in emscripten_set_main_loop(…, simulateInfiniteLoop = 1), which unwound the caller and silently destroyed a stack-allocated Game while the browser loop still held its address; the symptom was an indirect-call fault frames later, and the rule was “allocate with new”. In this snapshot Game::Run() keeps its caller alive: the loop runs on the same WebAssembly stack and suspends between browser frames through Asyncify, then returns when the game exits. MyGame game; game.Run(); is correct on every target now. The price is that your final executable must link CNA::EmscriptenAsyncify (CNA does that automatically only for executables inside its own source tree) — see the CMake block below. If you build against the alpha.1 tag, keep heap-allocating.

2. Saves persist through IndexedDB — except in a default threaded build. CNA’s storage module links Emscripten’s IDBFS, mounts it at /cna-storage (StorageDevice) and /save (IsolatedStorage), and restores both before main(); later writes are flushed to IndexedDB automatically, so a save survives a page reload. If the mount or the restore fails, StorageDevice throws StorageDeviceNotConnectedException instead of quietly saving to memory. A threaded build (-DCNA_ENABLE_EMSCRIPTEN_THREADS=ON) uses WasmFS by default, which cannot host IDBFS, so StorageDevice reports storage as unavailable there; configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF to get persistence, at the cost that the legacy file system can wait on the browser’s main thread during background content loads. Files you write anywhere outside those two mounts live in memory and are gone on reload.

3. There is no video playback. FFmpeg is never built for Emscripten. The Video and VideoPlayer types are present in every build, so calling code compiles and links, but opening a Video or calling VideoPlayer::Play throws System::NotSupportedException at run time. Guard video features with #ifdef CNA_VIDEO_AVAILABLE. The same is true on Windows, Android and iOS.

Installing Emscripten SDK

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install 6.0.3
./emsdk activate 6.0.3
source ./emsdk_env.sh
# Verify:
emcc --version

CNA’s CI pins emsdk 6.0.3 on purpose, because an unannounced emsdk bump can break a WebAssembly build in ways that look like a source regression; no other version is exercised. Replace 6.0.3 with latest at your own risk.

CMake toolchain file

# Configure CNA (and your game) for WebAssembly. emcmake supplies the Emscripten toolchain file.
emcmake cmake -S . -B build-wasm \
  -DCNA_GRAPHICS_RENDERER=WEBGL2 \
  -DCMAKE_BUILD_TYPE=Release

cmake --build build-wasm
# Output for a game target named MyGame: build-wasm/MyGame.html, MyGame.js, MyGame.wasm

# Equivalent without emcmake:
#   cmake -S . -B build-wasm \
#     -DCMAKE_TOOLCHAIN_FILE=$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake ...

# CNA ships a "web" CMake preset, but the preset itself names no toolchain file --
# run it through emcmake. It builds cna_house3d_demo into cmake-build-web:
emcmake cmake --preset web
cmake --build --preset web

Do not pass --target CNA: CNA is an INTERFACE library with no sources and is not a buildable target. Draco (glTF mesh compression) defaults to OFF under Emscripten, and the WebGL renderers need the easy-gl and meta-gl siblings like every other GL profile.

An executable outside CNA’s own source tree adds the Asyncify link option itself:

add_executable(MyGame main.cpp)
target_link_libraries(MyGame PRIVATE CNA CNA::EmscriptenAsyncify)   # -sASYNCIFY=1
set_target_properties(MyGame PROPERTIES SUFFIX ".html")
cna_apply_emscripten_renderer_link_contract(MyGame)   # -sMIN/MAX_WEBGL_VERSION for WEBGL2

CNA::EmscriptenAbi is the compatibility composition that bundles the JavaScript-lowered exception ABI with Asyncify; CNA’s exception ABI (-fexceptions) already reaches you through CNA. CNA’s CI links CNA’s own example programs rather than an external consumer, so if your link fails, compare with how cna_demo_2d is linked in modules/graphics/examples/CMakeLists.txt.

SDL3 Emscripten target

CNA builds SDL3 itself from its vendored submodule, as a static library under Emscripten, and caches it in .sdl-prebuilt-emscripten. You do not pass -s USE_SDL=3 or any other -sUSE_SDL* port flag.

The WEBGL2 renderer on WebGL 2

The WEBGL2 renderer maps OpenGL ES 3.0 calls directly to WebGL 2, which is supported in all modern browsers (Chrome, Firefox, Safari, Edge). CNA links WebGL 2 builds with -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 through cna_apply_emscripten_renderer_link_contract; there is no -s FULL_ES3=1 flag in CNA’s build. Unsupported features in WebGL 2: compute shaders (not available in WebGL 2), and glBlitFramebuffer with multisampling.

The other browser renderer

WEBGL2 is the one renderer identity gated to Emscripten; it refuses to configure for a native target, and it is one profile of the shared EasyGL implementation. WEBGPU is not gated to the browser, but it also has an experimental browser route (Tutorial 132). CNA’s Emscripten multi-renderer workflow builds WEBGL2 and WEBGPU into one bundle and checks that it links; it does not run the bundle in a browser. Tutorial 105 compares the two.

The browser main loop

Browsers require cooperative multitasking — you cannot block the main thread in an infinite loop. CNA keeps XNA’s blocking Game::Run() contract anyway: on Emscripten, RunLoop() runs one frame body, then awaits requestAnimationFrame() (through EM_ASYNC_JS) to suspend the very same WebAssembly stack until the browser is ready for the next frame. That is why the executable needs Asyncify. The entry point is therefore the same as on the desktop:

int main() {
    MyGame game;      // an ordinary local object is fine on the web in this snapshot
    game.Run();       // returns after Exit(); suspends between browser frames via Asyncify
    return 0;
}

The frame body also handles the browser’s timing for you: a frame that arrives after a long pause (a background tab) contributes at most 250 ms of elapsed time before the fixed-step accumulator runs. The alpha.1-era pattern of a hand-written emscripten_set_main_loop wrapper around a heap-allocated Game is obsolete, and Game’s Initialize() and LoadContent() are protected, so that wrapper does not compile against the current headers. Game::RunOneFrame() remains public for the rare case where JavaScript owns the loop, as the experimental C API’s WebAssembly build does.

Shell HTML template

Emscripten generates a default shell HTML page. Customize it by providing your own shell file:

# Link with a custom shell page
target_link_options(MyGame PRIVATE "SHELL:--shell-file ${CMAKE_SOURCE_DIR}/my_shell.html")

The shell HTML must contain {{{ SCRIPT }}} where Emscripten injects the loader JavaScript. Start from a copy of Emscripten’s default shell (src/shell.html in the emsdk tree): it already contains the HTML drawing element SDL3 renders into, under the id SDL3 looks for by default, so keep that element and its id.

Asset loading: preloading and Asyncify

Loading files in the browser means either preloading assets into the Emscripten virtual filesystem or fetching them while the program is suspended. Preloading is simpler and works well for games with a bounded asset set; CNA’s own demos preload their Content directory this way:

# Preload an assets directory into the WebAssembly virtual filesystem
target_link_options(MyGame PRIVATE
  -sALLOW_MEMORY_GROWTH=1
  -sFORCE_FILESYSTEM=1
  "SHELL:--preload-file ${CMAKE_SOURCE_DIR}/assets@/assets")

Asyncify is already part of the picture because Game::Run() needs it (linking CNA::EmscriptenAsyncify, above); it is what lets C++ code suspend while the browser does asynchronous work. Keep long blocking waits out of the per-frame path.

Deployed examples

Two CNA demos are live on the web (prebuilt and hosted at demos.libcna.com; the CNA revision behind a hosted build is not necessarily this snapshot):

  • CNA House 3D Demo — a real-time 3D house built with CNA's 3D rendering pipeline, running in the browser. Demonstrates procedural 3D geometry, BasicEffect and WASD movement with gravity; the same program is the cna_house3d_demo target that the web preset builds.
  • CNA Demo — the primary CNA feature showcase including 2D sprites, audio, and input (the cna_demo_2d target).

See the Demos page to run them directly in your browser.