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, the
peer-to-peer networking, 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.
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.
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.
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 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:
WgpuGl — the immediate-mode
GPU sink (f.gl). Primitives accumulate as
vertices between beginDrawing and
endDrawing and flush through one WebGPU render pass,
so adding draw calls costs vertices, not passes.SwAdapter (z.SwGl) — an
immediate-mode CPU software rasterizer. The same scene
drawn on the CPU — which is how an example renders one frame
on GPU and CPU at once (the engine's northstar: a single SPH fluid
identical on either, toggled at runtime).DrawList — a retained,
command-recording sink. This is what the Dear ImGui / UI layer
draws into (getWindowDrawList()); the recorded
commands replay into the GPU pass, so widgets and hand-written
drawing share the exact same primitive vocabulary.Canvas — a headless retained CPU
sink that renders straight to a PNG, for offline image generation
and tests where no GPU is present.// 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 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.
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 |
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.
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.
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.
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.
zimrnum is a numerics library in the same Zig, built on
zimrmath and compiling to SPIR-V like any other kernel source. It
covers tensors with strided views, linear algebra, statistics, dataframes,
automatic differentiation, neural-network layers, and reinforcement learning
— around 350 public declarations.
A tensor is a shape, a stride list and a slice, so a row, a column or a transpose is a view: no copy, and writes go back through to the original. Every operation reads through the strides, so a column taken out of a matrix behaves like a tensor in its own right.
const x = try zn.linspace(f64, gpa, 0, 1, 32, .include);
const y = try zn.zeros(f64, gpa, &.{ 32, 1 });
var graph: zn.Graph(f64) = .init(gpa); // a tape, rebuilt each step
const layer = try zn.Dense(f64).init(gpa, rng, 1, 1, .xavier);
const out = try layer.attach(&graph, try graph.constant(inputs));
const loss = try graph.mseLoss(out.out, try graph.constant(y));
try graph.backward(loss);
const dw = try graph.gradOf(out.weight); // gradients for anything reachable
The forward pass runs eagerly and the backward pass is recorded, so a value is
available the moment it is computed and the tape holds only what
backward needs. A tape is an arena of nodes; discarding it between
steps is one pointer reset.
zimrnum compiles to SPIR-V, so a compute kernel can call the same
function the CPU does rather than restating it. The
zimrnum_train example trains an XOR network entirely on device
— eight dispatches per step, with the weights never leaving the GPU and
only the loss read back — and runs the identical network through
Graph on the CPU from the same seed. The two loss curves agree to
about 1e-7 for the first forty steps, which is a claim about forward, backward
and update composing that no per-operation check can make.
Around a hundred operations have GPU kernels, each checked against its CPU twin on
the same input with a per-operation tolerance. Operations whose arithmetic cannot
drift — comparisons, counts, all, any —
carry a bar of exactly zero; a product carries a real one, because multiplication
is associative in real arithmetic and not in floating point.
PPO, DQN, TD3, SAC and the modern off-policy extensions. The critic estimates arrive as a slice rather than a pair, which is what makes the variants one call with a different list: twin critics pass two networks, DroQ passes several dropout passes of one network, REDQ passes a freshly drawn subset of an ensemble, and CrossQ passes a single value and no target network at all.
// GAE: `terminal` kills the bootstrap, `episode_end` kills the trace
try zn.gae(steps, 0.99, 0.95, &advantages, &returns);
// one target function, every variant
const y = try zn.sacTarget(f64, transition, next_estimates, next_log_prob,
gamma, alpha, .minimum);
Distributions are types rather than loose functions, so
sample, logProb and entropy share one
normalisation and cannot disagree. logProb goes through a stable
log-softmax, so a logit eight hundred below the maximum scores as
−800 rather than negative infinity — which is the common case for a
confident policy, not an edge case.
A Series is a named column with an optional validity mask, where
null means every value is present and costs nothing. Missing is not
NaN: an integer column has no NaN, and a gap that silently reads as zero is how
an average of five values measured three times comes out plausible and wrong.
The mask carries through everything. A shifted column's vacated row is absent rather than zero, because a zero there reads as "no change" instead of "no data"; a rolling mean's first partial window is absent rather than an average of fewer samples.
The zimrnum tutorial opens
with a complete program that fits a line to noisy data, and is organised as
twenty-one sections ending in Recipes, Gotchas and a reference table of every
public declaration. Three things in it are checked by the build rather than by
hand: the reference table against the library's public surface, the opening
program against examples/zimrnum_hello.zig byte for byte, and the
argument count of every zn.foo(...) in every example against the
real signature.
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.
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.
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=c → c2js 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 blocks —
Module 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.
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.
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.
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.
Beyond loading glTF, zimr generates meshes on the CPU. The
heart of it is a parametric spine: any smooth surface
is a callback mapping the unit square (u, v) to a
point, fed through parametricMesh, which
triangulates the grid, converts to Y-up, and welds seam and
pole normals so shading stays smooth. Every built-in curved
primitive — sphere, hemisphere, cylinder, cone, torus,
trefoil knot, plane, klein bottle — is a few-line
callback on that core, so authoring your own surface is one
function call. Alongside them are a disk, the four platonic
solids (flat-shaded), a geodesic icosphere (a
subdivided icosahedron — uniform triangles, no pinched
poles), and a procedural rock (a noise-displaced
icosphere).
// built-in primitives - (gpa, dims..., slices, stacks)
const sphere = try z.genMeshSphere(gpa, 0.75, 32, 24);
const torus = try z.genMeshTorus(gpa, 0.6, 0.25, 36, 18);
const ico = try z.genMeshIcosphere(gpa, 1.0, 4); // geodesic sphere
const rock = try z.genMeshRock(gpa, 1.0, 4, 7); // seed 7 -> a unique rock
const solid = try z.genMeshDodecahedron(gpa);
// a custom surface: supply your own uv -> xyz (par_shapes' Z-up convention)
fn rippleUv(u: f32, v: f32, ctx: ?*const anyopaque) [3]f32 {
_ = ctx;
const x = (u - 0.5) * 4.0;
const y = (v - 0.5) * 4.0;
return .{ x, y, 0.4 * @sin(6.0 * @sqrt(x * x + y * y)) };
}
const wavy = try z.parametricMesh(gpa, rippleUv, null, 80, 80);
A mesh-ops toolkit edits and composes any of these (or
a glTF mesh) on the CPU, so shapes build up from parts:
meshMerge fuses two meshes,
meshTranslate / meshRotate /
meshScale place them, meshInvert
flips a shell inside-out, and meshUnweld /
meshWeld / meshRemoveDegenerate
handle faceting and cleanup.
// compose an arrow: a cylinder shaft + a cone head, both spun onto +X
var shaft = try z.genMeshCylinder(gpa, 0.07, 1.7, 18, 2);
z.meshRotate(&shaft, 0, 0, 1, -zm.pi * 0.5); // +Y -> +X
var head = try z.genMeshCone(gpa, 0.18, 0.6, 18, 2);
z.meshRotate(&head, 0, 0, 1, -zm.pi * 0.5);
z.meshTranslate(&head, 1.7, 0, 0); // onto the shaft's tip
const arrow = try z.meshMerge(gpa, shaft, head); // fuse into one mesh
z.unloadMesh(gpa, shaft); // merge copies, free the parts
z.unloadMesh(gpa, head);
const model = try z.loadModelFromMesh(f.gl, gpa, arrow);
These are a pure-Zig reimplementation inspired by Philip
Rideout's par_shapes.h (see the LICENSE file for
attribution). The demos shapes_gallery,
platonic_solids, icosphere_demo,
rock_demo, and mesh_builder show them
off, and there's a full walkthrough in
src/notes/tutorials/shapes-tutorial.md.
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.
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.
Three of them, all pure Zig, none with a GPU dependency — so the whole simulation runs and is unit-tested natively, and only drawing it needs a browser. Two are general rigid-body engines in maximal coordinates; the third is an articulated-robot simulator in generalized coordinates.
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.
Robots: src/robot.zig is a third engine, and a
different kind. Where the other two carry each body's full pose and
enforce joints with a solver, this one simulates in
generalized coordinates — a four-hinge arm is four
numbers, and the joints cannot be violated because the freedom to
violate them was never represented. It follows MuJoCo's design:
composite-rigid-body for the mass matrix, an
LTDL factorization that exploits the tree's
sparsity, recursive Newton-Euler for Coriolis and gravity, and a
constraint solver (PGS and Newton) that poses contact, friction and
joint limits as a complementarity problem on accelerations. Hinge,
slide, ball and free joints; closed loops through connect, weld and
coupling equalities; sensors; MJCF import, so the MuJoCo Menagerie's
robots load and are checked against MuJoCo's own kinematics. On top
sits robot_mpc.zig: iLQR trajectory optimization with a
box-constrained control solve, used as receding-horizon MPC. Contacts
come from zimrphysics through a bridge, so collision
detection is written once. The walkthrough is
Robot dynamics, from the ground up, a course
that derives the whole thing from
M(q)v̇ + c = τ + JTf and ends with
three worked examples.
How the dynamics are checked. Two kinds of evidence, kept apart because they are not equally strong.
| checked against | how | |
|---|---|---|
| forward kinematics | MuJoCo's xpos, every body, to 1e-4 | generated fixture |
mass matrix, cinert, cdof | MuJoCo's mj_fullM and friends | generated fixture |
| bias forces, accelerations | MuJoCo's qfrc_bias, qacc | generated fixture |
| Jacobians | MuJoCo's mj_jacBodyCom | generated fixture |
| constraint rows and softness | MuJoCo's efc_J, efc_pos, efc_aref | generated fixture |
| constraint forces | m·g, and MuJoCo's resting depth | closed form |
| actuators | every gain, gear and transmission | closed form |
| tendons | length, rate, and the transpose that spreads the force | closed form |
| sensors | MuJoCo's readings for the same model and state | fixture |
| integrators | an rk4 reference at dt = 1/20000 | self-consistency |
The generated fixtures are real MuJoCo output:
scripts/robot_oracle.py runs mujoco 3.11 over eleven models
and writes src/tests/fixtures/robot/reference.zig. The
closed forms are quantities derivable with a pen, so
they do not depend on MuJoCo being right; they are single states rather
than a swept corpus.
Speed is a build step rather than a claim.
zig build robot-bench runs four models for 200 000
steps each and prints ns/step; one case fails the build if the Go1 is
not actually holding the pose it is named for. On a 1-core box:
two-link arm nv 2 292 ns/step
KUKA iiwa nv 7 1664 ns/step
KUKA, limits nv 7 2376 ns/step
Go1 standing nv 18 12313 ns/step (pgs) 22273 ns/step (newton)
scripts/robot_bench_mujoco.py is the other half — the
same models under mj_step, so the comparison runs on your
machine rather than on a number in a readme. Newton is compared against
Newton: MuJoCo's default solver is Newton, and quoting ours against
anything else flatters us for a reason unrelated to the code.
Two constraint solvers, because the right one depends on
the scene. PGS is around twice as fast where contacts are few and weakly
coupled, and does not reach tolerance on a standing quadruped — it
stops on its stall detector near tolerance rather than at
it. Newton converges there in two iterations, and solves a six-box stack
PGS cannot in a hundred. PGS is the default because "close, and fast"
suits most scenes; solver.algorithm = .newton switches.
src/codecs.zig reads BVH and
FBX, so a skeleton and its animation load from
whatever tool produced them. examples/geno_dance plays a
skinned character and drives a MuJoCo robot from the same clip.
Using it. Three calls, in
src/robot.zig:
const scale = robot.captureScale(&model, frame);
const n = robot.buildPointSamples(&model, inputs, &samples);
robot.solvePointCloud(&model, &data, samples[0..n], opts);
captureScale converts the capture's units to the robot's
metres. buildPointSamples is called once per
robot–capture pairing and returns the sample set.
solvePointCloud runs per frame and leaves the pose in
data. A MatchRow table states which capture
joint drives which robot body; it accepts alternative joint names and
ignores exporter prefixes such as mixamorig:, so one table
serves several rigs.
How it works. Retargeting is one optimization rather
than a pipeline of stages. Each frame minimizes
E(q) = Σ ‖Tb(q)·sbk −
&375;bk‖² + λlim
Σ barrier(qj) + λsmo
‖q ⊖ qprev‖² over the whole
configuration by Gauss–Newton: sample points on the robot's bodies
matched to where the capture puts them, joint limits as a soft barrier,
and a term holding the pose near the previous frame.
Sample points rather than joint rotations, because one point on a body fixes its position, two fix its direction and three fix its twist. Aiming a bone, choosing a bend plane, resolving a limb's swivel and converting a rotation between differently-built skeletons are then all the same operation. A body carrying too few samples is under-constrained in a way the sample count makes visible.
Two choices matter for quality. Joint limits enter as a
soft barrier, so the solve slides along a limit instead
of sticking against a clamp and jumping between the answers on either
side. And limb targets take their direction from the capture and
their length from the robot, which keeps every target reachable
when the two skeletons differ in proportion; the
position_pull option blends between that and the capture's
own joint positions.
Correspondences — twist offsets, foot heel and toe, leaf orientation — are measured once from the rest pose and reused per frame. A sample whose target misses its own point at rest is dropped, so a mismatched joint pair is excluded rather than fought.
examples/geno_dance/humanoid_flex2.xml is the stock
humanoid adapted to one capture: arm lengths and spine proportions
matched, one shoulder axis added, a toe bone added.
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).
The networking targets the small-scale case — invite a few friends into a shared session — and it is peer-to-peer: game data travels browser-to-browser over WebRTC data channels, and a server is used only to introduce peers, never to relay the game itself. Like the rest of the engine both ends are pure Zig, and the parts that don't need a live connection — the signaling server, the session logic, the room-name generator — build and run outside the browser.
The signaling server:
tools/signal_server.zig is a from-scratch
WebSocket server on Zig 0.17's new std.Io
— its own handshake (SHA-1 + base64 accept), frame parser,
and a room registry, with one async mutex serializing writes.
It is a native Zig program, like the dev server: peers
JOIN a room by name and it relays the opaque
SDP/ICE handshake between them (WELCOME /
JOINED / LEFT / FROM),
without ever parsing the payloads. A deploy twin,
tools/render_server.zig, folds the demo pages in
and builds to a single ~6 MB static binary.
The client, in the host:
src/web.zig exposes ws and
rtc — thin, poll-based wrappers over the
browser's WebSocket and RTCPeerConnection. The
JavaScript that actually touches those APIs isn't hand-written:
it is emitted from src/bridge.zig through the same
Zig→C→JS transpiler as the rest of the host, and its
event handlers are pure closures that only push into a queue the
wasm drains each frame — nothing ever calls back into
wasm. On native builds both wrappers compile to no-ops, so an
example links and runs headless with the networking simply
inert.
The session layer:
src/net.zig is the part a game actually
touches. connect(url, room), then each frame drain
poll() for events — a peer joined, a peer
left, a message arrived — and broadcast() /
sendTo() to talk. It runs a full mesh (one
connection per peer) and drives the whole offer/answer/ICE dance
for every peer itself, choosing which side offers from the peer
ids so two ends never collide. Rooms get human names like
brave-otter-42 so one player can host and read the
name out loud; the generator is pure and unit-tested for spread.
Two channels open per peer — an unreliable, unordered one
for fire-and-forget state like a cursor position, and a reliable
one for events you can't afford to drop.
examples/net_cursors is the demo on top: a shared
canvas where everyone's mouse cursor moves live and a click
sends a ripple every peer sees, in about 380 lines over that
session API. The honest edges: connectivity uses Google's public
STUN with no TURN relay yet, so a small fraction of very
restrictive networks won't connect; and while the handshake and
the session logic are exercised headlessly, two real browsers
meeting over the internet is the test that matters.
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.
Six standalone pages, each building one subsystem from first principles rather than documenting its API. They ship alongside this page.
M(q)v̇ + c = τ + JTf through
kinematics, the mass matrix, contact and trajectory
optimization, ending with three worked examples. Its code blocks
are checked against src/robot.zig by
zig build doc-sync, so they cannot quote drifted
source.
src/zimrnum.zig by a test in that file, so it cannot
name a declaration the library no longer has.
| 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/ |
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.
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.
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
|
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.