TBF #17 – Emscripten

This is part two of a three-part series about how we ported our custom C++ engine to the web for the prototype build of Make A Pair. See the previous entry right here.

WebAssembly

Introduced over 10 years ago, in 2015, WebAssembly (or WASM) is a portable binary code format that can be executed in any browser today. It allows high-performance applications, like games, to run on the web.

If you want to port your C++ game application to WASM, one of the best ways today is simply to use Emscripten: an LLVM/Clang-based compiler that compiles LLVM IR into WebAssembly. This is perfect because if your game happens to be compiled under LLVM/Clang, that means it could be ported to WASM.

This is ideal for a C++ codebase, because it means a frontend compiler can take the result of our code and transmit it to a backend compiler that transforms our code into WASM to be executed on the web. This is, in a nutshell, what Emscripten does.

"Emscripten is a complete compiler toolchain to WebAssembly, using LLVM, with a special focus on speed, size, and the Web platform." – From Wikipedia

Very smart engineers spent years working on Emscripten to allow developers like us to use this compiler and reap the rewards of their hard work. Emscripten is a fantastic tool for any desktop developer. They made sure most C++ applications could be ported over to the web without much trouble. I can't thank enough the people who broke their heads (and backs!) to make that happen.

It comes, however, with some big caveats. The web is a very limiting platform in terms of what you can do compared to a desktop environment. The main reason for these limitations is the security of the user running these applications. A single click could potentially start mining bitcoin on your computer or reading your files, without the user's knowledge. This is why WASM applications (and regular HTML/CSS/JS websites) are sandboxed.

With that in mind, some of the constraints are:

  • No direct access to the filesystem, for obvious reasons.
  • Networking has to be done through JavaScript. You cannot open a socket and start receiving data as you would in a regular desktop application.
  • Multithreading on the web is supported through web workers.
  • Rendering must be done through WebGL or WebGPU, which we will discuss in the next blog post.

Here is how each part impacted us specifically, but could potentially impact you if you decide to port your game as well:

  • As with any game, we load tons of assets and data at startup from the filesystem, and a few during the game through our streaming asset system. We need to find a solution to have access to our data at runtime.
  • We also save data for the save game. We need both write and read access to the filesystem, somehow, if we want to support that feature.
  • We use a custom solution for tracking information anonymously when you play the desktop version. This is not something we can do on the web easily unless we make some kind of call from C++ to JavaScript.
  • We use multithreading heavily during loading time to make load times faster. It's also used heavily for audio through FMOD.
  • Obviously, rendering. We cannot use DX12, Metal, or Vulkan on the web.

With that in mind, let's see how I tackled each specific part. As a reminder, this work took three weeks from start to finish.

CMake

For our game, we use CMake to generate our project for all the platforms we support. It's a fantastic tool in the C++ world for multiplatform configuration and a de-facto standard. Emscripten has a tool in place to support such a workflow: emconfigure!

Emconfigure's job is to set up the project for emcc (the Emscripten compiler) and add a bunch of setups to make it a breeze to compile right after. This was really a smooth process that took less than three minutes to set up and run.

Once that was done, I could finally see some errors popping up while compiling.

Core loop

The first issue that I found was how the main loop works. This is an important distinction to understand, as it changes how you set up your engine on the web.

The browser expects a web application to relinquish control back to it every frame, while a desktop application expects you to loop until the user closes the application. The reason for this is that the web core loop is based on the function requestAnimationFrame(), which is called at the display refresh rate.

This means that you cannot loop forever, and you will have to adapt your code accordingly.

C++
// Desktop: we own the loop and run until the user quits.
int main(int argc, char** argv)
{
    Engine engine;
    engine.init();

    while (engine.is_running())
    {
        engine.run_once();
    }

    engine.shutdown();
    return 0;
}
C++
// Web: the browser owns the loop and calls us back every frame.
void run_once(void* user_data)
{
    Engine* engine = static_cast<Engine*>(user_data);
    engine->run_once();
}

int main(int argc, char** argv)
{
    static Engine engine;
    engine.init();

    emscripten_set_main_loop_arg(run_once, &engine, 0, true);
    return 0;
}
On the web, the engine data is passed through the callback's user pointer.

This caught me off guard, as I didn't expect such a drastic change from the get-go.

Thankfully, SDL3 thought about it, and they have a good page about what to consider if you are porting to the web. What's convenient is that they give you four callbacks that you need to implement, and you're done! SDL3 then makes the promise that it respects the constraints of the web on that front.

I had some key places to change in our engine to get going. Since I control every part of the application, it was rather easy to set up. I moved the core of my logic into a function called run_once, which is called at every requestAnimationFrame(). The main crux was keeping the engine data in some place. SDL gives you a space for that in their function signature for callbacks.

C++
void EngineCore::run_engine()
{
    engine_is_running = true;

    // Low priority task (I/O, Logs, Network)
    ThreadManager::add_task(hbp_new Task([=](){
        while(is_running())
        {
            LogManager::get()->update();

            OnlineService::get()->update();

            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
        return 0;
    }));

    // Run game loop forever (system update, event, inputs, rendering, physics, etc.)
    time_manager->run();

    // Threads must be stopped
    engine_is_shutting_down = true;
}
C++
void EngineCore::run_engine_once()
{
    // Make a single update of the game
    time_manager->run_once();

    // Run the logs
    LogManager::get()->update();

    // We do not run any update on online services. Disabled for the web
}
Filesystem and I/O

As discussed earlier, the OS filesystem is not directly accessible on the web due to sandboxing. While it is possible to request access to a file thanks to the FileSystem API, this is not desirable simply because it requires the user to point to a file.

As you can imagine, Emscripten has developed a solution for this exact situation. By default, every file operation performed from the synchronous API of libc and libcxx is redirected towards MEMFS, which is the default in-memory virtual file system that Emscripten has. It becomes transparent for the app developer to do file operations. This comes with a caveat though: there is no persistent data. Every write you do will be lost if the user closes the session. Every read you do can only be done on the data that has been mounted into the virtual file system. If you are looking to get persistent data, there are other systems available, but that requires more work, which I did not do for our game. And even then, most of them are based on IndexedDB, which is persistent until it isn't.

I was able to embed all our data files into a single .data file, which is loaded and mounted in memory once the application starts. During the first call from SDL3, I know that all my data is already loaded in memory.

Emcc offers many ways to map your data file as you wish, through an extensive list of choices available on their website. It comes with many options for remapping paths, exclusion, and renaming that can all be set up during compilation. You can see our setup right here.

CMake
target_link_options(${PROJECT_NAME} PRIVATE
        -sASYNCIFY
        -sALLOW_MEMORY_GROWTH=1
        -sALLOW_BLOCKING_ON_MAIN_THREAD=1
        -sEXPORTED_RUNTIME_METHODS=['cwrap','setValue','getValue','FS']
        "SHELL:--exclude-file ${CMAKE_SOURCE_DIR}/data/shaders/*"
        "SHELL:--exclude-file ${CMAKE_SOURCE_DIR}/data/testlands/* "
        "SHELL:--exclude-file ${CMAKE_SOURCE_DIR}/data/logistic/*"
        "SHELL:--exclude-file ${CMAKE_SOURCE_DIR}/data/ksw/*"
        "SHELL:--preload-file ${CMAKE_SOURCE_DIR}/data@/data"
        "SHELL:--preload-file ${CMAKE_SOURCE_DIR}/data/shaders_wgsl@/data/shaders"
    )
The SHELL: prefix tells CMake to pass the string to the linker exactly as-is, without any re-ordering or collapsing, which would break everything.

With that out of the way, the game was almost able to boot up and run without crashes. Note that at that point, I only wanted a fixed black screen with nothing else and zero errors in the console log.

ICU

ICU is International Components for Unicode, a library used everywhere for any kind of text manipulation. It is used in your phone, your game console, your OS… it's everywhere. If you ever plan to support localization, you must use ICU for manipulating text.

Let me give you an example to better understand what this library can do. Imagine that you would like to wrap a text element. The rules are different for every language. A word in French follows different wrap-boundary rules than one in English or Chinese. You cannot just assume these rules or think they stay true within a single language. ICU takes care of that and many, many more things.

While starting the game, ICU would crash at startup. The error was clear: it was missing its data segment to be able to initialize itself correctly. Let me explain how emcc works in detail when it compiles your code and what the issue was here.

Emscripten needs to compile, from source, every element that composes your application, and can only link with elements that are already compiled as a WASM target. This means that any library you use that is closed source, without a WASM target, cannot be compiled. It also means that you have to provide most library code to Emscripten, under git submodules or a vendor folder.

Thankfully, Emscripten realized quite quickly that it was wasteful to ask every developer to have such a setup when most of them use the same kind of libraries. They developed a port system. The most popular libraries are present in the downloaded archive of Emscripten, and you can link directly against them through a simple command.

I was using the ICU port for our game, instead of the library available in our vendor folder, to prevent an expensive compilation step. The issue was that this port script file does not come with the ICU data file. The main reason for this is that the data file adds a significant amount of data (over ~30 MB) to the final executable. They left it up to the developer to add it if they do need it.

Once that issue got identified, I could preload the data file and set up an environment variable to point ICU to where it was.

CMake
elseif(EMSCRIPTEN)
  target_compile_options(${PROJECT_NAME} PRIVATE --use-port=icu)
  target_link_options(${PROJECT_NAME} PRIVATE --use-port=icu)

  # We have to include ourselves the ICU data file because --use-port does not do it by default.
  set(ICU_DATA_FILE "$ENV{EMSDK}/upstream/emscripten/cache/ports/icu/icu/source/data/in/icudt68l.dat")

  target_link_options(${PROJECT_NAME} PRIVATE
    "SHELL:--preload-file ${ICU_DATA_FILE}@/usr/local/share/icu/68.1/icudt68l.dat"
    )
endif()
C++
SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv)
{
    // We have to set this variable very early otherwise, it will fail when loading ICU content.
    setenv("ICU_DATA", "/usr/local/share/icu/68.1/", 0);

    // ... more code to initialize the game
}

At that stage, we had no more I/O issues. The game was able to boot.

Network

Networking is complex with Emscripten. The recommendation is simple: make your calls through JavaScript. You can embed JavaScript calls into C++ code quite easily thanks to emcc. The other option is to use WebSocket, which might require setting up your server backend, depending on what you use.

I opted for simplicity. Networking was not necessary, and since I was under huge time pressure, I decided to disable all our networking features on the web.

For networking, we didn't need to use it. We accepted that we would get no tracking information about what happens in-game, but it was good enough for the prototype build. If our game was using networking in some capacity that mattered, I would have done the effort of porting it. Since we only use it for data tracking (level completed, card picked, etc.) and for crash reporting, we accepted that it was not needed.

C++
#ifdef PLATFORM_WEB
    // On the web, use an empty stud
    #define HTTP_CLIENT_MOCKUP_IMPLEMENTATION
#else
    #define HTTP_CLIENT_IMPLEMENTATION
#endif // PLATFORM_WEB

#ifdef HTTP_CLIENT_IMPLEMENTATION
#include <curl/curl.h>
#endif // HTTP_CLIENT_IMPLEMENTATION
A simple empty mock of the http client works perfectly to prevent any calls from being made while we can keep the rest of the code as-is. It is transparent to other systems this way.
Threading

At that point in time, four days into the porting of the application, there was one single issue left: multithreading. As I said earlier, I use it extensively when loading the game and during gameplay to stream assets in and out depending on what's going on.

However, multithreading is more complex on the web. Emscripten has a specific page about it, and I also highly recommend reading about web workers. From now on, I will interchangeably use the words thread and web worker to mean the same thing in the context of the web.

How it works is that you define a thread pool in advance, and then once you do any threading operation, the browser will pick one of the available threads and use it. Of course, for security reasons, there are a couple of limitations.

When working on a desktop application, you can set up the data and its access between threads as you wish. On the web, this is represented as a SharedArrayBuffer that all web workers have access to. This means that you are limited in performance and in the data sizes you can have. You also need to be more careful about locks, and how you pass shared data across threads.

If you want to use this functionality, which is not supported everywhere, you have to compile your application and its dependencies with the flag -pthread. If you use a linked library that is closed source, it needs to also be compiled to WASM with that flag enabled.

There were two reasons I wanted to have multithreading support:

  • Accelerate I/O time.
  • Have a good audio experience with FMOD.

Audio is very sensitive to latency. That's why it wants to live in its own bubble. Any perturbation in performance due to latency or I/O will result in a degradation in the audio quality.

After a couple of hours, I was able to set up the flag and run the code. Everything was working, except I found a massive issue that was a deal breaker.

CMake
    # Enable multithreading on the project
    target_compile_options(${PROJECT_NAME} PRIVATE
        -pthread
    )

    # You can make it global as well.
    add_compile_options(-pthread)
    add_link_options(-pthread)

    # To force debug info
    target_compile_options(${PROJECT_NAME} PRIVATE -g)
    target_link_options(${PROJECT_NAME} PRIVATE -g)

    target_link_options(${PROJECT_NAME} PRIVATE
        -pthread
        -sPTHREAD_POOL_SIZE=8
    )
This has been simplified for readability.

During the initial loading time, multithreading was also used to load and create GPU resources at the same time that assets are loaded. As we will discuss in part three of this series, in WebGL or WebGPU, this is not something that is allowed. All GPU resources must be created on the main thread, and all graphics API calls must be done from the main thread.

This was a massive issue. Either I would do the work to reroute most calls back to the main thread to create the resources on the GPU, or I would find a way to make it work single-threaded while keeping the structure of the code intact.

In the end, I simply decided not to bother. I did not have enough time to architect a solution that would work for both the desktop application and the web application without introducing some heavy code changes. Instead, I disabled all threading and made the game single-threaded.

C++
void ThreadManager::add_task(Task* task)
{
    ThreadManager* thread_manager = global_engine->thread_manager;
    {
        HBP_AUTOLOCK(thread_manager->task_mutex);
        thread_manager->tasks.push(task);
    }

    thread_manager->condition_variable.notify_one();
}
C++
void ThreadManager::add_task(Task* task)
{
    // On the web, we simply do the task right as we get it. This is fine as most operations are
    // either at load time and in the worst case, we are doing some I/O operation from the MEMFS
    // which lives in RAM.

    // If the game was built differently in terms of I/O processing and/or expensive computation
    // on background threads, I would have a picked a different solution

    task->work();
    hbp_delete(task);
}

As of this day, you'll notice that the sound behaves strangely if you switch tabs while playing. This is mostly because FMOD is not using threading, so it replays the last audio buffer it has until you can come back to process more data. If multithreading was enabled, this would have been a non-issue.

Conclusion

At that point in time, one week into the port, we had a functioning game. I was able to move through the menu, the console showed zero issues, and I could hear the cards moving and the menu responding.

That said, I had one final big issue. I was doing all of that through a black screen. It was time to jump into the final beast: rendering through WebGPU.

This is what we will explore next week.

RSS Feed
Previous Post Blog List