This post is my journey of rewriting web-based moto trials simulation game prototype to a native application using Zig and WebGPU.
I don’t use LLMs/agents to produce content (text or code), only to gather information and troubleshoot external dependency/build/tooling issues.
I make games and write posts because I enjoy the process and want to share my progress, not to increase shareholders’ value
Context
For the past 6 months or so I’ve been working on a moto trials simulator. For the early prototype I picked Three.js (WebGL) + JoltPhysics.js for quick iteration and out-of-the box graphics. As a bonus I already had experience making web based games so it was a breeze.
Prototype turned out great, but issues were obvious:
- terrible performance - updating world matrices of 200-ish objects took ~4ms, which is a quater of the whole frame budget. GPU was pegged to 100% with a basic scene (1440p60, 200 meshes, 4 shadow map cascades, bloom pass, FXAA), supposedly very sane for my GTX 1050 mobile. It also took some juggling with browser setup, meaning that the game won’t be playable out of the box in every browser.
- debugger/profiler options - chrome dev tools are fine for CPU profiling and debugging, but good luck troubleshooting performance issues with WebGL.
Stack
Becoming fed up with browser overhead the solution was simple: WASM+WebGPU1 native OS game.
By that time I had a good understanding of what I need from the game stack:
- bare-metal performance
- down-to-machine code debugging and profiling
- excellent C interop and rich ecosystem of gamedev oriented libraries
- full control over dependencies’ source
- fast compilation and great tooling
- cross-compilation to at least Linux and Windows
Language
Considered options:
- C++ - while being the go-to option for gamedev, I have zero desire to read it and negative infinity desire to write it myself
- C - good minimalist choice ticking all boxes, but standard library is terrible, macro system is too forgiving, sentinel strings, and writing strings/arrays from scratch every time does not spark joy to me
- Rust - or as I say C++++, is nice when writing simple single threaded CLI tools, but quickly falls apart once you introduce concurrency, structs with references, C interop. I also played with Bevy before and didn’t like it at all.
- Odin - I’m not a fan of batteries-included standard library, but don’t listen to me as I never tried it.
- Jai - might be the best language of all time, but it doesn’t matter as it’s not public yet
- Zig - ticks all boxes and I had pleasure working with in the past. Having similar semantics to C/C++ I was sure it would be a great fit for game development.
Graphics API
Picking graphics API these days might be even harder than picking a language, but it’s more forced by target OSes.
Linux and Windows native support narrows the list down to OpenGL, Vulkan, and WebGPU. As OpenGL is quite a mess for beyond-tutorial graphics programming and Vulkan is more verbose than enterprise Java (which I’ve been writing professionaly for my whole career), I landed on WebGPU. In fact, native WebGPU support multiple backends making it a nice abstraction over Vulkan. And similar to Zig, I also had great expirience working with browser implementation of WebGPU (even though it barely worked on my standard Firefox+Linux setup).
There are two major WebGPU native implementations, wgpu and dawn. I picked wgpu but we will discuss later what this duopoly means for the ecosystem.
Physics engine
As my prototype was made using Jolt I initially considered using it here too, but legend himself dropped Box3D out of nowhere. Not just that, but its features and performance seemed not worse than Jolt’s. So I decided to give Box3D a try.
Other
For handling windows and input I just picked GLFW via zglfw wrapper and barely had any issues with it.
I rely on GLTF models exported from Blender for everything scene related (rider, bike, scenery, obstacles), cgltf wrapped via zmesh worked like a charm. Images are loaded via stb_image/zstbi.
Tracy is a must have for good instrumentation and sampling, wrapped via ztracy.
zalgebra is a nice little library for math primitives absolutely necessary for game development.
Making GUI from scratch is a fun challenge, but for debug menus imgui is well established. Wrapped via zgui.
I will also need audio API which will likely be FMOD, but I haven’t tackled sfx yet.
Difficulties
Outdated
Because Zig is not 1.0 yet, breaking changes are quite common and quite often. Most Zig libraries are not fast enough to catch up with the latest 0.x release, but luckily they usually have an already open PR to bump the project’s Zig version.
Missing Zig bindings
While Zig has excellent C interop, Zig has its own “idiomatic” way to writing libraries which is quite different from C. Which is why it’s common to have a wrapper libraries to convert between the two.
I managed to find two Box3D wrappers for Zig, just-deleted box3d-zig (classic GitHub) and zigbox3d. While one of them had full box3d header, another had nicer Zig bindings. I shamelessly combined the two into zbox3d library.
There are probably countless of great C libraries that I haven’t even considered for my game just because it has zero exposure in Zig ecosystem. Which is a shame but also an opportunity.
zalgebra OpenGL assumptions
Camera perspective functions in zalgebra assume that NDC depth is in range [-1, 1], while WebGPU and all other modern graphics APIs have it in range [0, 1].
Zig Vector != Zig array
What will the following expression produce?
@sizeOf(@Vector(2, u32))
@alignOf(@Vector(2, u32))
Any definite answer would be wrong, because it depends on the backend and OptimizeMode.
On x86_64-linux-gnu it gives 8 8 in Debug and 4 4 in Release.
I naively used za.Vec2Us in my uniforms struct and was quite surprized to see my shaders work in debug but break in
release mode.
Worst part is that there is no concise fix for it. I had to rewrite all vectors to use arrays instead (no significant performance difference).
It was extremely easy to debug though, big thanks to RenderDoc. All I had to do is to sample two frames into two RenderDoc windows and compare uniform buffers bound to vertex shaders2.
Prebuilt binaries in build.zig.zon
wgpu_native_zig relies on prebuilt binaries of a fixed (1 year outdated)
wgpu-native version.
While I understand the convenience, I would like to have full control over dependency sources and have a way to update it.
Of course build.zig needed an overhaul
but was not that bad.
Compiling it from scratch unfortunately can’t be handled by Zig’s toolchain as wgpu is written in Rust,
but there is nothing addSystemCommand can’t do:
const make_target = switch (optimize) {
.Debug => "lib-native",
else => "lib-native-release",
};
var cmd = b.addSystemCommand(&.{ "make", make_target });
cmd.cwd = b.path("lib/wgpu_native_zig/lib/wgpu-native");
b.getInstallStep().dependOn(&cmd.step);
Would be nice to have this code in wgpu_native_zig’s instead of my game’s build.zig, but I don’t think it’s
possible to add a dependency to Module directly.
wgpu-native update pain
Since wgpu_native_zig was using old prebuilt binaries of wgpu-native I wanted to update it to the latest version
built from source.
Of course it broke Zig bindings because of breaking changes in webgpu.h, without an obvous way around it.
I had to go through all [breaking] commits in webgpu-headers
project and fix them
one by one.
Updating webgpu.h also made it necessary to update Box3D’s WebGPU backend, which turned out to be quite straightforward,
except I somehow managed
to include two different versions of WebGPU headers used by wgpu-native and imgui.
Troubleshooting it was especially painful because ABI mismatch only surfaces at runtime as segfaults or failed
wgpu pipeline/bind group/pass validations.
Not using submodules
Many projects just raw dog external dependencies into their project without any reference to its version or origin. On a bright side, it was a great opportunity to learn how to properly use git submodules to vendor external deps.
zig + imgui + wgpu = big misery
Using imgui alone is not as straightforward thing as I thought.
There are custom options to use docking version, include plot, knob, freetype support.
And on top of that you have window system + graphics API combo (glfw + wgpu in my case).
It wouldn’t be that bad if zgui didn’t assume
I’m using zdawn backend.
I had to rewrite its build.zig to add -DIMGUI_IMPL_WEBGPU_BACKEND_WGPU flag and include webgpu.h together with
wgpu-specific wgpu.h.
glfw + wgpu = minor inconvenience
I said that I barely had any issues with GLFW, but barely is not none.
GLFW does not know by itself how to create surface when used with WebGPU.
I think it’s reasonable, except wgpu_native_zig exposes 6 functions depending on what window system is
without exposing one function to use in all cases.
pub inline fn surfaceDescriptorFromAndroidNativeWindow(descriptor: MergedSurfaceDescriptorFromAndroidWindow) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromMetalLayer(descriptor: MergedSurfaceDescriptorFromMetalLayer) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromWaylandSurface(descriptor: MergedSurfaceDescriptorFromWaylandSurface) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromWindowsHWND(descriptor: MergedSurfaceDescriptorFromWindowsHWND) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromXcbWindow(descriptor: MergedSurfaceDescriptorFromXcbWindow) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromXlibWindow(descriptor: MergedSurfaceDescriptorFromXlibWindow) SurfaceDescriptor;
Fine… I’ll write it myself steal it from
here:
pub fn createSurface(instance: *wgpu.Instance, window: *zglfw.Window) !*wgpu.Surface {
switch (builtin.target.os.tag) {
.macos => return createMetalSurface(instance, window),
.windows => return createWindowsSurface(instance, window),
.linux => return switch (zglfw.getPlatform()) {
.x11 => createX11Surface(instance, window),
.wayland => createWaylandSurface(instance, window),
else => return error.PlatformUnsupported,
},
else => return error.PlatformUnsupported,
}
}
// ...
// skipped for brevity
Box3D world snapshots
While I was very enthusiastic about using Box3D, the time has come to implement physics world save/restore to have the bike reset to the latest checkpoint on failure. To make it work I need:
- Create bodies, shapes, constraints, sensors for the scene
- Save the world snapshot
- Once it’s time for reset, restore existing world to the saved snapshot
I read this line in documentation and immediately became 75% less enthusiastic:
This machinery is currently internal, used only to seed and replay recordings. Box3D does not yet expose a standalone save-state / restore API.
Good news I own my dependency sources and can implement it myself, how hard can it be?
Existing record/replay system in Box3D is focused on providing devs with a debug replay of a simulation. Naively deserializing it into the same world is not possible with current API, you read replay world ID from deserialized replay object. But updating Box3D to accept existing world breaks simulation and existing body/joint/shape references.
Save/restore is not a thing yet because serialized state should be self-contained, meaning it should reproduce the whole world exactly from bytes alone. There is an open issue for it:
There needs to be some optimizations to make this reasonable. The current snapshot embeds all collision meshes. There should be some sort of context to keep the data size more reasonable.
But wait! My use case is different. I don’t create/remove shapes after snapshot save, so it’s not a big deal?
Box3D privately uses this snapshot functions to drive its replay system
int b3SerializeWorld( b3World* world, b3RecBuffer* buf, b3Recording* rec );
bool b3DeserializeIntoShell( const uint8_t* data, int size, b3World* world, b3RecReader* rdr );All I had to do is to make two similar functions that basically do the same thing just don’t include shapes in the snapshot:
int b3SaveSnapshot( b3WorldId worldId, b3RecBuffer* buf );
bool b3RestoreSnapshot( const uint8_t* data, int size, b3WorldId worldId );…and expose them in box3d.h and zbox3d.
Jolt side track
Talking about patching Box3D like it was easy is not really true and I procrastinated for two days not wanting to understand Box3D’s snapshot serialization logic. Another possible approach was to switch back from Box3D to familiar Jolt as I’m sure it has all functionality I need.
Did you know C++ library can’t be used directly as C-compartible ABI? It needs a shim like any other language. Thinking more about it it makes perfect sense since C++ has a bazillion features foreign to ABI.
Jolt allegedly has Zig bindings and 3 (three!) more providing C bindings. Such diversity!
Let’s use zphysics then. Its C bindings seem to be hand-written. Oops, it’s missing basic building blocks like constraints other than fixed.
What about JoltC, it has more stars on GitHub® meaning that it must be objectively higher quality.
Good, it has all constraint types, even PhysicsSettings, but no PhysicsSystem.restoreState…
Same for other two C bindings repos, they lack some of the Jolt’s interface that I use.
Even if they had it I would still need to write Zig bindings for it by hand (great use case for LLMs by the way!) or spend another week writing bindings generator.
Current state of the game
Now that I can breathe out and write my down journey, game is in a good state.
Performance-wise, I get 120fps in the same scene setup where I had a GPU bottleneck in a browser version, but this time with 4xMSAA instead of FXAA. Without AA I get 210fps. Surprisingly CPU-bound this time, spending a hefty amount of time submitting command buffers to the GPU. Might experiment with a render thread architecture and triple buffering once I get bored implementing gameplay.
Haven’t tried Windows3 build yet, but expecting a bunch more problems building dependencies from source.
Things I learned
- Owning dependencies is very powerful. Just going to definition and tweaking foreign code feels good.
- Tracy is very useful.
- RenderDoc is very useful.
- gdb is often better than
printfs, but I can’t wait for Linux support in the RAD Debugger. - C++ library cannot be used directly as C ABI and needs shims like any other language.
wgpuanddawnwhile targeting the same API are not interchangable.- Writing
build.zigfrom scratch is not that hard, but breaking changes twice a year is still annoying. - Making sure size and alignment of structs across CPU/GPU boundary is not always trivial.
- CPUs are stupidly fast if you compile your code natively.
- Big part of engineering is struggling with other people’s decisions
Conclusion
Using Zig for gamedev and anything else is a great choice if you know what you’re doing. Or at least know how to navigate encountered issues.
Native WebGPU has great performance and nice yet flexible API.
I found it quite annoying to have to declare BindGroupLayout for each BindGroup4, but luckily I only need to
do this once when initing graphics.
Validators in wgpu are capable of detecting many misconfigurations early (on first frame).
High quality game physics is finally available for indie devs with the release of Jolt and now Box3D.
While web is an amazing platform for delivering content and providing service, it still lacks performance and tooling needed for joyfull videogame development.
Be useful
You did all this hard work and didn’t contribute to the projects you complained about so much?
- All my work is in public forks.
- This post might guide lost souls confused about certain aspects of working with these technologies.
- Most of the fixes I did are quite specific to my use case and not ready to be immediately useful to others. Yet another power of owning dependencies!
Credit
Maddest props to zig-gamedev project and all its contributors. While this post might be a bit harsh towards their work, without it my journey might’ve never started or took N times more time and M times more energy.
Self promotion
If you would be interested in playing trials game and other hand-crafted games I’ll make in the future, I invite you to the Substep Game’s discord.
Further reading
- Zig Build System
- Git - Submodules
- Introducing Box3D (video)
- It’s Not About The API - Fast, Flexible, and Simple Rendering in Vulkan (video)
while it might be good for performance, tooling is non-existent, and writing/generating JS shims is not fun.↩︎
would be not too hard to troubleshoot this CPU-side with
std.mem.asBytes(&uniforms).↩︎imagine having a Windows machine to play games in 2026.↩︎
now I understand why everyone is prasing “bindless” in Vulkan.↩︎