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.
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.
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.
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.
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.
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 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.
| 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.