Some things seem like a good idea, but really aren’t. And even if you know it’s not a good idea, you can’t help but think, well, it’ll be fun at least. Or maybe it’s just me when I had the idea to use WebGPU + Dawn for this project.
Previously: Zig GUI from scratch (part 1)
Zig GUI from scratch (part 1): In Part 1, we started building a cross-platform desktop GUI app from scratch in Zig, targeting WASM, macOS, Linux, and Windows, and using OpenGL/WebGL and SDL2 for native windowing. It's some of the boring stuff like app structure, but also some of the more interesting parts of Zig, like comptime imports. We also planned out and partly implement the platform-specific modules that all use the same implicit interface, split across app, platform, graphics, and js modules. Also (not to list everything here) we worked on the the JS system call layer for wasm, using a modified version of mitchellh's zig-js extended to serialize arbitrary JSON so complex types like vertex arrays can cross the wasm-JS boundary. In the end, we've got a basic "hello canvas color" rendering on both web and native! Also had a little digression on using code gen for OpenGL bindings.
So the idea is that we can swap out OpenGL for WebGPU by using Dawn. Then we’re using a “modern” graphics API. There’s really not a huge benefit to this, but it seemed fun.
WebGPU
Admittedly, I don’t know a ton about GPUs, but I’ve always understood their relationship with CPUs to be a server-client relationship. So WebGPU’s API makes a lot more sense than, say, building my own API on top of OpenGL with a direct C-style ABI. This is what I was working towards a couple weeks ago. There’s nothing particularly wrong with this approach, it’s just that I’m writing OpenGL ES / WebGL shaders that aren’t exactly modern, and have fallen out of favor compared to more modern GLs like Vulkan or Metal. As I started to read more about WebGPU and the Dawn implementation of it, it seems like there are a lot of things that I can take advantage of.
- Writing WGSL shaders that are translated into whatever native shader language that makes sense based on the target platform.
- I already have to serialize calls + data (ie textures) to get it across the WASM boundary, so WebGPU’s pipeline seems well suited for that. I can initialize textures once in JS, cache the pointer, then have an interface where I only have to pass the pointer as an object via JSON or another serialization format over to JS to reference it.
- Fewer platform-specific GL issues.
Statically building and linking Dawn is I’m pretty sure (fingers crossed) I can do.
Building GLFW
TODO: Write this. Or maybe not. Tbh it’s not super interesting, and really just involves making it a git submodule and running some build scripts.
Building Dawn
I’ve goofed around in the Chromium and Skia repos before, but dang, Google’s build system is heavy and foreign to me. That aside, it seems like there are two ways to get Dawn working here: link against a static library when building, and compiling straight from the source when building.
Using a static library is not a bad idea. Using dawn’s documentation and build system I was able to get their hello-triangle, and a full release build working in just a few minutes on my mac.

The harder part is linking a static object file + headers with the program. The trouble is that while the libdawn_native.a file is static, all of it’s dependencies (abseil, tint, Metal, Foundation, QuartzCore, CoreFoundation, CoreGraphics, to name a few) are not in the static object file. They get their own static artifacts. So in my build.zig file I end up having to do a bunch of stuff like this just to get it close to working.
const lib = std.Build.Step.Compile.create(b, .{
.name = "webgpu",
.kind = .lib,
.linkage = .static,
.root_module = .{
.target = target,
.optimize = optimize,
},
});
lib.installHeadersDirectory(b.path("libs/dawn/out/Release/gen/include/"), "dawn", .{});
lib.installHeadersDirectory(b.path("libs/dawn/out/Release/gen/include/"), "webgpu", .{});
lib.installHeadersDirectory(b.path("libs/dawn/include/dawn"), "dawn", .{});
lib.installHeadersDirectory(b.path("libs/dawn/include/webgpu"), "webgpu", .{});
lib.addIncludePath(b.path("libs/dawn/out/Release/gen/include/"));
webgpu_module.addIncludePath(b.path("libs/dawn/out/Release/gen/include/"));
lib.addObjectFile(b.path("libs/dawn/out/Release/src/dawn/native/libdawn_native.a"));
webgpu_module.addObjectFile(b.path("libs/dawn/out/Release/src/dawn/native/libdawn_native.a"));
lib.addIncludePath(b.path("src/webgpu"));
webgpu_module.addIncludePath(b.path("src/webgpu"));
lib.linkLibC();
lib.linkLibCpp();
lib.linkSystemLibrary("c++");
lib.linkFramework("Metal");
lib.linkFramework("Foundation");
lib.linkFramework("QuartzCore");
lib.linkFramework("IOSurface");
lib.linkFramework("CoreFoundation");
lib.linkFramework("CoreGraphics");
// and so on and so forth
So far, so good. My build process for my application is:
- Build dawn using CMake (ie
cd libs/dawn && cmake -S . -B out/Release -DDAWN_FETCH_DEPENDENCIES=ON -DDAWN_ENABLE_INSTALL=ON -DCMAKE_BUILD_TYPE=Release && cmake --build out/Release). - Build my app, linking against all the dawn dependencies.
List of things I learned
Some interesting things I discovered along the way:
Using llvm-nm to check the symbol table
Running llvm-nm against the static object file is a useful way to confirm that what I’m reading in the dawn source is actually correct. Really helps me narrow down that, for example, dawn::native::GetProcs() is actually in the static native build, and I’m not just imagining it.
$ nm libs/dawn/out/Release/src/dawn/native/libdawn_native.a | grep GetProcs
U __ZN4dawn6native15GetProcsAutogenEv
0000000000000000 T __ZN4dawn6native8GetProcsEv
0000000000002a00 T __ZN4dawn6native15GetProcsAutogenEv
Right now I’m using these individual symbol names to link, which seems wrong…. Come back to this one. Should just be able to use something like native_dawn_GetProcs instead of __ZN4dawn6native8GetProcsEv.
Zig’s build.zig for C is actually good
I’ve used CMake and Make before, and I like the latter more than the former, but zig’s build structure is really good. I love being able to write code as a build process, trusting that zig will link the steps and dependencies together, check it, and I don’t have to mess around with figuring out a syntax or dropping into bash to find sources.
Dawn’s dependencies
Depending on the target platform, we have to link a lot of stuff. Dawn is complex, and these are required to support the different backends. Some of these are system libs, so we just need to add a line. But others are a bit more complex. Tint and SPIRV for example need to be compiled along side dawn, and then included in your final exe.
┌───────┐ ┌──────────────────┐
┌──┤ macOS ├────┬───►│ Metal │
│ └───────┘ │ └──────────────────┘
│ │ ┌──────────────────┐
│ ├───►│ CoreGraphics │
│ │ └──────────────────┘
│ │ ┌──────────────────┐
│ ├───►│ Foundation │
│ │ └──────────────────┘
│ │ ┌──────────────────┐
│ ├───►│ IOKit │
│ │ └──────────────────┘
│ │ ┌──────────────────┐
┌─────────┐ │ ├───►│ IOSurface │
│ Abseil │◄───┤ │ └──────────────────┘
└─────────┘ │ │ ┌──────────────────┐
┌─────────┐ │ ├───►│ QuartzCore │
│ Tint │◄───┤ │ └──────────────────┘
└─────────┘ │ │ ┌──────────────────┐
┌─────────┐ │ └───►│ XCode Frameworks │
│ SPIRV │◄───┤ └──────────────────┘
└─────────┘ │
│ ┌─────────┐ ┌───────────────────────┐
├──┤ windows ├──┬───►│ D3D12 Headers │
│ └─────────┘ │ └───────────────────────┘
│ │ ┌───────────────────────┐
│ └───►│ DirectXShaderCompiler │
│ └───────────────────────┘
│
│ ┌───────┐ ┌────────────────┐
└──┤ linux ├────┬───►│ Vulkan Headers │
└───────┘ │ └────────────────┘
│ ┌────────────────┐
└───►│ X11 Headers │
└────────────────┘
To get moving, I’m just using find libs/dawn/out/Release | grep \\.a to get all static artifacts, and linking them all. This is… a lot, but we can clean them up later. For xcode, vulkan, wayland, and x11 libs I’m depending on hexop’s simplified libraries.
Dawn
Dawn is Google’s open-source implementation of the WebGPU API. It provides the native graphics layer that browser engines (namely Chrome, I think) or apps (like this) can use to exec WebGPU calls by translating them to different GPU backends (eg Vulkan, Metal, and Direct3D 12).
I wanted to use it because it seemed like it’s a longer-term solution than OpenGL/WebGL, which is stable, but falling out of popularity.
Using Dawn: The Good
Let’s start with the good.
There are two ways to use dawn here: dawn_native and dawn_wire. The native way of using dawn is a little closer to traditional GPU interactions that are fairly synchronous. You call a function in the proc table, and something happens. dawn_wire is the library that uses a client/server relationship where buffers are serialized so the client can defer as much as possible to the server.
I’m using dawn_native at the moment, but the wire library interests me because it may open up the option for me to more easily split my application into two threads where processing and rendering take place simultaneously. When targeting a browser via WASM, I think I could do something like run the dawn_wire client (or my implementation of it) in a Web Worker, handling application logic in the main browser event loop. But for now, dawn_native works well. The initialization structure is close to OpenGL.
Something interesting that I didn’t fully understand until now is that the procedure table is required because applications ship with or require dylibs, and while a binary is compiled for a specific platform and architecture, the GPU often isn’t known until runtime. For example, Vulkan may not be supported, and depending on whether it is, your application needs to define procedures to work around it. So you’re not really loading the proc table, so much as you are resolving the addresses to the shared functions. Could you lookup the correct function on each call, based on the supported GPU, or any other runtime data? Sure, but since it’s not gonna change, it’s a better idea to just choose your implementation up front when initializing.
The Bad: Duplicate Extern Defs
I can use Dawn by importing the headers, but it’s easier to work in native Zig than it is to write application code using C-style Zig imports like struct_WGPUComputePassEncoderImpl. So I wrote a couple one-to-one Zig extern structs to match the WGPU header types, before finding that the maintainer of the mach game library already tried using WebGPU as a backend for the engine last year, and helpfully has a couple of related libraries archived, or in workable states in the commit log.
Using that as a base, I was abel to correct an implement most types defined in the webgpu header. The simplest example of this is the CommandBuffer as a pointer type and then with an interior descriptor type.
const ChainedStruct = @import("webgpu.zig").ChainedStruct;
const Impl = @import("interface.zig").Impl;
const WGPUStringView = @import("string_view.zig").WGPUStringView;
pub const CommandBuffer = opaque {
pub const Descriptor = extern struct {
next_in_chain: ?*const ChainedStruct = null,
label: WGPUStringView = .{},
};
pub inline fn setLabel(command_buffer: *CommandBuffer, label: WGPUStringView) void {
Impl.commandBufferSetLabel(command_buffer, label);
}
pub inline fn reference(command_buffer: *CommandBuffer) void {
Impl.commandBufferReference(command_buffer);
}
pub inline fn release(command_buffer: *CommandBuffer) void {
Impl.commandBufferRelease(command_buffer);
}
};
The be honest, this is… a lot of work. I’m paying a huge overhead just for ergonomic Zig structs, that are still marked as externs anyways. It’s an error prone process that has lots of seg faults, and errors if I don’t get the ABI alignment correct. There’s no tolerance for out of order fields for externs.
It’s an enormous pain, but I got it working.

Flags
Fun to use packed structs instead of bitwise flags. Instead of
typedef WGPUFlags WGPUTextureUsage;
static const WGPUTextureUsage WGPUTextureUsage_None = 0x0000000000000000;
static const WGPUTextureUsage WGPUTextureUsage_CopySrc = 0x0000000000000001;
static const WGPUTextureUsage WGPUTextureUsage_CopyDst = 0x0000000000000002;
static const WGPUTextureUsage WGPUTextureUsage_TextureBinding = 0x0000000000000004;
static const WGPUTextureUsage WGPUTextureUsage_StorageBinding = 0x0000000000000008;
static const WGPUTextureUsage WGPUTextureUsage_RenderAttachment = 0x0000000000000010;
static const WGPUTextureUsage WGPUTextureUsage_TransientAttachment = 0x0000000000000020;
static const WGPUTextureUsage WGPUTextureUsage_StorageAttachment = 0x0000000000000040;
I can do this:
pub const Usage = packed struct(u64) {
copy_src: bool = false,
copy_dst: bool = false,
texture_binding: bool = false,
storage_binding: bool = false,
render_attachment: bool = false,
transient_attachment: bool = false,
storage_attachment: bool = false,
_padding: u57 = 0,
comptime {
std.debug.assert(
@sizeOf(@This()) == @sizeOf(u64) and
@bitSizeOf(@This()) == @bitSizeOf(u64),
);
}
pub const none = Usage{};
pub fn equal(a: Usage, b: Usage) bool {
return @as(u7, @truncate(@as(u64, @bitCast(a)))) == @as(u7, @truncate(@as(u64, @bitCast(b))));
}
};
Then just toggle on the ones I want rather than doing a union on the multiple flags.
Using Dawn: The Ugly
If I were writing this in straight up C, the obvious thing to do would be to just use emscripten, which has webgpu headers and all the code gen need to work with the html canvas and JS Web GPU API. Even with Zig, I could use emscripten and this would work really well. GitHub user seyhajin did this in github.com/seyhajin/webgpu-wasm-zig, which I was able to fork and get working.

By defining WGPU extern structs in Zig, I’m making it very ugly to work with these in the JS implementation. I’m essentially working with my own emscripten definitions. I’d rather not add yet another tool, so I’m trying to avoid using emscripten, but in the end it may be just simpler.
Another option would be to use something like github.com/juj/wasm_webgpu, which already did what I am attempting to do, but they’re doing it with C. Using them as a reference for now.
WGPUStringView
Dawn does not like [*:0]const u8 as a string type, and has it’s own WGPUStringView, defined as:
typedef struct WGPUStringView {
WGPU_NULLABLE char const * data;
size_t length;
} WGPUStringView WGPU_STRUCTURE_ATTRIBUTE;
So then a blank string is just a null with a length of max size. In zig, this is a weird one, as normally we’d write [*:0]const u8 and be done with it, allowing a null string with a ? prefix, but when serializing we need to use the Dawn ABI, so we just define a compatible string view.
pub const WGPUStringView = extern struct {
data: [*c]const u8 = std.mem.zeroes([*c]const u8),
length: usize = 0,
pub fn of(v: [*:0]const u8) WGPUStringView {
return .{
.data = v,
.length = std.mem.len(v),
};
}
pub fn toString(view: WGPUStringView) []const u8 {
if (view.data == null or view.length == 0) {
return "";
}
return view.data[0..view.length];
}
};
WebGPU in WASM
Now that we’ve got dawn working on native, let’s see if we can use as much of the same code to get it working on WASM. One issue I can already foresee is serialization. Right now we have a giant dawn_impl.zig file that has a huge pub const Interface = struct ... that contains fns like:
pub const Interface = struct {
pub inline fn textureCreateView(
texture: *gpu.Texture,
descriptor: ?*const gpu.TextureView.Descriptor,
) *gpu.TextureView {
return @ptrCast(procs.textureCreateView.?(
@ptrCast(texture),
@ptrCast(descriptor),
));
}
}
I assume that we would create a duplicate called web_impl.zig and swap out @ptrCast with our JS sys library to call the fn on the struct, like texture.call(js.Object, "createView", .{js.ref(descriptor)});.
pub const Interface = struct {
pub inline fn textureCreateView(
texture: *gpu.Texture,
descriptor: ?*const gpu.TextureView.Descriptor,
) *gpu.TextureView {
// pseudocode... probably more complex in reality.
// maybe js.ref wouldn't be needed if it's already a ref?
return texture.call(js.Object, "createView", .{js.ref(descriptor)});
}
}
This requires that our types like gpu.Texture (which are currently just pub const Texture = opaque {}) exist as the js.Ref type. This is actually good; the opaque types exist just to maintain type safety with pointers that point to unknown types. This is why we need to @ptrCast in and out of the proc table fns. But I’m also hand waving away some other issues. We’re assuming that all calls can have their args transferred to JS types. We can use comptime inference to determine if the value is already in JS or not. If it’s a JS ref, we know it’s fine, and if it’s a string literal we can convert, and so on.
Would this be simplified at all if we just defined a JS interface for webgpu, like emscripten does, then call that? We would still need to serialize/deserialize everything. For things like nested structs, we would deserialize and treat pointers as strings/numbers, looking up the referenced object to work with. I think this is essentially the same thing.
As long as I know the opaque type gpu.Texture can be treated as a js.Ref type, I’m good. This is the point where it would be really useful to just use emscripten and be done with it. What are the pros/cons of emscripten?
- PRO:
- Easier to use than doing it myself.
- May need it for async C code down the line anyways.
- CON:
- Yet another tool to add.
- Need to include the emscripten JS in our final web bundle.
Let’s see how far I can get with my own strategy before I turn to emscripten.
Drawing The Rest of The Owl
This is where I spun my wheels for a long time. Defining WebGPU bindings on both sides of the JS boundary, while also keeping with the webgpu headers is an enormous pain because it’s the exact mix of boring and error-prone.
It’s like those competitions where they see who can hold their hand on new a car the longest, with the winner taking home the car. Starts out pretty easy, but you eventually tire and slip up by changing position and taking your hand off the car, and then you don’t win the car. Or in my case, you spend months debugging dozens of slip ups in what is basically emscripten’s WebGPU bindings, but in Zig. (I wouldn’t win the car, but I’d probably set a pretty good personal record.)
The JS-land code sure is simple for most of these calls. As long as we do our serde from Zig-land, we can just pass-through w/ some globals.
window.surfaceConfigure = (...args) =>
context.configure({ ...args[0], device });
Zig-land is…. less simple. Messy enough that I’m omitting the whole JSON serde part to make it readable.
pub inline fn surfaceConfigure(
surface: *gpu.Surface,
surface_configuration: *const gpu.Surface.Configuration,
) void {
// TODO/NOTE Ben 2025-11-18: REF-fe403808 replace global binding w/ call
// to surface/context when we do global binding clean up.
_ = surface;
const AlphaMode = enum {
const Self = @This();
opaque_,
premultiplied,
pub fn toString(self: Self) []const u8 {
return switch (self) {
.opaque_ => "opaque",
.premultiplied => "premultiplied",
};
}
pub fn jsonStringify(self: Self, out: anytype) !void {
return try out.write(self.toString());
}
};
const alpha_mode = if (surface_configuration.alpha_mode == .opaque_)
AlphaMode.opaque_
else if (surface_configuration.alpha_mode == .premultiplied)
AlphaMode.premultiplied
else
// TODO/NOTE Ben 2025-11-18: do something other than panic?
@panic("only alpha modes 'opaque' and 'premultiplied' allowed for web impl");
const device_obj: *align(8) js.Object = @alignCast(@ptrCast(surface_configuration.device));
// Create JSON configuration with device reference
var json_string_raw = std.ArrayList(u8).init(allocator);
defer json_string_raw.deinit();
var writer = json_string_raw.writer();
// Create a JSON object that references the device by its ID
// ...
// Omitted, but we build a json string of device, _ptr, format,
// alphaMode, colorSpace, and usage
// ...
const config_json = json_string_raw.items;
const config_obj = js.Object.json(config_json);
// TODO/NOTE Ben 2025-11-18: REF-fe403808 avoid calling js.global, use
// the actual context.
_ = js.global.call(
void,
"surfaceConfigure",
.{config_obj},
) catch unreachable;
}
Tempting as it is to say that LLMs could have generated many of these bindings, I’ve found that not to be true. LLMs barely know what Zig is, and Zig’s features and API change so frequently that when LLMs think they know what Zig is, they often know what Zig was. For every binding they get right, they yada-yada another binding, and hallucinate a third. Debugging these is excruciating. Far more painful that just writing them by hand. Also, by using an LLM I would deny myself the chance to learn some WebGPU stuff, which I need to do anyways because I’ll be using it to write our UI.
So I cut a few small corners just to get this to work, and wrote the rest of the WebGPU JS/Zig bindings. The few calls that were async, I consolidated into setup commands that we run once in our JS when we load it, before we even initialize our WASM module. This feels like cheating, but I’ll be honest that I don’t yet know how I’m going to handle async GPU callbacks w/ WASM. WASM doesn’t have (in it’s current form) a standard way to do this, and it probably involves pushing state to a struct, then pointing to a callback that will pop that state and continue work.
// TODO/NOTE Ben 2025-11-18: REF-fe403808 replace global binding w/ proper
// js-instantiation-from-zig technique.
async function initWebGpu() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
quitIfWebGPUNotAvailable(adapter, device);
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
bindToWindow({ canvas, context, adapter, device });
return {
canvas,
context,
adapter,
device,
};
}
(Actually, this might not even be cutting that many corners. I’m having a difficult time imagining a situation where we would need multiple canvases or change adapters/devices when deploying to the web.)
Manual Labor Yielding Results
The hard work (sometimes) pays off! We get to say hello to our triangle, this time in a browser.

Was this a lot of work the achieve basically the same results as before? Yes. Was it interesting and did I learn from it? Yes. Is this slowing down the project? Absolutely. But I’m having a great time hanging out with that little triangle.