zimr

A zig port of raylib, imgui, implot2d/3d, and box2d / jolt physics for wasm/webgpu. Includes transpilers from zig to wgsl (gpu shaders/compute) and javascript — the browser host and the web workers are Zig too — so we can live in a pure zig world.

Work in progress The API surface, build steps, and example list change often. This page describes the current state of the repository. It is not a stable contract and not a recommendation.
New to graphics? This page is a reference. If shaders, WebGPU, and the GPU are new to you, start with the from-scratch tutorial instead — it teaches the whole stack through zimr, from drawing a circle to reading a raymarched-metaball shader, one idea at a time.

What this is

zimr is a port of raylib, Dear ImGui, ImPlot / ImPlot3D, Jolt, and Box2D to Zig, compiled to wasm32-wasi and rendered through WebGPU in a browser. Zig moves fast so it is difficult to get dependencies to agree on which zig version of zig to use, so I prefer a giant monorepo where everything moves to new zig versions at the same time. There are no third-party libraries. The renderer, the math, the UI, the 2D and 3D plotting, the physics, the glTF loader, the TTF font loader, the shader toolchain, the GPU compute layer, the software rasterizer, the web workers, and the browser host are all in this repository, all in Zig. There is no hand-written JavaScript anywhere in the engine — not in the host, and not in the worker.

Live — the launcher, running here in your browser. Tap the pill (◂ ▸) to switch between the flagship apps. Its wasm is a separate file, fetched only when this frame scrolls into view, so the page itself stays small — nothing is inlined.

The only dependency is the Zig compiler. The build shells out to nothing else: the steps that matter — Zig to SPIR-V to WGSL for shaders and compute, and the host's Zig to C to JavaScript — are done by tools written in Zig and living in tools/. The dev server is also a Zig program.

Size, for calibration: about 206,000 lines of Zig under src/ (66 top-level files plus a src/shaders/ directory of 106), 265 example programs, ~1,900 unit tests, and a locked SPIR-V→WGSL fixture corpus of 91 shaders run via zig build corpus. The transpilers are themselves large: src/spv2wgsl.zig is ~11,500 lines and tools/c2js.zig is ~9,400. The toolchain is pinned to a specific Zig 0.17 dev build; other versions are not expected to work.

What is ported is the behaviour, not the API. The algorithms, the numerical choices and the visual results are faithful — that is what "port" means here — but the surface has diverged, deliberately and substantially, and code does not carry across.

The clearest break is that raylib's global state is gone. There is no DrawCircleV(center, radius, color) reaching for a hidden context: 2D primitives are methods on an explicit sink (circle, rect, text…), which is what lets the same drawing routine run unchanged on four different backends. Dear ImGui keeps its immediate-mode rhythm and its ID rules, but calls take Zig format strings rather than printf ones — u.text("{d} spp", .{samples}) — and errors come back as error unions instead of out-params. Colors, vectors and matrices belong to zimrmath rather than to any one library. Options arrive as designated struct literals; buffers are slices, not pointer-plus-count.

So upstream knowledge transfers — if you know how an ImGui ID stack behaves, or what raylib's fog fold does, you already know how this behaves — but upstream code does not. What did survive wholesale is the example set: the shader demos on this page (fog, cel shading, deferred rendering, the texture-effect gallery) are raylib's own examples rebuilt on this stack, usually with the keyboard toggles turned into touch UI, and zimrphysics2d-demo carries Box2D's 137 sample scenes.

One language, several outputs

Everything starts as Zig. Different parts of the program take different paths to the browser, but no part is hand-written in GLSL, WGSL, C, or JavaScript — there is no inline WGSL anywhere in the tree. Shaders are Zig functions; the WGSL that reaches the GPU is an invisible build artifact. The paths are:

engine + app + UI + math      zig  --(zig build-obj, wasm32-wasi)-->  .wasm
shaders + compute kernels     zig  --(zig build-obj, spirv)--> SPIR-V --(spv2wgsl)-->  WGSL
browser host / bridge         zig  --(zig build-obj, -ofmt=c)-->  C --(c2js)-->  JavaScript

The same Zig source also feeds a fourth and a fifth path: a shader function compiled to SPIR-V for the GPU is the very same function the software rasterizer runs per-pixel on the CPU, and a few shaders are additionally evaluated at Zig comptime, baked into the binary by the compiler itself. There is one definition; the build decides where it executes. The shadow-mapping flagship below runs one shader body all three ways on one screen.

output tool source
wasm module Zig compiler (self-hosted) all of src/
WGSL shaders spv2wgsl (Zig) src/spv2wgsl.zig
JavaScript host c2js (Zig) tools/c2js.zig
CPU pixels / CPU compute / comptime bakes software rasterizer src/raster.zig, src/raster_shader.zig

The SPIR-V→WGSL translation is more than a syntax swap. SPIR-V control flow is an arbitrary basic-block graph; WGSL requires structured if/loop/switch, so the transpiler reconstructs structured control flow and lowers OpPhi nodes into ordinary assignments. WGSL demands textureSample under uniform control flow, so a pass hoists sampling out of non-uniform branches. Distinct function-local variables that share a SPIR-V name are uniquified so they don't collide as WGSL declarations. Each of these passes exists because its absence once produced a blank canvas or a validation error, which is why every shader in the tree is translated and checked against locked fixtures on every gate run (zig build corpus).

A few invariants hold across all of it. There are no module-level mutable globals: every subsystem threads its state through explicit pointers, so two instances never share hidden state. The source is one Zig module with an enforced acyclic dependency graph. And the discipline is mechanical: a per-change test gate, a custom AST linter (tools/lint_zimr) that repairs mechanical style violations in place, and the transpiler-corpus and headless smoke checks that keep the GPU and CPU paths honest. The point of one language is that one set of tools — one compiler, one test runner, one debugger — covers the entire stack.

The application model

An example is a struct of state plus a few functions, described by a z.AppSpec value named app. The host owns the browser frame loop and calls update once per frame with a *z.Frame carrying input, timing, and the draw context. There is no main loop to write and no callback registration beyond this declaration:

const z = @import("zimr");
const zm = @import("zm");

const State = struct { pos: zm.Vec2, vel: zm.Vec2, paused: bool = false };

pub const app: z.AppSpec(State) = .{
    .config = .{ .window = .{ .title = "ball", .width = 800, .height = 450 } },
    .init = initState,
    .deinit = deinit,
    .update = update,
};

fn update(f: *z.Frame, s: *State) void {
    if (z.isKeyPressed(f.input, .space)) s.paused = !s.paused;
    const dt: f32 = @floatCast(f.time.delta_time);
    if (!s.paused) s.pos += s.vel * zm.splat2(dt * 60.0);

    z.beginDrawing(f.gl);
    z.clearBackground(f.gl, zm.Color.raywhite);
    f.gl.circle(s.pos, 20.0, .{ .color = zm.Color.maroon });
    f.gl.text(.{ 10, 10 }, "SPACE to pause", .{ .size = 20, .color = zm.Color.gray });
    z.endDrawing(f.gl);
}

The lifecycle is three functions and no more. init(gpa, f) !State runs once — load fonts, build meshes, create render textures, do one-time GPU uploads — and returns the initial state by value. update(f, s) runs every frame and is where all input, simulation, and drawing live. deinit(gpa, s) frees what init allocated. State is an ordinary struct returned by value: no globals, no hidden singletons; two instances of the same app would not share a byte.

The *z.Frame is the one argument threaded everywhere, and its fields are the engine's whole runtime surface: f.gl is the immediate-mode draw context (every z.draw* call takes it), f.gpu is the lower-level WebGPU frame for renderers that manage their own pipelines (the graphics demos below, the PBR renderer, compute), f.input holds keyboard / mouse / touch / gesture state, and f.time carries delta_time and the total clock.

2D drawing

2D drawing goes through one small surface. A scene calls primitives — rect, circle, triangle, line, text, image, texture, and the rest of a roughly thirty-strong vocabulary (rounded rects, gradients, rings, arcs and sectors, polygons, ellipses, dashed lines, and linear / B-spline / Catmull-Rom / Bézier splines) — as methods on a sink. There is no separate free-function drawing API for 2D: every 2D primitive is a sink method, and the only draws that remain plain z. functions are the 3D ones.

The sink is duck-typed (sink: anytype), so one routine written against it runs unchanged on any of four backends:

// One primitive vocabulary, here on the immediate GPU sink f.gl. The exact
// same calls run on z.SwGl (CPU), a retained DrawList, or a Canvas (-> PNG).
z.beginDrawing(f.gl);
z.clearBackground(f.gl, zm.Color.raywhite);

z.beginMode2D(f.gl, s.camera);                 // pan/zoom in world space
f.gl.rect(.{ .x = 100, .y = 100, .width = 60, .height = 40 }, .{ .color = zm.Color.skyblue });
f.gl.line(.{ 0, 0 }, s.pos, .{ .color = zm.Color.red, .thickness = 3 });
f.gl.circle(s.pos, 20, .{ .color = zm.Color.maroon, .outline = 2 });
f.gl.text(.{ 10, 10 }, "hello", .{ .size = 20, .color = zm.Color.gray, .font = &s.font });
z.endMode2D(f.gl);
z.endDrawing(f.gl);

The same routine, factored against a sink, is what lets it run on more than one backend:

fn drawScene(sink: anytype, s: *State) void {
    sink.circle(s.pos, 20, .{ .color = zm.Color.maroon });
    sink.text(.{ 10, 10 }, "any backend", .{ .size = 20, .color = zm.Color.gray, .font = &s.font });
}

drawScene(f.gl, s);                 // GPU
var sw = z.SwGl.init(&s.sw_ctx);    // CPU software rasterizer, side by side
drawScene(&sw, s);

Images and textures follow the raylib split: an z.Image is CPU-side pixels (src/image.zig is a GL-free port of raylib's image module — procedural generators like genImagePerlinNoise and genImageCellular, PNG decode, blur / invert / rotate edits); a z.WgpuTexture is the GPU upload. updateTexture re-uploads an edited buffer in place, the right move for pixels that change every frame. Render-to-texture is loadRenderTexture(w, h) once, then a normal draw sequence bracketed by beginTextureMode(f.gl, rt, clear) / endTextureMode(f.gl); afterwards rt.asTexture() is a sampleable texture to draw back to the screen with f.gl.texture, map onto 3D geometry, or feed a shader — that last one is the spine of the effects gallery below.

A render-texture pass is a full pass, so beginMode3D works inside one: an app can render 3D into several framebuffers in a single frame and composite them (a split screen, a minimap, a security camera). Each 3D pass takes its own slot in the engine's view-projection ring, so the pass keeps the camera it was recorded with — a frame's writeBuffer calls all land before its one submit, so a single-slot camera uniform would hand every pass the LAST camera. When you draw a framebuffer back, opts.source selects a sub-rectangle in pixels, and a negative width or height flips that axis, the same rule as gl.image and raylib's DrawTexturePro. The textures-framebuffer-rendering example is the whole story in one screen: two cameras, two framebuffers, one of them cropped and magnified back over the other.

Input, time, and the math vocabulary (zm)

Input is polled, not delivered by events: the frame carries a snapshot and you ask it questions. isKeyPressed fires once on the down edge, isKeyDown stays true while held; the mouse and wheel work the same way, multitouch reports getTouchPointCount and per-point positions, and a gesture layer recognizes taps, holds, swipes, and pinches. The graphics demos below all share one phone-first idiom built on this: one-finger drag orbits, two-finger pinch zooms, and the drag is gated on ui.wantCaptureMouse() so grabbing a slider never spins the camera.

src/zimrmath.zig (imported as zm) is the shared vocabulary used by CPU code and shaders alike. Vectors are native Zig vector types — Vec is @Vector(4, f32), with Vec2 and Vec3 alongside — so arithmetic is just operators. On top sit a 4×4 Mat, camera helpers (lookAtRh, perspectiveFovRh, rotations), and dot / cross / normalize / clamp. zm.Color is the color type, engine-wide — the raylib named palette plus colorFromHSV. Because a shader's shaderMain is written against this same module, the vector math in a fragment shader and in a CPU simulation is literally the same code.

Shaders in Zig

A shader is an ordinary Zig function over a typed input/output schema. There is no GLSL and no WGSL anywhere in example or engine code; the build compiles the function with zig build-obj -target spirv32-vulkan and translates the SPIR-V to WGSL with spv2wgsl. Each shader is two small files: the body (foo_fs.zig, whose entry point is shaderMain) and a sibling schema (foo_fs_io.zig) declaring the attribute inputs, the varyings, the uniform block, and any samplers as plain Zig structs. Here is the shape of a real fragment material from the tree — the distance-fog shader, condensed (the full version is src/shaders/fog_fs.zig and its io file, about 120 commented lines between them):

// src/shaders/fog_fs_io.zig — the schema
const common = @import("gbuffer_common_io.zig");
pub const Inputs = common.Interp;          // world pos + normal, SHARED (see below)
pub const Ubo = struct {                    // plain struct (Zig 1245 bans
    base_color: zm.Vec,                     // @Vector fields in extern structs
    view_pos:   zm.Vec,                     // on CPU); GPU bytes come from the
    light_dir:  zm.Vec,                     // shader_interface wire serializer,
    fog_color:  zm.Vec,                     // NOT @sizeOf/asBytes on the struct.
    params:     zm.Vec,                     // {fog_density, 0, 0, 0}
};
pub const Outputs = struct { final_color: @Vector(4, f32) };

// src/shaders/fog_fs.zig — the body
pub fn shaderMain(io_in: Io) Out {
    var out: Out = undefined;
    const normal: Vec3 = normalize(io_in.frag_world_normal);
    const n_dot_l: f32 = @max(dot(normal, light_dir(io_in)), 0.0);
    const lit: Vec3 = shade(io_in, n_dot_l);            // lambert + blinn
    const dist: f32 = length(toEye(io_in));
    const dd: f32 = dist * io_in.u.params[0];
    const visibility: f32 = clamp01(1.0 / @exp(dd * dd)); // raylib's exp² curve
    const fog: Vec3 = rgb(io_in.u.fog_color);
    const final: Vec3 = fog + (lit - fog) * @as(Vec3, @splat(visibility));
    out.final_color = .{ final[0], final[1], final[2], 1.0 };
    return out;
}
comptime { _ = shader_externs.installSpirvEntry(shaderMain); }

The typed schema is what makes everything else fall out. The Ubo struct becomes the WGSL uniform buffer and its bind-group layout, generated from the Zig types so the host and shader cannot disagree about the layout; on the host you take the same type back with @FieldType(fog_fs.Io, "u"), fill it, serialize it with z.shader.wireOf (the plain-struct schema's std140 bytes), and queueWriteBuffer it — one definition of the uniform block, used by both sides. Samplers are declared the same way (texture0: shader.Sampler2D(...)) and sampled as a call: io_in.texture0(uv). And because shaderMain is plain Zig over zm, the identical function runs per-pixel in the software rasterizer, which is how every stylized demo below is verified before a GPU ever sees it.

The schemas also enable deliberate sharing, which is the design idea the whole shader tree is organized around: a vertex stage's varyings are a named struct in a *_common_io.zig file, and any fragment shader that wants those varyings just aliases them (pub const Inputs = common.Interp;). Varying compatibility between stages is compatibility by construction, not by matching magic @location numbers by hand. Two vertex shaders have grown into universal fixtures this way:

shared stage emits consumers
gbuffer_vs clip pos + world pos + world normal every world-space-lit forward material: the G-buffer writer, fog, cel — a new material is one fragment file
deferred_shading_vs a fullscreen NDC quad + its UV the deferred lighting pass and every 2D screen effect in the gallery — a new effect is one fragment file

Under the hood: how a schema becomes bindings

Nothing about the schema is interpreted at runtime. A build step (tools/gen_shader_externs.zig) reads each *_io.zig at comptime and generates a companion *_externs module: for every schema field it emits the exact @extern declaration SPIR-V needs — an addrspace(.input) for an attribute, an addrspace(.uniform) block for the Ubo, a sampler placeholder, a storage-buffer handle — each already decorated with its @group and @binding. It also emits the typed io_in wrapper your shaderMain receives, the Out struct, and the entry trampoline. The body file never writes an @extern or a decoration by hand.

That generated Zig is compiled to SPIR-V, lightly rewritten by zspv (sampler bindings, and a few things Tint is strict about), and translated to WGSL by spv2wgsl. The host @embedFiles the WGSL and builds the pipeline. So one struct field — say positions: StorageBuf(Vec2, .read) — becomes a decorated SPIR-V global, a @group(0) @binding(1) var<storage, read> line of WGSL, a bind-group-layout entry, and a typed io_in.positions(i) accessor, all from the one declaration.

Which @group each resource lands in is decided by a single function, shader_interface.uniformGroupForSchema — and that is the invariant the whole system rests on. The codegen, the pipeline-layout builder, and the bind-group builder all ask the same function rather than each re-deriving the answer, so a group number cannot disagree with itself across three unrelated files. Vertex uniforms are group 0, samplers group 1, fragment uniforms group 2; when a stage does not use a slot, an empty bind-group layout fills the positional hole honestly.

Known edges

Two soft spots, named honestly, because they are the shapes a future bug is most likely to take.

The software-rasterizer path for Storage and Builtins is written but barely exercised. The CPU rasterizer runs fragment shaders per pixel, and the decal side-by-side drives a real typed fragment shader (with a sampler) through it. But every storage-fed or builtin-using shader today is a vertex shader, which only runs on the GPU — so those CPU accessors compile but have never driven a frame. The first shader that needs a storage buffer on the CPU path will likely find the dispatcher does not populate those slices yet.

ubo_group is a blunt instrument. It pins the schema’s single UBO to a group; it has no answer for a stage that genuinely needs two uniform blocks in two groups. The split-file design means no shader needs that today, but if one ever does, the name and the mechanism both have to change — the honest fix is a per-block config that can place several uniform blocks independently.

Building the graphics demos

The showpiece examples share one recipe, and each adds exactly one idea to it. The recipe: write the shader pair as Zig (previous section), build a pipeline around the generated WGSL, render into an offscreen target with a raw pass, and composite with the ordinary 2D renderer — which is also how the touch UI ends up on top for free. Every demo below uses these two helpers verbatim:

// a bind-group layout holding one uniform buffer
const entries = [_]z.shader_introspect.BindGroupLayoutEntry{
    .{ .binding = 0, .visibility = .{ .vertex = true },
       .resource = .{ .uniform_buffer = .{ .min_size = z.shader.wireSizeOf(VsUbo) } } },
};
const blob = try z.gpu.encodeBindGroupLayoutEntries(gpa, &entries);
const bgl  = z.wgpu.createBindGroupLayout(device, blob, "g0");

// the pipeline: generated WGSL modules + one state literal
const combo = z.gpu.StateCombo.fromParts(
    .triangle_list, .none, .less, .back, .rgba8_unorm, .depth24_plus, 1);
const pipe_blob = try z.gpu.encodeRenderPipelineDescriptor(gpa, .{
    .vertex_buffer_layouts = &.{pos_normal_layout},
    .vs_entry_point = "entry", .fs_entry_point = "entry",
    .state = combo,
});
const pipeline = z.wgpu.createRenderPipeline(device, layout, vs_mod, fs_mod,
                                             pipe_blob, "demo");

Group numbers come from the shader codegen with one fixed scheme — vertex uniforms group 0, samplers group 1, fragment uniforms group 2 — so a pipeline layout is always the same positional array, with an empty bind-group layout filling any hole. Per frame, uniforms are filled through the shader's own types and every pass follows the same shape:

var vs_io: fog_vs.Io = undefined;
vs_io.u = .{ .mvp = mvp, .model = model, .normal_matrix = spin };
// Plain-struct Ubo -> std140/wire bytes via the shader_interface serializer
// (Zig 1245 forbids asBytes on a struct with @Vector fields; wireOf is the
// authoritative host-side layout, byte-matching the generated WGSL block).
const bytes = z.shader.wireOf(fog_vs.Ubo, &vs_io.u);
z.wgpu.queueWriteBuffer(f.gpu.queue, obj.vs_ubo, 0, &bytes);

z.beginTextureModeRaw(f.gl, s.rt, clear_color);       // raw pass into the RTT
const pa: *z.PassState = f.gl.pass;
Backend.setPipeline(pa, .{ .gpu_handle = s.pipeline });
Backend.setBindGroup(pa, 0, obj.g0_bg);
// ... setVertexBuffer / setIndexBuffer / drawIndexed per object ...
z.endTextureModeRaw(f.gl);

f.gl.texture(.{ .x = 0, .y = 0, .width = vw, .height = vh }, s.rt.asTexture(), .{});
s.ui_host.render(f);                                   // sliders on top

fog-rendering — a forward material is one fragment file. It ships no vertex shader of its own: the vertex stage is gbuffer_vs, reused, because "clip position + world position + world normal" is what every world-space-lit material wants. fog_fs aliases the shared varyings and adds Lambert, Blinn, and the exponential-squared fog fold. The scene is a 21-torus row marching into the murk with a density slider. The fog color equals the pass clear color, so distant geometry dissolves seamlessly — raylib ships a mismatch and every silhouette wears a halo.

cel-shading — two draws, opposite cull faces. The toon pass is one fragment file on gbuffer_vs, quantizing the diffuse with raylib's formula. The silhouette ink is the classic inverted hull: draw the mesh again, inflated along its normals, front faces culled, so only the shell peeking past the silhouette survives the depth test. Both draws share one pass and one vertex buffer; the pipelines differ only in fragment module and cull face. Two details are load-bearing — divide the band snap by bands - 1 or nothing ever reaches full brightness, and extrude the hull in model space before the model matrix, or a mesh normalized by ~14× (the Stanford bunny) turns an unconverted thickness into a blob.

deferred-render — multiple render targets and a fullscreen lighting pass. Pass A fills a three-texture G-buffer in one walk of the scene: gbuffer_fs is the engine's first multi-output fragment shader (world position and normal into rgba16_float, albedo+specular into rgba8_unorm). Pass B never touches geometry again: a fullscreen quad samples the three maps and runs Blinn-Phong for four orbiting lights once per screen pixel, however many cubes overdrew it. Sample the G-buffer with a nearest sampler — it is data, not a picture, and bilinearly mixing two world positions across a silhouette invents a third position on neither surface.

shadowmap-sw — one shader body, three executors. The flagship. Shadow mapping is the usual two passes, and the four shaders are standard Zig pairs. What the example exists to show is that the same shaderMain bodies run three ways on one screen: a GPU panel through the WGSL pipeline, a CPU panel through the software rasterizer calling the literal functions per pixel, and a comptime corner baked into the binary by the compiler evaluating the same code at build time. The three images agree, and that agreement is the whole argument for writing shaders in Zig.

shader-effects — one fragment file per raylib example. Four of raylib's texture-shader demos in one gallery. Every effect has the same shape — sample texture0 at (or near) the quad UV, do math, emit — so the family shares a schema and the fullscreen vertex stage, and each raylib demo became exactly one fragment file: color grading (at saturation −1 it is their grayscale example, two ports for one shader), a sinusoidal UV warp, a four-tap outline, and a posterize into a swappable palette (done as a branchless masked walk, because dynamic indexing into a uniform array upsets some drivers). Where raylib filters a static PNG, the gallery filters a live animated scene, which makes it the stress test: offscreen 2D, raw passes, and UI in one frame.

helmet-sw — PBR, and the CPU as referee. Textured PBR goes through z.pbr3d, a renderer that owns its pipeline: build it once, loadGltf a model (the pure-Zig parser resolves the five PBR maps and synthesizes flat normals when missing), then beginFrame and draw per frame. helmet-sw renders the damaged-helmet glTF twice — GPU pipeline on one half, software rasterizer running the identical PBR shaderMain on the other, draggable split. The older side-by-sides (mandel-sidebyside, rt-sidebyside, cube-sidebyside) are the same idea at smaller scale, with a pixel-diff overlay.

launcher — every demo above, one wasm. The capstone hosts the whole flagship roster — the six shaders here plus physics, plots, GPU fluids, and the fractal explorer — behind a fullscreen switch pill (◂ prev, a dot per app, ▸ next), showing one full-screen at a time. Children are registered with addDeferred, so a child is lazy: it is initialized on the frame it is first switched to, which is why bundling a 6 MB PBR helmet next to two dozen others still boots light. Each app is hosted fullscreen rather than in a placement rect on purpose — pushViewport remaps rendering but not input, so an offset child's own touch UI (physics orbit, showcase tabs) would mis-hit; fullscreen keeps every child's input space identical to running it standalone. The compose contract is uniform: a child draws its scene and its own UI and never calls endDrawing — the runner owns frame begin/end and the launcher paints the pill on top in the same frame, with App.endDrawing asserting if a child breaks the rule. It is the readable proof of the "kernel wasms compose" idea from the compute section: one wasm exporting a superset of the hosted apps' names.

Verify before the device sees it. Every example runs headless in the smoke harness, which drives real frames through the WebGPU layer without a GPU: it validates attachment state, catches a uniform written twice at the same (buffer, offset) in one frame, and diffs the WGSL a change produces against the WGSL it replaced. A phone-only workflow needs the build to be the thing that says no.

Lower-level pipeline studies. The pipeline-* examples each isolate one WebGPU mechanism against the raw API rather than the raylib-shaped one: instancing, storage buffers, MSAA, mipmaps, render targets, post-processing, bloom, samplers, constants, arrays, uniforms, and per-pipeline settings.

GPU compute (kompute)

Compute kernels are also Zig. A kernel file declares a Buffers struct (the storage arrays), a Params struct (the uniform), and one or more kernel functions. The same source compiles two ways: to SPIR-V for a GPU dispatch (translated to WGSL), and natively for a CPU loop that executes the literal kernel functions over heap arrays. The complete file for a working example:

const k = @import("kompute");

pub const config = k.Config{ .max = 4096, .workgroup = 64 };
pub const Buffers = extern struct { data: [config.max]f32 };
pub const Params  = extern struct { count: u32, pad: [3]u32 = .{ 0, 0, 0 } };

pub const g = k.Globals(@This());
const b_data = g.bind(.data);          // one alias per Buffers field

pub fn double(c: k.Ctx(@This())) void {
    if (c.id >= c.params.count) return;
    b_data[c.id] = b_data[c.id] * 2.0;
}

comptime { k.installKernel(@This(), "double"); }

The host side, with either backend chosen at init:

var pipe = try z.Compute(dk).initGpu(gpa, dev, queue, &.{
    .{ .name = "double", .wgsl = double_wgsl },   // WGSL generated by the build
});
pipe.params = .{ .count = n };
pipe.upload(.data, input[0..n]);
pipe.count = n;
pipe.run("double");
if (pipe.readLatest(.data)) |out| { ... }          // frame-delayed readback

// a renderer can bind a field's storage buffer directly, with no CPU copy:
const pos_buf = pipe.fieldBuffer(.pos).?;

Each Buffers field becomes its own storage buffer and its own binding in the generated WGSL. It was not always so: the original design bound one struct containing all arrays through a single binding, which corrupted on a Qualcomm Adreno 7xx phone above ~1,000 invocations — writes landed in the wrong field. Per-field bindings run 20,000 particles on the same device. GPU→CPU readback is poll-based: a staging copy plus an async map, surfaced to Zig as "the latest completed snapshot", typically one frame old.

The flagship compute example is a 2D SPH fluid (fluid-gpu): 20,000 particles entirely on the GPU with Clavet double-density relaxation, a handful of kernels per step, drawn with no CPU round-trip — the FluidDiscs renderer binds the position and density buffers directly and SDF-shades a disc per particle. The transpiler does not lower atomics yet, so the neighbour grid is built without them: the baseline scans, and fluid-sort replaces the scan with a GPU spatial counting sort (clear, count, prefix-sum, scatter) that reorders the particle arrays into cell order every frame — the classic spatial-hashing win, atomic-free. Because the kernels are ordinary Zig, the same files run on the CPU backend, which is how the simulation is debugged without a GPU in the loop.

Web workers, written in Zig

A browser runs your page on one thread. Anything slow you do on it — encoding a PNG, tracing a ray, chewing through a mesh — freezes the entire interface for as long as it runs. The escape hatch is a Web Worker: a second OS thread with its own memory, which cannot touch the page and talks only in messages.

In zimr the work you send it is an ordinary Zig function, the worker's own program is Zig, and the plumbing between them is Zig. The complete kernel from the worker-png example:

const jobs = @import("zimr").jobs;

/// `extern`, not a plain struct. These bytes are memcpy'd out of the app's wasm and
/// into the kernel's — two separate compilations — and Zig's auto layout is free to
/// reorder fields across them.
pub const Size = extern struct { w: u32, h: u32 };

/// An ordinary Zig function. Allocator first, as everywhere in std; a plain
/// `std.Io.Writer` for output. Nothing about it announces that it will run on another
/// thread, which is the point.
///
/// `gpa` is an ARENA: whatever the kernel allocates is released when the job ends, so
/// there is no `defer free` here and no way to leak.
pub fn encodePng(gpa: Allocator, hdr: Size, pixels: []const u8, out: *std.Io.Writer) !void {
    try codecs.png.encode(gpa, pixels, hdr.w, hdr.h, out);
}

/// The header type is DERIVED from the function signature. There is nothing to keep in
/// sync — change `Size` and every call site fails to compile.
pub const registry = jobs.Registry(.{
    .{ "encodePng", encodePng },
}, .{ .max_input = 16 << 20, .max_output = 8 << 20 });

And the app side is two calls, neither of which blocks:

// submit: returns immediately, whatever the kernel costs.
s.job = try kernels.registry.submit(gpa, kernels.encodePng, .{ .w = 1024, .h = 1024 }, pixels);

// poll: once a frame, in update(). Null means "not yet"; the app just draws again.
if (try s.job.pollInto(s.result_buf)) |png_bytes| {
    // ...it landed. `result_buf` was allocated ONCE, at init.
}

1024×1024 takes about 240 ms in wasm on a phone. On the main thread that is a 233 ms frozen frame — fourteen dropped frames, plainly visible. Off-thread the measured worst frame gap is 18 ms, and a dot spinning in the corner does not stutter.

the kernel wasm has no imports, and the build proves it

A job kernel compiles into its own wasm module with its own linear memory, so it cannot see the app's globals even if it wanted to — and it has zero imports, so the worker instantiates it with a bare {}. That is not a convention: the build reads the wasm's import section and refuses to embed it if it is non-empty, so a kernel that reaches for the DOM fails to build rather than failing on someone's phone.

Each kernel is its own export, named zimr_job_<name> — no id, no hash, no dispatch table, so a job cannot reach the wrong kernel. It also means kernel wasms compose: the launcher page bundles many examples into one wasm exporting a superset of the names, and every example's submit still finds its own with nobody renumbering anything.

the worker's program is Zig too

A worker's entry point must be JavaScript — the browser will only start one from a script, and something has to call WebAssembly.Instance before any wasm exists. So the file cannot be eliminated. It does not have to be written. src/jobs_worker.zig is a Zig program compiled to JavaScript by the same build-obj -ofmt=cc2js path that produces the browser host:

export fn start() void {
    globalScope().setProperty("onmessage", asJsFunction(&handleMessageFromHost));
}

fn instantiateKernelWasm(kernel_wasm_bytes: JsValue) void {
    const web_assembly: JsValue = globalScope().getProperty("WebAssembly");

    const compiled: JsValue = web_assembly.getProperty("Module").construct(.{kernel_wasm_bytes});
    const instance: JsValue = web_assembly.getProperty("Instance")
        .construct(.{ compiled, newEmptyJsObject() });   // {} is a COMPLETE import object

    kernel_wasm_exports_handle = instance.getProperty("exports").handle;
    ...
}

JsValue wraps a handle into a table the runtime keeps on the JavaScript side: read v.getProperty("data") as v.data, and v.callMethod("f", .{x}) as v.f(x). It is JavaScript's semantics in Zig's syntax, with Zig's type checker on top.

Which buys two things a JavaScript file could not. Every protocol name lives once, in src/jobs_abi.zig, read by all three sides that never link against one another: the kernel wasm exports them, the worker calls them, the build tool verifies them. And the instantiation above blocksModule and Instance are constructors, not promises — so a job cannot arrive mid-compile and re-enter the handler with the exports unset. In Zig there is no await to reach for by mistake.

fanning out: one job per tile

The pool has always been parallel, but a single job can only occupy one worker. Group submits a batch and hands results back as they land — out of order, because whichever worker finishes first gets there first:

const Group = kernels.registry.Group(kernels.traceTile);

// One job per horizontal band of the image.
var tiles = try Group.submitAll(gpa, headers, scene_bytes);

// ...then, once a frame:
while (try tiles.next(tile_buf)) |landed| {
    blitTile(s, landed.index, landed.bytes);   // `index` is which band — they arrive shuffled
}

rt-workers is the demo: a path tracer, one job per band, tiles popping in as they finish. Because Group.deinit cancels whatever is still in flight, the camera can drive the render — drag it and a cheap preview starts; let go and a full-quality pass replaces it, stale tiles from the drag discarded rather than drawn.

what it is, and what it is not

A latency device, not a throughput one. It gives the frame back. It will not make a phone four times faster: the pool is half of hardwareConcurrency capped at four, and eight busy workers measured about 3.4× before thermal throttling began eating the GPU's budget instead. For raw parallel arithmetic the answer is kompute and the GPU.

Where workers are unavailable — a sandboxed iframe forbids blob-URL workers, which is what an embedded preview usually is — the kernel runs inline on the main thread. The app is none the wiser; it just hitches, exactly as it would have if it had never used jobs. Failures arrive in one place too: inline or remote, the error surfaces at poll carrying the kernel's own error name ("ShortPayload", not "KernelFailed"), so a phone with no console to read still tells you what broke.

four-ways makes the point whole: one seven-line Zig function — a Mandelbrot escape iteration — running on four machines at once, in four panels. Evaluated at comptime into a baked table; on the CPU on the main thread; on a worker, through the jobs system; and on the GPU as a compute shader translated Zig → SPIR-V → WGSL. Same source, four backends, no duplication.

3D and assets

The 3D path covers retained meshes, glTF loading (pure-Zig parser, textured and skinned variants), Lambert and PBR materials, instancing, billboards, a skybox, render-to-texture, first-person and orbit cameras, and wireframe. A pass is bracketed by z.beginMode3D(f.gl, cam) / endMode3D; inside it the immediate helpers are raylib's (drawCube, drawGrid, drawSphere, drawLine3D, drawBillboard) plus the retained drawModel family. Two storage-buffer renderers (DrawPoints, FluidDiscs) draw compute-produced data straight from GPU buffers.

// minimal retained-mesh draw
s.cube = z.genMeshCube(1, 1, 1);
s.model = z.loadModelFromMesh(s.cube);
// ... per frame, inside begin/endMode3D:
z.drawModel(f.gl, s.model, .{ 0, 0, 0 }, 1.0, zm.Color.white);
z.drawModelWires(f.gl, s.model, .{ 0, 0, 0 }, 1.0, zm.Color.darkgray);

Assets ride inside the wasm with @embedFile: the build registers named imports (a font as roboto_mono_ttf, a model as a .glb blob, an engine shader's generated WGSL by its file name) and the example embeds them at compile time, which is why a standalone HTML needs no network. Each asset kind has a loader called once in init: fonts through a pure-Zig TTF rasterizer (z.loadFont), images through loadImageFromMemory + loadTextureFromImage, models through loadGltf or the procedural genMesh* generators.

The Dear ImGui port

src/ui.zig is an implementation, not a binding: windows, the ID stack, layout, hit-testing, widgets (buttons, sliders, checkboxes, combos, trees, tables, tabs, color pickers, text fields with editing), drag-and-drop, docking, tooltips, menus, and window-state persistence, drawing through the same renderer trait as everything else. The model is immediate: every frame the whole UI is rebuilt between s.ui_host.begin(f) and s.ui_host.render(f). A button is a function returning true on the frame it was clicked; a slider takes a pointer to your float. The durable state lives in your State struct — the UI is just this frame's view of it. Identity comes from the label hashed against the ID stack, with the label###id escape for changing captions.

if (u.window("inspector", .{ .initial_size = .{ 360, 520 } })) |w| {
    defer w.close();
    if (u.beginTabBar("tabs", .{})) {
        defer u.endTabBar();
        if (u.beginTabItem("entity", null, .{})) {
            defer u.endTabItem();
            _ = u.inputText("name", &s.name, &s.name_len, .{});
            _ = u.combo("kind", &s.kind, &kinds, .{});       // packed labels
            _ = u.colorEdit("tint", &s.tint, .{});           // [4]f32 RGBA
            if (u.treeNode("transform", .{})) {
                defer u.treePop();
                _ = u.slider("x", &s.pos[0], .{ .min = -8, .max = 8 });
                _ = u.slider("y", &s.pos[1], .{ .min = -8, .max = 8 });
            }
        }
    }
}

Tables are full ImGui tables — per-column sizing, borders, headers, click-to-sort with per-column flags — and the clipper virtualizes long lists to O(visible) (the 100,000-row walkthrough below). Windows dock into splittable nodes and the layout serializes. Touch is first-class: multitouch, gestures, and a set of phone-oriented example UIs exist specifically to exercise hit-target sizes and drag behavior on small screens, which is also why every graphics demo's control panel is this UI rather than key bindings.

Plotting: ImPlot and ImPlot3D

2D (src/plot.zig + src/plot_ui.zig). The core is a deliberate departure from ImPlot, which welds rendering to a single draw list: here the Plot / Axis / Ticker core depends only on zm and emits primitives to a duck-typed sink — one sink renders to SVG for host snapshot tests, another wraps the UI draw list. Data coordinates are f64, and the axis transform encodes direction and inversion in its pixel endpoints, so inverted and log axes fall out for free. The stateful shell takes a slice of Series literals (seventeen kinds, from line and heatmap to candlestick and pie) plus a persistent PlotState for zoom/pan and legend toggles:

const series = [_]z.plot_ui.Series{
    .{ .kind = .line,    .ys = &ys, .spec = .{ .thickness = 2 } }, // implicit x = index
    .{ .kind = .scatter, .xs = &xs2, .ys = &ys2 },
    .{ .kind = .bars,    .ys = &ys, .bars = .{ .width = 0.5 } },
};
z.plot_ui.show(u, &s.plot, .{ 520, 320 }, &series, .{
    .title = "signals", .show_legend = true,
});

3D (src/plot3d.zig). A closer port of ImPlot3D threaded through an explicit context with zero module globals. It projects on the CPU and draws a depth-sorted triangle batch into the same 2D draw list; the box is drag-to-orbit and the legend toggles series. Plot types cover plotLine / plotScatter / plotSurface / plotMesh / plotTriangle / plotQuad / plotText / plotImage, generic over f32/f64 samples, with colormaps and the style stack shared with the 2D side.

The physics engines

Two of them, both pure Zig, both with no GPU dependency — so the whole simulation runs and is unit-tested natively, and only drawing it needs a browser.

3D: src/zimrphysics.zig, a single-file rigid-body engine ported from Jolt. Convex primitives (sphere, box, capsule, cylinder, tapered capsule, convex hulls) collide through GJK and EPA; compounds, triangle meshes with a BVH, heightfields, and an infinite plane cover static geometry. A frame is broadphase → narrow phase → an island-based sequential-impulse solver, advanced by step(&world, scratch, dt). On top of contacts sits a full constraint set — point, fixed, distance, hinge, slider, swing-twist (the ragdoll joint), gear, rack-and-pinion, pulley, six-DOF, and path — with motors and limits on the hinges and sliders. zimrphysics-demo is the playground: stacks, ragdolls, vehicles-of-constraints, profiler attached.

2D: src/zimrphysics2d.zig, a faithful port of Box2D v3.1 — Erin Catto's data-oriented rewrite, with the TGS Soft solver. The algorithm is reproduced term for term, including the single-threaded performance architecture that makes v3 fast: graph coloring, and a 4-wide SIMD contact solver. Soft contacts, substepping, speculative contacts, restitution, warm starting and stored impulses all behave as they do upstream. Five shapes (circle, capsule, segment, polygon, chain segment) and all seven joints (distance, motor, prismatic, revolute, weld, wheel, filter). What is not ported is the task scheduler and the cache machinery — those are replaced by zimr's own storage model, ECS-backed through entities.zig, with no globals, no vtables and no OOP.

zimrphysics2d-demo carries Box2D's own samples: 137 scenes across stacking, joints, character movement, continuous collision, determinism, events, geometry, robustness, and the upstream benchmark set. Scenes never write rendering code — a shared debug renderer draws every shape, joint and contact the engine emits, so a scene is purely a description of a world.

The profiler, entities, and audio

Profiler. src/profiler.zig + profiler_ui.zig are an in-engine frame profiler with zone macros, ring-buffered history, a flame view, a sortable per-call-site statistics table, and a find-zone histogram. Two features make it usable on a phone: auto-freeze-on-spike captures a transient hitch without racing to tap a button, and GPU timing (where timestamp-query is supported) wraps each render pass in timestamp writes so the panel can show CPU versus GPU time — the answer to whether a slow frame is the engine's fault or the driver's.

Entities. src/entities.zig is a single-module archetype store: spawn takes component values, and a "system" is a plain function forEach runs over every matching entity. The boids example is three passes over one store; the solar system parents bodies so a moon inherits its planet's transform. Audio. src/sound.zig drives WebAudio from the Zig host: play decoded clips or stream synthesized samples (the drum machine and live synth make sound with no JavaScript of their own).

Under the hood: host, WebGPU layer, rasterizer

The entire browser side is one Zig file, src/bridge.zig (~6,200 lines): page boot, the WebGPU async setup, the imported GPU functions, DOM glue, input, and WebAudio. It is turned into JavaScript by tools/c2js.zig, a C→JS transpiler that targets exactly the narrow SSA-like C Zig's C backend emits (bridge.zig → bridge.c → zimr.js). The JavaScript is meant to run, not to read; a JS→C source map exists for debugging, and zig build c2js-diff checks the output against a reference.

Wasm-side GPU handles are integers into per-type tables held by the host. Pipeline and bind-group descriptors are built and serialized in Zig (src/gpu.zig) and decoded on the JavaScript side, so descriptor construction is unit-testable without a browser; on native targets every host call compiles to a no-op, which is what lets the descriptor encoder, the CPU compute path, and the physics run under zig build test with no GPU anywhere. src/raster.zig is the software rasterizer behind the CPU halves of the side-by-side demos and the pixel-verify targets: it implements the same renderer trait as the WebGPU backend and dispatches real shaderMain functions per pixel, at runtime or at comptime.

One war story from this layer is worth recording because it shaped an engine rule. WebGPU executes every queue.writeBuffer before the frame's encoder submit — so writing one uniform buffer twice in a frame means only the last value reaches any pass segment, including segments recorded before the second write. So per-segment uniforms live in small ring buffers — the 2D projection and the batch vertices both ride rings — and the headless smoke harness fails any example that writes the same (buffer, offset) twice in one frame. The whole class is a red build rather than a device-only glitch.

Source layout

area files
umbrella module examples import src/zimr.zig (as @import("zimr"))
math + shader vocabulary src/zimrmath.zig (as @import("zm"))
app loop, frame, raylib surface src/wgpu_app.zig
engine shaders (Zig, one pair per stage) src/shaders/*.zig
2D batching src/renderer_2d.zig + src/gpu_iface.zig
3D src/draw3d.zig
Zig→WGSL transpiler src/spv2wgsl.zig
descriptor serialization + pipeline cache src/gpu.zig
GPU compute src/kompute.zig + src/compute_host.zig
physics src/zimrphysics.zig
profiler src/profiler.zig + src/profiler_ui.zig
browser host (Zig) src/bridge.zig; transpiler tools/c2js.zig
Dear ImGui / plots src/ui.zig, src/plot*.zig
software rasterizer src/raster.zig, src/raster_shader.zig
style linter tools/lint_zimr.zig
examples one directory each under examples/

Building

Every example has two build steps: <name> builds a wasm plus an HTML page plus the JavaScript host into zig-out/, and <name>-standalone inlines all of that into one self-contained .html file — the fastest way to try anything, including on a phone. Step names are kebab-case; the same name in -Dfocus is the snake_case directory name. There is also a launcher example that bundles the flagship demos behind a fullscreen menu in a single standalone HTML.

# build one example (wasm + page + JS host) under zig-out/
zig build shapes-showcase

# the same example as one self-contained .html file
zig build shader-effects-standalone

# release build: ReleaseSmall with zimr asserts + the profiler kept on
zig build fluid-gpu-standalone -Dmode=release
# ship build: ReleaseSmall, asserts off, profiler stripped
zig build fluid-gpu-standalone -Dmode=ship

# native pixel-verify targets (render the demo's look on the CPU, no browser)
zig build cel-shading-verify
zig build shader-effects-verify
zig build shadowmap-sw-verify

Project tooling and gates:

# unit tests + example typecheck (~1,900 tests); filter by name or glob
zig build test
zig build test -Dfocus=ui_*

# transpiler gates: SPIR-V->WGSL fixture corpus, and Zig->JS vs a reference
zig build corpus
zig build c2js-diff

# headless wasm smoke of one example (boots it, runs frames, profiles the
# host calls, and fails on same-buffer-same-offset double writes in a frame)
zig build smoke-test -Dfocus=shader_effects

# style gate (zig fmt --check + the AST linter), module graph, docs, server
zig build lint-check
zig build dag-check
zig build docs
zig build serve

The inner loop stays fast because most checks need no browser: a single example typechecks in well under a second, and the CPU pixel-verify targets answer "does it look right" before any device round-trip. Headless smoke cannot see GPU validation errors, so anything visual is finally confirmed by opening a -standalone — on a phone when touch or the tile-based-GPU paths are involved, since those are exactly what a desktop never exercises. -Dmode= selects debug / release / ship as in the block above; -Dfocus= filters build, typecheck, and smoke.

More walkthroughs: the 2D and UI side

The graphics demos above are one slice; these five show the 2D, image, and UI range. Each is a single self-contained file in examples/<name>/, small enough to read in a sitting.

fractal-tree — recursion into the 2D batch. A recursive function draws a branch with drawLineEx, then calls itself twice at a smaller length and thickness; a per-depth sinusoidal term added to each branch angle makes a sway ripple travel up the tree. There is no scene graph — the call stack is the tree.

procgen-noise — CPU image generators and in-place upload. Three noise fields are made on the processor (genImageWhiteNoise / PerlinNoise / Cellular), uploaded once, and shown as panels. A fourth panel scrolls: it regenerates a Perlin field at a moving offset each frame and pushes it through updateTexture, which rewrites the existing GPU texture instead of allocating a new one — the difference between a steady frame time and per-frame texture churn.

recursive-hud — render once, composite many. An animated HUD is rendered a single time into a render texture, then drawn back as a stack of concentric, ever smaller and dimmer copies — a screen-inside-screen tunnel. Because the scene is rasterized exactly once and only re-sampled, it sidesteps the per-frame swapchain-pass switch a dynamic offscreen target would need: the right pattern whenever the offscreen content is the same across all its uses in a frame.

text-on-texture — render-to-texture meets the 3D pipeline. A 2D "sign" is drawn into an offscreen texture, then mapped onto a rotating cube with drawCubeTexture in the 3D pass. It is the smallest program that touches both subsystems at once, and a good template for in-world labels, mirrors, or security-camera views.

ui-clipper — a 100,000-row list at O(visible). A naive list submits one widget per row every frame, even off-screen. The clipper submits only the visible window (about thirty rows) and advances the layout cursor over the rest arithmetically, so the scrollbar still reflects the full range; row text is computed from the index, so the rows cost no per-row memory. A toggle turns the clipper off so you can watch the frame time climb.

Examples

There are 265 examples, each a single file at examples/<name>/<name>.zig with no shared state, so any one reads top to bottom as a complete program. Categories and representative steps:

category representative steps
graphics flagships (this page's tour) shadowmap-sw, deferred-render, cel-shading, fog-rendering, shader-effects, helmet-sw, launcher
core 2D / raylib ports hello-world, shapes-showcase, life, camera2d, trails, easings-testbed
procedural / generative fractal-tree, hilbert-curve, procgen-noise, recursive-hud
images / textures image-editor, text-on-texture, png-demo
input & touch input-multitouch, gestures-testbed, touch-paint
imgui ui-full-showcase, ui-tables-demo, ui-clipper, ui-kanban-board, ui-dock-persistence, imgui-phone-demo
plotting (implot / implot3d) plot-demo, ui-plot, plot3d-demo, native-plot-png
zig shaders julia, mandel-julia, rt-shader, kaleidoscope, comptime-julia
custom pipelines pipeline-basic, pipeline-instancing, pipeline-postprocess, pipeline-bloom, pipeline-msaa
CPU | GPU side-by-side + native proofs mandel-sidebyside, cube-sidebyside, sw-mandelbrot, sw-engine-shader
3D cube3d, models3d, gltf-textured, damaged-helmet, skinned-mesh, instancing, skybox, billboards
gpu compute compute-particles, fluid-gpu, fluid-sort
physics / ecs / audio / cpu sims zimrphysics-demo, ecs-boids, composer-drum, sph-fluid-2d, double-pendulum, raytracer

Status

zimr is exploratory. It exists to find out how far a single-language Zig stack — engine, shaders, compute kernels, the SPIR-V→WGSL transpiler, the C→JS host transpiler, and the UI — can go on WebGPU, and to record what breaks along the way. Read the source and the example list before deciding whether any of it is useful to you.