zimr · shader authoring · internal note

Two ways to
write a shader.
One is going away.

Every zimr shader is Zig that compiles to SPIR-V and transpiles to WGSL. Until now there were two ways to write one: the direct way, where you hand-wire every binding, and the typed IoT way, where a schema does it for you. This is the case for collapsing to one.

shaderMain.zig → build-obj · spirv32 → zspv rewrite → spv2wgsl → @embedFile(.wgsl)

01 The two shapes

Same shader, two source shapes

Here is the instanced-points vertex shader written both ways. It reads a particle position from a storage buffer indexed by the instance, expands a quad, and colors by height. Read the two side by side — the logic is identical; only the boundary differs.

Old · directpoints_vs.zig
const u = @extern(*addrspace(.uniform) const Uniforms, .{
    .name = "u",
    .decoration = .{ .descriptor = .{
        .set = 0, .binding = 0 } },
});
const positions = storageBuffer(Vec2,
    "positions", 0, 1);
const col_out = @extern(*addrspace(.output) Vec, .{
    .name = "col", .decoration = .{ .location = 0 },
});

export fn entry() callconv(.spirv_vertex) void {
    const vi: u32 = spirv.vertex_index;
    const ii: u32 = spirv.instance_index;
    const p = ssboLoad(Vec2, positions, ii);
    const uu = u.*;
    // ... quad expand, color ...
    spirv.position_out.* = .{ x, y, 0, 1 };
    col_out.* = color;
}
New · IoTpoints_vs.zig + _io.zig
// points_vs_io.zig — the schema
pub const Ubo = extern struct { /* … */ };
pub const Storage = struct {
    positions: shader.StorageBuf(Vec2, .read),
};
pub const Builtins = struct {
    vertex_index:   shader.Builtin(.vertex_index),
    instance_index: shader.Builtin(.instance_index),
};
pub const Outputs = common.Interp;

// points_vs.zig — the body
pub fn shaderMain(io_in: Io) Out {
    var out: Out = undefined;
    const vi = io_in.vertex_index();
    const ii = io_in.instance_index();
    const p = io_in.positions(ii);
    const uu = io_in.u;
    // ... quad expand, color ...
    out.position = .{ x, y, 0, 1 };
    out.col = color;
    return out;
}
▲ risk

The four red integers on the left are descriptor set/binding numbers, typed by hand. Get one wrong and nothing errors at compile time — the pipeline is simply invalid at creation, or worse, silently reads the wrong buffer on the GPU. This is the exact failure class that cost real debugging time on the decals work.

On the right there are no binding integers at all. The schema names each resource once; the codegen assigns groups and bindings by a fixed, documented rule.

02 The signature move

The schema field is the binding

In the IoT world you never write a @group(N) @binding(M). You declare a field in a schema section, and the codegen derives the WGSL decoration from which section it's in and its position. Uniforms land at binding 0; storage buffers follow in the same group; samplers get their own group. Here is the actual derivation for the points shader:

schema → generated WGSL

Ubo  →  VS uniform block
╌╌►
@group(0) @binding(0) var<uniform> u
Storage.positions  →  StorageBuf(.read)
╌╌►
@group(0) @binding(1) var<storage, read>
Builtins.vertex_index
╌╌►
@builtin(vertex_index) vertex_index
Builtins.instance_index
╌╌►
@builtin(instance_index) instance_index

Verified byte-for-byte: this is the exact WGSL the direct shader emitted — so the host bind-group code, which hand-wires the same layout, still works unchanged. The port is a drop-in.

◆ why

Because the same rule feeds both the WGSL decorations and the host-side bind-group layout, the two can never disagree. In the direct pattern they're two hand-written lists that have to be kept in sync manually — and when they drift, the GPU rejects the pipeline.

03 Two more wins

Sampling, and the varying contract

Beyond bindings, the typed boundary changes two everyday things: how you sample a texture, and how a vertex shader's outputs stay matched to a fragment shader's inputs.

Old · direct sample*_fs.zig
// declare the sampler by hand…
extern const decal_sampler2d: u32
    addrspace(.constant);
// …bind it by hand at the top of main…
binding(&decal_sampler2d, 2, 0);
// …then call the raw op:
const t = zsample2d(decal_sampler2d, uv);
New · typed samplelambert_fs_io.zig + _fs.zig
// declare it once, named, in the schema:
pub const Samplers = struct {
    texture0: shader.Sampler2D(.albedo, .{}),
};
// call it as a method — no binding, no raw op:
const tex = io_in.texture0(io_in.frag_tex_coord);

The varyings between stages are the other quiet hazard. In the direct pattern the VS @extern(.output) list and the FS @extern(.input) list are written separately and must match by location index. In IoT they alias one shared struct, so they match by construction:

New · one source of truthpoints_common_io.zig
// points_common_io.zig — declared ONCE
pub const Interp = struct { col: Vec };

// points_vs_io.zig             points_fs_io.zig
pub const Outputs = common.Interp;   pub const Inputs = common.Interp;
// VS output === FS input. They cannot drift; renaming a field
// breaks both stages at compile time until you fix it.

04 Honest accounting

What it costs, what it buys

IoT is not free — it adds schema files. But it removes the per-binding @extern blocks from the body and the host-side bind-group code. On line count it's close to a wash; the real dividend is type-safety. Measured on real shaders:

Concern
Direct
IoT
Binding numbers
hand-typed ints
derived, none
Texture sampling
raw op + manual bind
io.name(uv)
VS-out / FS-in match
two lists, by hand
shared struct
Host bind-group layout
~35 hand-written lines
Resources auto
Failure mode
silent GPU / runtime
compile error
Schema tax
none
~20–30 lines

The schema tax is real. The bug surface it removes is realer.

05 The decision

Which pattern, when

The goal is one pattern. The interface now expresses everything — storage buffers, builtins, samplers — so the direct pattern is no longer a capability, only a habit. Until the last shaders are ported, here's the rule:

Reach for IoT

the default — 40+ of our shaders

  • Any shader with bindings. Uniforms, samplers, storage buffers — all named in a schema, zero hand-counted integers.
  • Anything with a VS/FS pair. The shared Interp keeps the varyings honest.
  • Anything using builtins. vertex_index and instance_index are schema members now.

Direct — shrinking

an exception, not a parallel path

  • Bespoke bind-group topologies the fixed group scheme can't name yet — e.g. the decal projector-UBO ring with its 64 pre-built groups.
  • Everything else — points, fluid, billboard, skybox — has already moved over. The decal ring is the only shader left on the direct path.
● status

points, fluid_discs, billboard, and skybox are all ported and shipping — every standard shader is now IoT. Their hosts bind storage buffers through Resources, validated on device. Only the decal ring stays direct, and that's a host-side resource concern (its 64-slot projector-UBO ring), not a limit of the interface.