The challenges of porting games to WebAssembly
As an itch.io user, you dislike downloading games, you prefer running them in your browser. It's more convenient, faster and there are no anti-virus issues. So I, as an game engine writer have to make sure that I'm writing something cross-platform that supports the web.
At first, when writing vectarine, only Windows and Linux were supported. The engine is written in Nim (a language that compiles to C) with some C and C++ dependencies, so it's trivial to port it to any platform that has a C compiler with a few tweaks.
When I wanted to implement a web version, I turned to Emscripten, which is a C compiler for WebAssembly, a set of instructions that can be executed on the browser.
API Differences
The first issue I ran into were windows. There are no concept of windows on the web. To bridge the gap between JavaScript and Nim, I used some emscripten functions to fetch the size of the browser window so that the code written was portable and had the correct aspect ratio. I also ran into issues measuring elapsed time. This is required as I need to be able to know how many time a frame takes to render to have frame rate independent physics. The function provided by the browser did not have the same units and as the ones available on desktop, but after a few multiplication and using Performance.now(), I got something working.
when defined(emscripten): proc emscripten_get_now(): float64 {.importc: "emscripten_get_now".} else: import std/monotimes # Provided by the Nim standard library. proc getTime*(): float = ## Return a number that increases by 1 every millisecond. ## This uses the highest possible precision of the platform available (even nanosecond if possible) ## Designed for benchmarking / keeping the fps stable. when defined(emscripten): return emscripten_get_now() # in ms. else: return getMonoTime().ticks / (1000 * 1000) # from nano to milli
Threading
The next issues were threads. Because of CPU vulnerabilities like Spectre and Meltdown, the usage of threads (named web workers in the web world) is heavily restricted. You need to pass a few compilation options to the compiler like
-pthread -sSHARED_MEMORY
and more to the linker:
-sPTHREAD_POOL_SIZE=4 -sPTHREAD_POOL_SIZE_STRICT=0
Also, you need to serve your content using some special HTTP headers (Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy) so that your program cannot use Spectre/Meltdown to read the content of other website using iframe trickery.
OpenGL ES
On the browser, the version of OpenGL is not the same as the one on the desktop. The shaders don't have the same syntax. So, when loading the shaders, I parse their content and rewrite them to make them compatible with the web.
I add the correct #version directive depending on the platform. Moreover, uniforms cannot be set inside the shader on OpenGL ES, so I parse their initial value and manually set them with glUniform. Because my shaders are pretty simple and don't use geometry shaders, everything else worked.
Geometry shaders are known to be slow anyway, so not using them is a great idea in general.
Scripting with Lua
After that, I found that Lua did not work in the browser. This is because I used the Lua JIT library which generates x86 specific bytecode which does not work on the web and its WASM bytecode. So I added a switch to use regular Lua in the web version. I had to fix some issues regarding a different in behavior between Lua and Lua JIT related to stack size (lua_checkstack) to make to work.
All in all, Lua and Lua JIT have identical similar behavior and API with only this one difference. The Lua code is the same on all platforms.
Data persistency
Then, I worked on the file system. To save progress, I write to a single file named "save.bin" next to the executable. On the web, I don't have access to the filesystem (without requestion permission first, which is weird to go for a web game). I could store the data in localStorage but I would need to write a sort of filesystem. Luckily, emscripten already provides some persistent filesystem (but there are not used by default >:-( ). So at startup, I need to switch to the IndexedDB based filesystem and after every write, I need to sync the filesystem with the storage.
Because the filesystem cannot be mounted on root easily, I mounted it on a /persistent folder and added a compile-time switch to write to this folder when using the web platform.
Audio
emscripten supports the openal library by default so nothing had to be done, which is one of the big reasons I choose openal to play sounds in my engine.
Conclusion
All in all, while there were a lot of small issues to make everything work, nothing was too hard and the emscripten documentation well made. Using C is really magical as in the end, it's all just compilers. Once you've written some code in C (or Nim), you can run it anywhere if there is a compiler for it.
Linking against symbols defined in JavaScript and writing JavaScript libraries for C instead of the other way around feels funny at first but works well. You just need to be careful to convert between C pointers and JavaScript objects
addToLibrary({ emscripten_open_url: (url_ptr) => { var url = UTF8ToString(url_ptr); // C pointer to Javascript String conversion. window.open(url, '_blank').focus(); } });
The next step will probably be the Android backend. As I add more backends, the code because more generic so I hope it won't be too hard. Most of the work will probably be in writing all the java boilerplate required to run the C code linked against the Android NDK and to interoperate between the Java and GUI world and the low-level C world.
And thanks to all this work, you can play vectarine games like Domino Demon straight from your browser !
Leave a comment
Log in with itch.io to leave a comment.