Skip to content

Instantly share code, notes, and snippets.

@alichraghi
Last active September 2, 2025 11:43
Zig Shaders

What does it look like?

Here is a simple fragment shader with uniform buffers:

const std = @import("std");
const gpu = std.gpu;

const UBO = extern struct {
    object_color: @Vector(4, f32),
    light_color: @Vector(4, f32),
};

extern const ubo: UBO addrspace(.uniform);
extern var frag_color: Vec4 addrspace(.output);

export fn fragmentMain() callconv(.spirv_fragment) void {
    // Annotation
    gpu.binding(&ubo, 0, 0);
    gpu.location(&frag_color, 0);

    frag_color = ubo.object_color * ubo.light_color;
}

How to build?

In CLI:

zig build-obj shader.zig -target spirv32-vulkan -ofmt=spirv -mcpu vulkan_v1_2 -fno-llvm

In build.zig:

const vulkan12_target = b.resolveTargetQuery(.{
    .cpu_arch = .spirv32,
    .cpu_model = .{ .explicit = &std.Target.spirv.cpu.vulkan_v1_2 },
    .os_tag = .vulkan,
    .ofmt = .spirv,
});
const shader = b.addObject(.{
    .name = "shader",
    .root_source_file = b.path("shader.zig"),
    .target = vulkan12_target,
    .optimize = .ReleaseFast,
    .use_llvm = false,
    .use_lld = false,
});
// Use the emited SPIR-V with `shader.getEmitedBin()`

GLSL/HLSL -> Zig mapping

This is by no means complete, but it's a good starting point when you're looking to port some shaders between GLSL/HLSL to Zig.

GLSL HLSL Zig
gl_Position SV_Position gpu.position_in/gpu.position_out
gl_VertexIndex SV_VertexID gpu.vertex_index
gl_InstanceIndex SV_InstanceID gpu.instance_index
gl_FragCoord SV_Position gpu.fragment_coord
gl_FragDepth SV_Depth gpu.fragment_depth
layout(location=N) SV_Target gpu.location()
layout(binding=N) register() gpu.binding()
gl_GlobalInvocationID SV_DispatchThreadID gpu.global_invocation_id
gl_LocalInvocationID SV_GroupThreadID gpu.local_invocation_id

How does inline assembly look like?

You can directly write SPIR-V assembly using the inline assembly feature. As you probably have noitced, it requires a basic knowledge in both Zig's inline assembly syntax and SPIR-V so make sure to read Zig's inline assembly, SPIR-V Assembly Syntax and SPIR-V Specification docs.

Here's how std.gpu.binding() is implemented:

pub fn binding(comptime ptr: anytype, comptime set: u32, comptime bind: u32) void {
    asm volatile (
        \\OpDecorate %ptr DescriptorSet $set
        \\OpDecorate %ptr Binding $bind
        :
        : [ptr] "" (ptr),
          [set] "c" (set),
          [bind] "c" (bind),
    );
}

OpDecorate is an instruction with no result-id which means it has no output. normal input constraints are declared by an empty string and a % sign in the code. There's also constant constraints ("c") which takes a comptime known value and are determined with a $ sign. for more examples checkout std.gpu.

How can i help?

Write code. SPIR-V backend is in early stages so we are eager to see how it works for real-world examples so a reproducible bug in issue-tracker is appreciated. If you have any questions, feel free to reach me (#alichraghi) or Snektron (#snektron) in Discord or ZSF's zulip.

@igaryhe
Copy link

igaryhe commented Mar 12, 2025

yes, i think it only happens when outputting spir-v. can you reproduce it on your side? my zig version is 0.15.0-dev.34+8e0a4ca4b

@igaryhe
Copy link

igaryhe commented Mar 17, 2025

i'm not exactly sure why, but the error log seems coming from this line: https://github.com/ziglang/zig/blob/2a4e06bcb30f71e83b14026bcbade6aac3aece84/src/link/SpirV.zig#L266-L267, and in a debug build of the compiler, i observed that this self.base.file is null:

D:\workspace\zig\zig\src\link\SpirV.zig:266:19: 0x7ff7d97b7755 in flushModule (zig.exe.obj)
    self.base.file.?.writeAll(std.mem.sliceAsBytes(linked_module)) catch |err|
                  ^
D:\workspace\zig\zig\src\link\SpirV.zig:197:28: 0x7ff7d95e98e2 in flush (zig.exe.obj)
    return self.flushModule(arena, tid, prog_node);
                           ^
D:\workspace\zig\zig\src\link.zig:847:77: 0x7ff7d94785eb in flush (zig.exe.obj)
                return @as(*tag.Type(), @fieldParentPtr("base", base)).flush(arena, tid, prog_node);
                                                                            ^
D:\workspace\zig\zig\src\Compilation.zig:2521:17: 0x7ff7d9477e5e in flush (zig.exe.obj)
        lf.flush(arena, tid, prog_node) catch |err| switch (err) {
                ^
D:\workspace\zig\zig\src\Compilation.zig:2458:22: 0x7ff7d947cebd in update (zig.exe.obj)
            try flush(comp, arena, .{
                     ^
D:\workspace\zig\zig\src\main.zig:4221:32: 0x7ff7d94f99ab in serve (zig.exe.obj)
                try comp.update(main_progress_node);
                               ^
D:\workspace\zig\zig\src\main.zig:3661:22: 0x7ff7d951fe9b in buildOutputType (zig.exe.obj)
            try serve(
                     ^
D:\workspace\zig\zig\src\main.zig:277:31: 0x7ff7d93f79ad in mainArgs (zig.exe.obj)
        return buildOutputType(gpa, arena, args, .{ .build = .Obj });
                              ^
D:\workspace\zig\zig\src\main.zig:212:20: 0x7ff7d93f6326 in main (zig.exe.obj)
    return mainArgs(gpa, arena, args);
                   ^
D:\scoop\apps\zig-dev\0.15.0-dev.56\lib\std\start.zig:631:28: 0x7ff7d93f6149 in main (zig.exe.obj)
    return callMainWithArgs(@as(usize, @intCast(c_argc)), @as([*][*:0]u8, @ptrCast(c_argv)), envp);
                           ^
D:\scoop\apps\zig-dev\0.15.0-dev.56\lib\libc\mingw\crt\crtexe.c:266:0: 0x7ff7dbf436db in __tmainCRTStartup (crt2.obj)
    mainret = _tmain (argc, argv, envp);

D:\scoop\apps\zig-dev\0.15.0-dev.56\lib\libc\mingw\crt\crtexe.c:186:0: 0x7ff7dbf4373b in mainCRTStartup (crt2.obj)
  ret = __tmainCRTStartup ();

???:?:?: 0x7ff85f99259c in ??? (KERNEL32.DLL)
???:?:?: 0x7ff860c2af37 in ??? (ntdll.dll)

however, this only happens on windows, and when i run this on a linux machine it works flawlessly.

@alichraghi
Copy link
Author

this only happens on windows, and when i run this on a linux machine it works flawlessly.

yeah that explains why i couldn't either lol. btw feel free to continue discussion in zulip. other compiler folks may have a clue

@VictorSohier
Copy link

I'm just wondering if you have at least the shaders for a full "Hello, Triangle" example in zig.

Also, I'm having some issues running the SPIRV output in vulkan, something about an unimplemented instruction. This very well might just be the fact that I'm running a 5700XT, but I doubt it since vulkaninfo tells me that my system can do vulkan version 1.4 and this is vulkan version 1.2.

@alichraghi
Copy link
Author

alichraghi commented Apr 24, 2025

I will try to get a working vulkan triangle example soon. perhaps just contribute it as an option to vulkan-zig.

something about an unimplemented instruction

Can you send the full validator message?

EDIT: See Snektron/vulkan-zig#181

@VictorSohier
Copy link

VictorSohier commented Apr 24, 2025

Sure, here's the full error:

ACO ERROR:
    In file ../src/amd/compiler/aco_instruction_selection.cpp:9022
    Unimplemented intrinsic instr: @store_deref (%1, %6) (wrmask=xyz, access=none)

This is being run through an Odin platform layer, but I can't imagine that being the problem since these are just passed into vulkan as shader code.

Either way, thank you dearly for all the resources both you and Robin have created regarding Zig on GPU. I will be very happy when this is fully fleshed out. Might try doing GCN for other things in the meantime.

EDIT: I figured out my issue, apparently, GPUs don't like concatenating arrays (++ operator)

@EtienneParmentier
Copy link

EtienneParmentier commented May 13, 2025

EDIT: I figured out my issue, apparently, GPUs don't like concatenating arrays (++ operator)

This is a compile time operation, it should be allowed ? I'd consider this a bug. There are no memory allocator on the GPU.

@EtienneParmentier
Copy link

EtienneParmentier commented May 13, 2025

How does one accesses the compiled shader code ? when using installArtifact on zig 14, it fails because object files can't be installed.

inside: lib\std\Build\Step\InstallArtifact.zig line 56:

    const dest_dir: ?InstallDir = switch (options.dest_dir) {
        .disabled => null,
        .default => switch (artifact.kind) {
            .obj => @panic("object files have no standard installation procedure"),
            .exe, .@"test" => .bin,
            .lib => if (artifact.isDll()) .bin else .lib,
        },
        .override => |o| o,
    };

@VictorSohier
Copy link

I get no such issues on Linux. I suspect it is a bug in the Windows build. I tried this kind of thing on Windows and it basically told me that it has no permission to write.

@alichraghi
Copy link
Author

@EtienneParmentier use getEmittedBin(). example

@EtienneParmentier
Copy link

EtienneParmentier commented May 14, 2025

@EtienneParmentier use getEmittedBin(). example

Cool ! could we update the example at the top ? Actually, I want to save the binary file somewhere, not import it inside another application, to allow swapping shaders after the app is compiled.
I tried using addObjCopy(), but I can't figure out how it works.

I tried using your embedfile approach, and it doesn't work for me either; here is my code (it uses your fragment shader)

Wait it's the same error as @VictorSohier ! I believe submitting an issue to zig repo is valuable at this point, using the shader here and my build.zig code as minimal reproducible example. here it is

@Kbz-8
Copy link

Kbz-8 commented Jul 19, 2025

I made a super simple hello triangle example with Zig's shaders and SDL3 GPU if anyone's interested

https://github.com/Kbz-8/ZigShadersWithSDLGPU/

@EtienneParmentier
Copy link

EtienneParmentier commented Jul 23, 2025

Hi ! Is there any hope to see more primitives added to std.gpu ? As a spirv noob I don't have the skills/knowledge required to insert image2D/UBO using assembly. I see that quite a few people have done their own implementation, one could write a PR collecting what has already been done ?

@alichraghi
Copy link
Author

Yes but it can't be implemented in a good form yet until @SpirvType(...) or @Opaque(...) is added to language.

@EtienneParmentier
Copy link

Why ? If people are already implementing it, it means zig can do it right ? what would these new builtins add to the langage ?

@alichraghi
Copy link
Author

To create a sampler or image you need inline assembly which has to be written inside a function so you can't pass them anywhere because the concept of assembly types do not exists and is rejected. The current decision is introducing one of those builtins instead.

@EtienneParmentier
Copy link

Aaah we can't 'return' a sampler, because zig doesn't know how to represent them in the type system.

@Ben-Miller0
Copy link

how do you create a 4x4 matrix type

@Kbz-8
Copy link

Kbz-8 commented Sep 2, 2025

how do you create a 4x4 matrix type

I use zmath to do so, it works perfectly in shaders as it uses @Vector to implement basically anything. I even generated and uploaded documentation of zmath here.

Here's a dumb simple 2D vertex shader that uses zmath:

const std = @import("std");
const gpu = std.gpu;
const zm = @import("zmath");

const Vec2f = @Vector(2, f32);
const Vec4f = @Vector(4, f32);

extern var in_position: Vec4f addrspace(.input);
extern var in_uv: Vec2f addrspace(.input);

extern var out_color: Vec4f addrspace(.output);
extern var out_uv: Vec2f addrspace(.output);

const UniformBuffer = extern struct {
    mat: zm.Mat,
    color: Vec4f,
};

extern const ubo: UniformBuffer addrspace(.uniform);

export fn main() callconv(.spirv_vertex) void {
    gpu.location(&in_position, 0);
    gpu.location(&in_uv, 2);

    gpu.location(&out_color, 0);
    gpu.location(&out_uv, 1);

    gpu.binding(&ubo, 1, 0);

    out_color = ubo.color;
    out_uv = Vec2f{ -in_uv[0], in_uv[1] };

    const position = Vec4f{ in_position[0], in_position[1], 0.0, 1.0 };
    gpu.position_out.* = zm.mul(ubo.mat, position);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment