So You Want To Compile Your C# Game Engine To The Web With WASM
Diving into the NativeAOT-LLVM compile chain to make my game engine work on the web
December 24, 2024
Tagged: Gamedev wasm csharp zinc
When I set out to make Zinc I had a few goals in mind. A large portion of those goals were around engine ergonomics and “making happy developers,” but one very specific technical goal was “make it work on the web”.
I didn’t know how to do this. I didn’t even really know how to approach the problem at the time, or even really understand how web builds would (or even “do” for engines that support it) work.
I understand how most JavaScript game frameworks work — they wrap WebGL and draw to a canvas element. But for something that was authored in a non-JS context (C/C# in my case), the avenue to “working on the web” seemed muddy at best. But as the saying goes, anything worth doing is going to be difficult.
Why Web?
Before I really get into it, I wanted to take a small moment to address “why”. Why should a primarily desktop-targeted game engine work on the web? Especially when there are lots of good frameworks for making games on the web?
For me, The answer is twofold:
- The barrier to entry for playing a game on the web is incredibly low, meaning the potential audience for your own games is incredibly high (albeit with lots of competition!)
- I want to provide game developers a way to create games for the web using C#
For 1, this was put into start relief when I participated in 7DRL last year and saw how games that required downloads were simply played and evaluated far less than those that worked on the web. This inspired a moment of introspection in myself when I realized how I, AS SOMEONE WHO LIKES PLAYING NEW AND WEIRD GAMES, would much more likely give something a go if it worked on the web instead of requiring a download.
Which is to say that if I want people to use Zinc, those people will likely be motivated by people playing their games, and the best place for that to happen is on the web. Not only this, but there are a few different forces converging on the web that makes it a viable target for “modern” games.
And for 2 — what better way to go about that than using, IMO, the best language in the world, C#. Giving developers the power and flexibility C# to write games, and have those games work on web seems like a perfect match. It’s also selfishly something I want! I want to make games rapidly and put them on the web for people to test, and the fact of the matter is that even if I have the best C# game code in the world, if someone has to download something to play it’s less likely they’ll every even try it out.
So let’s make it happen.
Waiting Made It Possible
I started Zinc nominally around 2022 and since then a few things have happened to make this goal come into focus. First, I swapped my backend early on from Kinc to Sokol. Sokol is a platform and graphics abstraction framework (written in C) that works for common desktop targets (Windows/Mac/Linux) but also considers the web a primary target as well and has robust support for webgpu/webgl through the use of Emscripten to compile to wasm. This meant that, at my core platform interop level, I knew what I wanted was theoretically possible.
But what about the scripting layer? I’m using C#, which obviously works well for desktop targets, but getting C# working on the web is non-trivial, and also far less clear. C# doesn’t work on the web by default — you can’t just write C# code for the web like you can for JS and have it work. It’s effectively a separate toolchain. I knew you could theoretically compile C# code to wasm for use on the web, but how?
Since I started Zinc though, there has been effort at Microsoft to make C# work on the web via wasm. The ecosystem is a bit muddy, but this is the best I can understand for it:
- The first primary way C# was supported on the web as the Blazor-wasm project, which allowed you to to compile a Blazor app to client side wasm.
- Since .NET 7, Microsoft added in the ability to compile arbitrary C# code to wasm and invoke it from JS. This is a nice substitute for similar capability in Rust/C++ with Emscripten.
- And more recently, Microsoft also started a wasi-wasm target which is meant as a way to compile C# for WASI for use of web assembly inside non-browser wasm “containers”/runtimes (like wasmtime).
Parallel to these efforts, Microsoft has started and continued to invest in the AOT compilation for C#. With .NET 9 they added support for a ton of platforms (see bottom table here) but notably not web.
For a game engine, what we really want is a web-targetable wasm compile chain that has the performance characteristics of NativeAOT. Waiting to tackle this problem has been a boon for Zinc, because another effort has cropped up that is a bit of a combination of the above efforts and exactly what we want — the .NET team has been working on an experiment that uses LLVM to compile the output from the C# JIT(RyuJIT) to AOT’d code in the NativeAOT-LLVM project.
So if C# can be compiled to wasm with NativeAOT, presumably this should all work maybe probably right? Well after lots of learning about a lot of the above and lots of stops and starts — yes!
I also wanted to take this moment to thank a lot of people on the C# and DotNetEvolution Discord for helping me through all this. Katelyn Gadd, Marek Fisera, Yowl, and SingleAccretion. Thanks for answering all my questions!
Just To Be Clear, I Know Nothing
I want it be clear that at this point in writing this, after I’ve done all the work I’m about to talk about, I still have a very basic understanding of wasm and the toolchains I’m going to talk about. I like to tell people that I usually work right at the fringes of my own ability, and this was 100% one of those cases. I figured out enough to get the most basic version working, but by no means fully understand the limits and affordances of the full toolchain.
In lieu of trying to spell out The Ecosystem all for you, I wanted to paste in something Marek Fisera sent me on Discord that can hopefully be a reference (circa 2024) for anyone walking the same path can get the same level setting. This was very helpful for piecing apart all the bits that go into all this around tools/workloads/RIDs, etc.
Marek:
I’ll write down some trivia to set a common ground
- There is wasm/WebAssembly as a standard
- Originally it worked in browsers (it is orchestrated by javascript)
- Then there was the idea to run it outside of browsers, which needs some API (because there is no javascript)
- This API is called WASI (system interface)
Now, .NET world of WebAssembly
browser-wasm
(aswin-x64
orlinux-arm64
or etc) defines that you want to target your application for WebAssembly on browser. It means that you can use javascript interact with the wasm code, you need HTML to host it, etcwasi-wasm
defines that you want to target standalone WebAssembly runner without any web technology.NET teams supported WebAssembly through Mono runtime, because Mono can be very tiny and versatilly, etc. Blazor is running on it.
To be able to compile .NET app to WebAssembly (on Mono runtime), you need
wasm-tools
workload.By default it runs in interpreted mode, where you have much smaller download (because IL is smaller than native code), but the actual performance is slower. Mono supports also AOT, which is a mixed mode between AOT and interpreter, based on the features the actual code is using. The benefit is that you can run almost any .NET code, the drawback is that it can be slower when it’s interpreting the code.
The same story applies for wasi on Mono. But the underlaying tooling is different, and so it requires a different workload (browser apps are based on emscripten toolchain, whereas wasi apps are based on WASI SDK).
Side note: WASI on Mono is not fully supported yet, so that’s why the name of the workload is
wasi-EXPERIMENTAL
.Second part: NativeAOT. There is a community driven project NativeAOT-LLVM which uses NativeAOT for compile .NET apps to WebAssembly. It’s full AOT, no .NET runtime on runtime, etc. NativeAOT in general is quite limited in what .NET/C# features you can use, because it needs to be able to compile it to native code before the execution.
NativeAOT-LLVM was focusing more on the actual WebAssembly part and was missing some of the features that you “need” when running an actual interactive web app. We are filling these gaps recently.
NativeAOT-LLVM can target both browsers and standalone WebAssembly runners using the same nuget packages and just changing the RID.
WebAssembly on Mono (aka wasm-tools workload) is fully supported and more mature thing.
Seeing this laid out was incredibly helpful for me, especially as it feels like the wasm space in general is a nonstop moving train that can be hard to get up to speed on. My only contribution here would be to recommend reading this nice intro into understanding what wasm is if you’re just getting started.
Lets Get Started
Before jumping in, let’s talk how Zinc “normally” works. The C# side of Zinc is compiled with normal .NET-y things with normal targets for desktop. All the platform stuff and lower level libraries are compiled to DLLs (or dylib, etc.) and those DLLs are called in C# through PInvoke calls.
Now notably, wasm and DLLs don’t really mix as concepts. As far as I know, you can’t have a wasm-compiled module call out to a DLL. I’m out of my depth here a bit on exactly why, but basically if you want to do stuff with wasm, you either need to do it all inside a wasm file, or call out to another wasm file.
Because we just want one lil wasm blob, I’m more interested in the former, which is better stated as “link in everything as part of the final compile step”. The later is interesting for a few reasons (like “safe” code loading for mods!), but is not a route I really wanted to pursue for this project, as I wanted the performance profile of everything statically compiled together and didn’t need dynamic assembly loading at runtime.
So knowing that we need everything linked together, we know that we need to statically compile (as opposed to dynamically) our dependent C libraries such that they can be linked in the final compilation of the engine web output itself. Because we’re going to the web, we need to use Emscripten to compile the code for wasm (as opposed to using something like Clang/GCC to target WASM).
Static Compilation of C Code With Emscripten
Sokol luckily makes this easy! I’ve been using a variation of the the Sokol maintainer’s build scripts for Zig here that do all the Emscripten setup and such for you, however with a major caveat!
Because our ultimate goal is to use NativeAOT-LLVM to compile everything together, we need to use the same version of Emscripten that NativeAOT-LLVM uses to statically compile our C code. In practice (for me), this meant changing thebuild.zig.zon
file to point to the right commit hash for Emscripten that matched the version indicated here. As a side note, the hash required for Zig for the commit in the zon file can be grabbed by just running the build script with a bad (or no) hash, and the CLI will give you the right one! For reference, here’s my zig build script for Sokol.
If you’re trying to apply this directly to your own pipeline, how you do this step may vary. I’m using Zig, so can leverage the build infra for everything to make this work cross platform, but if you’re doing something different you’ll need to setup Emscripten and invoke it in your own way.
So assuming you setup and invoked Emscripten correctly, you should get some static library output, ready to be linked into your C# code!
Setting Up NativeAOT-LLVM
Setting up NativeAOT-LLVM is relatively easy, it’s just a handful of seemingly unrelated steps that you need to do. Luckily, you don’t need to figure these out yourself as they are largely documented here! It’s relatively straight forward editing of your project file and adding in some nuget packages.
The biggest wrinkle here is comes with the linking in of the previously compiled static libraries. There’s a whole docs page on this, but the main point is that you need to declare your libraries like so in your csproj
file:
<ItemGroup>
<!-- Generate direct PInvokes for Dependency -->
<DirectPInvoke Include="Dependency" />
<!-- Specify library to link against -->
<NativeLibrary Include="Dependency.lib" Condition="$(RuntimeIdentifier.StartsWith('win'))" />
<NativeLibrary Include="Dependency.a" Condition="!$(RuntimeIdentifier.StartsWith('win'))" />
</ItemGroup>
As an aside, because NativeAOT uses Emscripten, it’s possible to use it to actually compile in C files directly and have them link. I don’t do this for Zinc in part because I dont want to expose the platform code to people using the library. Otherwise I’d need to ship raw Sokol, etc. to people, instead of just shipping libs.
So we link the libraries in statically via NativeLibrary
, then run:
dotnet publish -r browser-wasm -c Release
And it works right?
Debugging My Bugs
Obviously not! We’ve got some bugs to work through!
Given that we’re running through two (three?) different compile and linking chains it’s obviously very possible stuff can not only go wrong, but go wrong in vastly different ways based on a host of different factors. This is to say that I’ll be talking about my bugs and would expect you to not necessarily run into the exact same ones, but more to suggest the flavor of bugs you can run into (as well as possible fixes).
Undefined Symbol error
My first issue was this:
libsokol.a(C:\Users\kylek\Workspace\zinc\repos\Zinc.Bootstrapper\libs/sokol/build\zig-cache\o\b7c1cf7d5a645a1b16fdddac32b0b48b\imgui.o): undefined symbol: __stack_chk_guard
This undefined symbol: __stack_chk_guard
error turned out to be a big of a bugbear with an easy (hacky) fix but helped me to better understand the toolchain.
It’s important to say — Emscripten is a compiler. This means it is a bit open season on your code in terms of what it may choose to want/need/see in your code. Looking around the internet, this idea of a “stack check guard” was something GCC mentions here as a preventative measure to avoid stack overflows. Best I could determine is that some part of the toolchain was enabling stack protection, and as such was expecting the compiled code to define the symbol for the stack check.
So solving this weird sounding error meant (for now), literally just declaring the symbol in the compiled .c
code:
int __stack_chk_guard = 42;
It’s worth noting here that this is also very likely a byproduct of me using Zig to compile the C code, as Zig uses LLVM and Clang. So the Zig-compiled C was potentially injecting this want for a check as part of the clang compile, but only until I did the final linking of everything did the error manifest. And actually in typing this now I just checked the Zig Discord for “__stack_chk_guard” and found some answers:
I can see that reflected in the Zig source here but haven’t dug into clang itself to see how it feels about Zig’s approach to the check. For now, I fixed it with the hack and MOVED ON. Adding in that line above to the .c
file got the code to successfully compile! Hooray!
The code is all output to the publish directory of the project, and you can use Emscripten’s emrun
command to launch the application in a browser (you did follow the NativeAOT-LLVM instructions and put EMSDK in your path right?):
emrun C:\Users\kylek\Workspace\zinc\repos\Zinc.Demos\Zinc.Demos\bin\Debug\net9.0\browser-wasm\publish\ZincDemos.html
So we run this, and we see the Emscripten “chrome” on a webpage, and our little app…. black with nothing in it. WELP. We got more bugs! But also it works more than last time. Let’s fix it.
GL Version Woes
A black screen is rough, but luckily I was getting pretty nice callstacks so I could pinpoint the error from Sokol’s js
code here:
This is a parameter fetch inside Sokol’s GL code, where it looks up a parameter in the established GL context by int id. 33309
corresponds to GL_NUM_EXTENSIONS. This is something that presumably shouldn’t fail with a GL 2 context, as it’s literally a constant as part of the context. A lightbulb moment I had was to double check the actual GL context created, especially as the Sokol maintainer said that Sokol shouldn’t be creating GL 1 contexts any more for any backend.
So I found the constant for the version (7938
) and fetched it from the context and lo and behold:
My build was creating a GL 1 context, but Sokol thought it was GL 2, hence failing on fetching constants that weren’t in GL 1. Fair enough, but what should I do?
This is a great example of exactly the type of bug I knew I would encounter when trying to make this all work: one that has a very simple answer that is near-impossible to find because it has nothing to do with “knowledge” and more about “trivia”. It’s the kind of bug that makes me want to give up on all of it because it’s so hard to even understand the problem you’re actually having, and the answer is never the result of “learning about the problem” but instead just finding the one thing you have to do.
I’ll save you my meandering journey to try and figure out what was going on, and just tell you the answer.
Emscripten requires you to pass a linker argument USE_WEBGL2
when you do the final wasm linking, otherwise it will default to GL1. I found it thanks to this issue which also points to the changelog in Sokol that mentions (but is admittedly very buried!):
- (breaking change) on Emscripten use the linker option
-s USE_WEBGL2=1
Passing linker arguments in the .csproj
file is pretty simple:
<ItemGroup>
<LinkerArg Include="-s USE_WEBGL2=1" />
</ItemGroup>
In trying to figure this out, Katelyn Gadd on DotNetEvolution Discord as well as on BlueSky, mentioned that there are a lot of flags that, to be effective, need to be passed both at compile time and at link time when compiling the static libs with the managed C# code. This one seemed like it only needed to be passed at link time, but I suspect the __stack-chk-guard
flag is one that should be passed to Emscripten at both moments.
A Small Interruption To Discuss Similar Work
Just so it’s clear, I’m not the only one who is attempting/has done a web compile for a C# game engine! There’s some prior work here that I used as supplementary material when figuring out my own toolchain.
Primarily I was looking at Noel Berry’s (of Celeste fame) framework Foster. It’s a C# engine with some similar goals to what I’m trying to do with Zinc, and a bit ago someone posted a question to the Foster repo a bit ago asking about web builds. Noel did a dive similar to my own on getting web builds to work for Foster, and his write up here was incredibly useful to me to figure out all of what was going on.
HOWEVER, one thing to point out though is that he didn’t use NativeAOT-LLVM (nor do the samples they reference). Which I bring up specifically to point out that, though it was helpful to understand the shape of the problem, his approach is relatively different than where I landed, primarily because he (as well as the linked ones, like Raylib) use the Uno platform. The differences here in toolchain are relevant, as the .csproj
file he posted assumes the use of Uno and as such has tags that are Uno-specific that notably won’t work/have the same effect on NativeAOT-LLVM. He is also using SDL
, which needs more care around main loop hacking that luckily Sokol handles for me/you and I didn’t have to change any of the control flow C# code to get it to work.
Csproj Wrap-Up
If you look at the reference csproj file in Zinc.Demos you can see what you actually need. The NativeAOT-LLVM compile chain acts more like a “normal” dotnet build/publish pipeline, so things like file copies and such for directories (relevant when needing to mount assets via web-addressable paths) doesn’t require the larger amount of scaffolding Uno requires.
I haven’t yet found a good reference for what’s required vs. redundant in my own project file (so apoligies), but I think a future here is probably to better bake in command line build args to toggle features on and off for compilation. For another time.
Trimming Woes
Once I set the WebGL linker arg… things worked! Sort of! Enough for me to yell out a “hell yeah” into the void of my own office.
Though I was able to see my engine on screen, I quickly ran into another issue that was nice to run into now instead of later: type not found
. I was getting the errors for the Demo scene types I was trying to load to showcase functionality of Zinc. These types were definitely in the project, but were somehow being omitted after compilation which… ah… trimming.
To keep build size low, web builds should be trimmed as much as possible. NativeAOT-LLVM has a whole docs page on this. In practice this is also a good thing to do as well for Desktop release builds, but I had mostly not been making proper builds of the Demos until now so missed it.
So my Demo scene types were (likely) getting trimmed out. Why? The answer here is pretty obvious! In the Demos, I use reflection in C# to grab all the tagged demo types and use Activator.CreateInstance
to create instances of the demo scenes via their type (they aren’t deserialized from binary assets). This is not trimming friendly, because the scenes themselves aren’t directly referenced from code. So when I do a trimmed build from web (or anywhere), my actual scenes aren’t there.
I tried to fix this in two ways. First, instead of building up a dictionary of types to instantiate on the fly, I just hard coded the dictionary itself:
DemoSceneInfo[] demoTypes = new DemoSceneInfo[]
{
new DemoSceneInfo(typeof(Zinc.Sandbox.Demos.Animation),"Animation"),
new DemoSceneInfo(typeof(Zinc.Sandbox.Demos.BunnyMark),"BunnyMark"),
new DemoSceneInfo(typeof(Zinc.Sandbox.Demos.Cards),"Cards"),
//...etc
This worked! But it has the obvious downsides of maintenance and verbosity. Running into this also made me realize the relevance of these reflection/trimming related docs in the NativeAOT-LLVM repo.
rd.xml file
One thing that stood out to me in there was a way to declaratively state types that shouldn’t be stripped from the build via a rd.xml
file. Now if you look into information about this online you’ll find a lot of people yelling about how your shouldn’t use and it may get removed at any point.
But for now, it’s very easy to basically prevent trimming on everything in a given assembly by doing something like this:
<Directives xmlns="http://schemas.microsoft.com/netfx/2013/01/metadata">
<Application>
<Assembly Name="ZincDemos" Dynamic="Required All" />
</Application>
</Directives>
This will preserve all the type information in the Zinc.Demos project, which, for now and for testing, is good enough. The trimming does still apply to the .NET runtime and Zinc’s engine, so we do still get optimization benefits in those assemblies.
A Better Approach To Avoid Trimming?
One thing I could (and probably should) do here is have some defaults for core Zinc types to prevent trimming on them. Things like entities in general (and scenes by extension) should maybe not get trimmed? Maybe? But also some reflection APIs aren’t available anyways at runtime on AOT platforms so maybe it’s better to make people aware of what’s happening and provide guidance instead of trying to be smart.
Handling Component Trimming
So I fixed the trimming of the demo types with the rd.xml
file. But upon running the engine again, I’m getting not found errors for component (as in, ECS component) types that are sourced from the core Zinc library. Hmm. Why would we not see Position
? It seems to clearly be trimming again, but I don’t know the trimming rules inside and out to understand exactly why, but I think it’s this:
Because of how the ECS system (via Arch) in Zinc works (via my ECS source generator), we don’t often new
a component instance and attach it to an underlying entity. Instead, the source generation creates components on entities through typeof()
calls inside of a constructor, and the generated field member accessors only reference the type through refs
and Arch ECS functions where the type is only passed as a generic argument for T
. So my theory is “types that are only ever used as generic arguments or part of a typeof()
call are trimmed”. Maybe?
Regardless, I remembered that Arch actually has a package meant to solve exactly this problem for general AOT use: The AOT Source generator. This package creates an internal component registry that stores information about types in a way that ensures they won’t get trimmed.
Adding this solved the problem! Well, with the caveat that I then needed to go and decorate every component with the [Arch.AOT.SourceGenerator.Component]
attribute.
Ideally, this is something I could/should do automatically, in part because of how I already more or less require components to extend the Zinc interface IComponent
. I don’t like that end users may need to similarly tag their components just to get them working in a publish mode. I did also comment in the Arch Discord that adding a similar function to the source generator to use an interface (so it isn’t just possible via na attribute) would be a nice addition, so that may happen. We’ll see!
Okay so I’ve added in trimming-proof components — does it work?
Yes!
It’s baby steps still from here moving forward, especially with the aforementioned trimming-proof work I need to put in, but having the whole compile + link + deploy chain working feels like a major milestone passed. Recalling the previous stated reasons of “Why?”, I can now easily send people stuff made with Zinc and they can just try it out. No need for an install or anything — just a webpage.
The Future
So deploying the engine (and presumably games made with it) to the web is great… but can we do more now? OH YEAH!!!
The first obvious, easy thing is that I can embed demos for Zinc functionality directly on the docs pages! Nice!
Beyond that, a bigger thing I’ve been interested in is having a scripting layer on top of Zinc that allows for hot reloading. There are some experiments in bootstrapping this with wasm to allow us to script in C#, but also I’m very interested in Luau as something maybe better suited here.
The dream is that you basically just go to a web page, and then can start writing code in Luau or C# and see that reflected in your running scene! Basically, a sort of high-performance 2D game creation sandbox, all hosted online!
I’m obviously not the first one to think of this, nor will I be the last, but I think as a tool it could be a really great little idea sketchpad for people to make stuff with, similar to the Kaplay playground for the JS engine here.
For now though, I’m happy to have gotten all this done, and also taken the time to write nearly 5000 words about it! Hope you found this useful!
Published on December 24, 2024.