Skip to content

vulkan-zig (Zig)

A build-time Vulkan binding generator for Zig that parses vk.xml and emits a single idiomatic vk.zig — error sets from VkResult, packed-struct bitflags, slice-merged parameters, and layered dispatch/wrapper/proxy types — while staying a strictly thin, zero-tracking binding.

FieldValue
LanguageZig (master tracks Zig master; minimum_zig_version 0.16.0; zig-<version>-compat branches)
LicenseMIT
RepositorySnektron/vulkan-zig
DocumentationREADME.md (the only docs) · examples/
CategoryThin / generated binding
First release~2020 (no tagged releases — rolling master, build.zig.zon version stays 0.0.0)
LatestRolling; last commit May 12, 2026 (b496a6a); CI regenerates daily against the latest vk.xml + Zig

NOTE

vulkan-zig is the de-facto standard Vulkan binding in the Zig ecosystem (≈860 GitHub stars; used by the mach-glfw Vulkan example and most Zig Vulkan tutorials). It deliberately stops at the binding layer: it is the Zig analogue of Ash (Rust) and erupted (D), not of Vulkano or the C++ render-graph layers.


Overview

What it solves

Consuming Vulkan from Zig via @cImport/translate-c gives the raw C API: VkResult return codes the compiler never forces you to check, uint32_t-typedef'd flag bits with no type distinction between VkQueueFlagBits and VkQueueFlags, out-parameters everywhere, and — on 32-bit targets — non-dispatchable handles that all collapse to uint64_t, destroying type safety. vulkan-zig regenerates the entire API surface from the Vulkan XML registry (vk.xml) into native Zig constructs instead, per the README:

"vulkan-zig attempts to provide a better experience to programming Vulkan applications in Zig, by providing features such as integration of vulkan errors with Zig's error system, function pointer loading, renaming fields to standard Zig style, better bitfield handling, turning out parameters into return values, slices for buffer parameters and more."

The second problem it solves is loading: vulkan-zig generates no static extern symbols at all — "Vulkan-zig provides no integration for statically linking libvulkan, and these symbols are not generated at all" (README § Dispatch Tables). Every function is a dynamically loaded pointer in one of three dispatch tables, mirroring how the Vulkan loader actually works (and skipping its per-call trampoline for device functions, the same motivation as volk in C and the wrapper structs in erupted).

Design philosophy

Idiomatic-but-transparent: every generated construct is ABI-identical to its C counterpart, so the binding adds Zig's type system without adding a runtime. Where a convenience could cost safety, the README is explicit that the burden stays on the programmer — on unconditionally loaded function pointers:

"The load function tries to load all function pointers unconditionally, regardless of enabled extensions or platform. If a function pointer could not be loaded, its entry in the dispatch table is set to null. … it is up to the programmer to ensure that a function pointer is valid for the platform before calling it, either by checking whether the associated extension or Vulkan version is supported or simply by checking whether the function pointer is non-null."README § Initializing Wrappers

Even micro-architecture is reasoned about: proxying wrappers store a pointer to their dispatch table rather than embedding it, because "By using a separate function pointer, LLVM knows that the 'vtable' dispatch struct can never be modified and so it can subject each call to vtable optimizations." (README § Proxying Wrappers).


How it works

The pipeline is a freestanding Zig program, vulkan-zig-generator (src/main.zig): a hand-written XML parser (src/xml.zig) reads vk.xml, a mini C tokenizer (src/vulkan/c_parse.zig) parses the C declarations embedded in registry <type>/<command> elements into a typed registry model (src/vulkan/registry.zig, filled by src/vulkan/parse.zig), and src/vulkan/render.zig (~2400 lines) renders the final vk.zig, which is then formatted with Zig's own std.zig formatter. Typical integration runs the generator as a build artifact, so bindings regenerate whenever vk.xml changes (README § build.zig):

zig
// build.zig — generate vk.zig from the Vulkan-Headers registry at build time
const registry = b.dependency("vulkan_headers", .{}).path("registry/vk.xml");
const vk_gen = b.dependency("vulkan", .{}).artifact("vulkan-zig-generator");
const vk_generate_cmd = b.addRunArtifact(vk_gen);
vk_generate_cmd.addFileArg(registry);
const vulkan_zig = b.addModule("vulkan-zig", .{
    .root_source_file = vk_generate_cmd.addOutputFileArg("vk.zig"),
});
exe.root_module.addImport("vulkan", vulkan_zig);

The generated API is layered: plain dispatch tables (BaseDispatch / InstanceDispatch / DeviceDispatch — structs of optional function pointers, grouped by whether they load via vkGetInstanceProcAddr with no instance, with an instance, or via vkGetDeviceProcAddr), wrappers (BaseWrapper / InstanceWrapper / DeviceWrapper) adding the Zig-style signatures and error sets, and proxies (InstanceProxy, DeviceProxy, QueueProxy, CommandBufferProxy) bundling a handle with a wrapper pointer so the handle argument disappears from call sites (examples/graphics_context.zig):

zig
// examples/graphics_context.zig (abridged)
self.vkb = BaseWrapper.load(getGlfwInstanceProcAddr);
const instance = try self.vkb.createInstance(.{ ... }, null);   // error union, struct literal
vki.* = InstanceWrapper.load(instance, self.vkb.dispatch.vkGetInstanceProcAddr.?);
self.instance = vk.InstanceProxy.load(instance, vki);
defer instance.destroyInstance(null);                           // still manual destruction

Binding generation & API coverage

Generation is whole-registry: every type, command, and extension in the supplied vk.xml is emitted, with no feature-level or extension filtering. That is a stated limitation, not an oversight — promoted extensions lose their author tags when they enter core (VkSemaphoreWaitFlagsKHRVkSemaphoreWaitFlags), so per-feature-level slicing would need tag re-derivation: "vulkan-zig has as of yet no functionality for selecting feature levels and extensions when generating bindings" (README § Limitations). Coverage is kept honest by CI: the project "is automatically tested daily against the latest vk.xml and zig, and supports vk.xml from version 1.x.163" (Vulkan 1.2.163, December 2020). Vulkan Video definitions are opt-in via --video video.xml / -Dvideo=.

Which registry metadata survives into the output is precisely enumerable from parse.zig's attribute reads:

vk.xml attributeSurvives as
successcodes / errorcodesPer-command error sets + returned non-success Result values
optionalOptional pointers (?*), defaulted fields (= null, = .null_handle, = .{})
lenSlice-merged parameters with shared-length debug assertions
values (on sType)s_type field defaults (= .instance_create_info)
structextendsOnly to detect feature structs (extenders of VkDeviceCreateInfo) and default their VkBool32 fields to .falsenot rendered as a typed pNext relation
api, supported, promotedto, requiresCoreDeclaration filtering + the vk.features / vk.extensions ApiInfo metadata tables
parent (on handles)Parsed into the registry model but unused in rendering
externsyncDropped entirely — the string externsync does not appear anywhere in src/

The "comptime substitutes for codegen" story is two-stage. Stage one is genuine codegen (the generator executable). Stage two is comptime inside the generated file doing what other ecosystems need more generated text for: FlagsMixin(comptime FlagsType: type) derives all set operations for every flags type from one ~90-line template via inline for (comptime std.meta.fieldNames(FlagsType)); each wrapper is a type constructor (pub fn BaseWrapperWithCustomDispatch(DispatchType: type) type, with BaseWrapper = BaseWrapperWithCustomDispatch(BaseDispatch)); and the loader is a single reflective loop instead of one generated line per function (render.zig, renderWrapperLoader):

zig
// generated vk.zig — wrapper loader (rendered by render.zig:renderWrapperLoader)
pub fn load(device: Device, loader: anytype) Self {
    var self: Self = .{ .dispatch = .{} };
    inline for (std.meta.fields(Dispatch)) |field| {
        if (loader(device, field.name.ptr)) |cmd_ptr| {
            @field(self.dispatch, field.name) = @ptrCast(cmd_ptr);
        }
    }
    return self;
}

Platform types use the same trick for late binding: pub const xcb_connection_t = if (@hasDecl(root, "xcb_connection_t")) root.xcb_connection_t else opaque{}; lets the application's root module inject real windowing-system types at compile time with zero generator involvement (README § Platform types).

Handle lifetime & ownership model

Handles are branded but unowned. Each handle type is a distinct non-exhaustive enum — usize-backed for dispatchable handles, u64-backed for non-dispatchable ones (README § Handles):

zig
const Instance = extern enum(usize) { null_handle = 0, _ };

"This means that handles are type-safe even when compiling for a 32-bit target."README § Handles

This fixes the C headers' 32-bit collapse (where every non-dispatchable handle is the same uint64_t) purely nominally: passing a Buffer where an Image is expected is a compile error, and null_handle replaces VK_NULL_HANDLE with a typed zero. But that is the full extent of the model. There is no RAII, no destructor generation, no reference counting, and no use of the registry's parent attribute (parsed in parse.zig line 185, never rendered): defer instance.destroyInstance(null) in the example is the idiom, and use-after-destroy or double-destroy is undiagnosed. Even the proxying wrappers, which look like objects, are plain (handle, *wrapper) pairs — DeviceProxy.destroyDevice() must still be called explicitly. Contrast vulkan-hpp's optional vk::raii namespace and Vulkano's Arc-based ownership; vulkan-zig matches Ash's position that lifetime is the application's problem.

Synchronization safety

None — and explicitly out of scope. vulkan-zig generates Fence, Semaphore, timeline-semaphore commands (waitSemaphores, signalSemaphore), cmdPipelineBarrier2, and queue-family ownership-transfer structs exactly as the registry declares them, as inert data types. There is:

  • no barrier/layout tracking, render graph, or auto-sync layer (compare vuk, daxa);
  • no typed distinction between externally synchronized and internally synchronized commands — the registry's externsync attribute (which marks, e.g., the commandPool parameter of vkFreeCommandBuffers as requiring host-side exclusion) is not even parsed, as the attribute table above shows;
  • no thread-safety annotations: a CommandBufferProxy can be recorded from two threads and neither the type system nor a debug assertion objects.

The absence is coherent with the project's thin-binding category: the expectation is that correctness comes from the standard Khronos validation layers (including synchronization validation) at runtime, which work unmodified since every generated call is ABI-identical to C. But it makes vulkan-zig a floor, not a ceiling, for the survey's synchronization question — the interesting Zig-specific observation is that nothing in the language would prevent an externsync-aware layer (e.g. wrapper variants taking *CommandPool exclusively), and the generator already has the parse infrastructure it would need.

Type-system techniques

vulkan-zig's safety budget is spent on representation, where Zig's type system is strongest:

  • Error sets per commanderrorcodes becomes a named Zig error set (CreateInstanceError = error{ OutOfHostMemory, … , Unknown }), and the wrapper returns CreateInstanceError!Instance. Ignoring a result is now a compile error. See Error handling.

  • Packed structs of bools for bitflags — one type replaces the C FlagBits/Flags pair (README § Bitflags):

    zig
    pub const QueueFlags = packed struct {
        graphics_bit: bool align(@alignOf(Flags)) = false,
        compute_bit: bool = false,
        transfer_bit: bool = false,
        ...
    };

    The first field's align(@alignOf(Flags)) pins struct ABI; on function-call boundaries the flags are reinterpreted through the mixin's IntType because the alignment trick does not survive the call ABI. FlagsMixin contributes toInt/fromInt/merge/intersect/complement/subtract/contains. The cost of unifying the pair: where C distinguishes "exactly one bit" (VkQueueFlagBits) from "a set" (VkQueueFlags), here "The programmer is responsible for only enabling a single bit."

  • Out-parameters become return values; in-pointers lose one indirection — a non-const non-optional single-item pointer is returned; a const one is taken by value so InstanceCreateInfo can be a struct literal; multiple returns synthesize an ad-hoc result struct with return_value, result, and de-p_-prefixed out fields (README § Wrappers; classification in render.zig classifyParam).

  • Pointer+length pairs become slices, with a generated std.debug.assert when several slices share one length parameter (README § slices).

  • Non-exhaustive enums everywhere (enum(i32) { …, _ }) so values from newer drivers/extensions than the generation-time registry round-trip without UB.

  • Defaults from registry semanticss_type pre-set, p_next = null, optional handles = .null_handle, optional bitmasks = .{}, and VkBool32 fields of feature structs = .false (the only consumer of structextends; render.zig isFeatureStruct), making .{ .sampler_anisotropy = .true }-style feature requests total.

  • Version/extension metadata as valuesvk.features.version_1_2 and per-extension vk.extensions.* constants of type ApiInfo { name, version } support runtime capability checks against apiVersion.

What is absent is equally diagnostic: pNext stays ?*const anyopaque, so structure chains are untyped and an invalid extender compiles silently (compare vulkanalia's push_next-style typed chains and vulkan-hpp's vk::StructureChain); there is no typestate, no linear/affine ownership, and no capability typing of extensions beyond the loaded-or-null function pointer.

Overhead & escape hatches

The runtime added over raw C calls is close to the theoretical minimum for dynamic dispatch:

MechanismCost
Dispatch tableOne direct call through a non-null-checked-at-load function pointer — same as volk-style C
Optional-pointer unwrap (.?)A null check in Debug/ReleaseSafe; undefined behavior in ReleaseFast if the pointer never loaded
Wrapper marshallingCompile-time only: re-introducing &create_info, the Result switch (a jump the C caller writes by hand)
Slice length assertionsstd.debug.assert — compiled out of release modes
Flags ABI reinterpretationA bitcast (toInt) at call boundaries — free
Proxy typesOne extra pointer dereference; deliberately shaped so LLVM treats the dispatch struct as an immutable vtable
State tracking / locks / hash mapsNone exist

Loading all function pointers unconditionally trades a few thousand vkGet*ProcAddr calls at startup for zero per-call capability logic — and shifts validity to the programmer (the bolded README warning quoted above). The escape hatches are total and zero-cost: wrapper.dispatch.vkFuncYouWant.?(...) calls the raw C function pointer with the exact registry signature (PfnCreateInstance types are generated with callconv(vulkan_call_conv)), handles are @intFromEnum-convertible integers ABI-compatible with C libraries (the example passes vk.Instance straight to GLFW), and an enumerate-style wrapper's manual two-call form remains available beside the allocating one.

Error handling & validation integration

VkResult handling is the binding's signature feature. For each command, render.zig (renderErrorSwitch, renderErrorSet) renders the registry's errorcodes into an error set and a switch:

zig
// generated vk.zig — shape of every wrapper body (per render.zig:renderErrorSwitch)
switch (result) {
    .success => {},
    .error_out_of_host_memory => return error.OutOfHostMemory,
    .error_initialization_failed => return error.InitializationFailed,
    ...
    else => return error.Unknown,   // forward-compat: codes newer than the registry
}

Non-error success codes are not flattened away: a command whose successcodes exceed VK_SUCCESS (e.g. vkAcquireNextImageKHR with VK_SUBOPTIMAL_KHR / VK_TIMEOUT) returns the Result value (or a result struct containing it), so semantically meaningful statuses survive the error-set translation — a design point Ash shares but several C++ wrappers fumble. For the enumerate pattern (vkEnumeratePhysicalDevices and friends, listed in a StaticStringMap in render.zig), an additional fooAlloc(..., allocator) wrapper is generated per command and on each proxy that owns it; it loops while (result == .incomplete), growing a caller-supplied std.mem.Allocator allocation — the only allocating code in the entire binding, and explicitly opt-in by suffix.

Validation-layer integration is pass-through: nothing generated knows about VK_LAYER_KHRONOS_validation, but because handles, structs, and calling conventions are ABI-exact, the layers (and DebugUtilsMessengerEXT, whose creation functions are ordinary generated wrappers) work without adapters — see the synchronization-validation deep-dive for what that runtime net actually catches.


Strengths

  • Zero runtime above dynamic dispatch — no tracking structures, no locks, no allocation outside the opt-in *Alloc wrappers; release-mode marshalling compiles to the code a careful C programmer writes.
  • Result-code discipline by construction: per-command error sets make unchecked VkResult a compile error while preserving non-SUCCESS success codes.
  • Representation-level fixes C cannot express: branded 32-bit-safe handles, one packed-struct flags type with set algebra, slices with length checking, s_type/p_next defaults that kill an entire class of boilerplate bugs.
  • Build-time generation from the exact vk.xml you ship — bindings can never drift from the headers/SDK in use, and day-one extension support is a regeneration away (CI proves it daily).
  • Layering with clean exits: dispatch table → wrapper → proxy, each level optional, raw C function pointers always reachable.
  • comptime keeps the generator small (~5.8 kLoC total): mixins, loaders, and platform-type injection are reflective templates in the generated file rather than expanded text.

Weaknesses

  • No synchronization or lifetime safety whatsoever — barriers, semaphores, queue-family transfers, and destruction ordering are entirely manual; externsync metadata is discarded unparsed, so even host-synchronization documentation is absent from the generated API.
  • Unconditional pointer loading defers capability errors to call time: an extension function missing on the platform is a null unwrap — a checked crash in safe modes, UB in ReleaseFast.
  • Untyped pNext (?*const anyopaque): structure chains, the fastest-growing part of modern Vulkan, get no help.
  • No feature/extension selection at generation time — the whole registry is always emitted (single large vk.zig; compile time and namespace noise scale with the registry).
  • Single-bit vs. multi-bit flag distinction is lost by unifying FlagBits/Flags; the registry's optional data is also "not everywhere as useful … leading to places where optional-ness is not correct" (README § Pointer types).
  • Rolling versioning against a rolling language: no tagged releases; master chases Zig master, with compat branches as the only stability story — awkward for long-lived projects.
  • Docs are a single README; there is no generated per-function reference (the Vulkan spec remains the manual).

Key design decisions and trade-offs

DecisionRationaleTrade-off
Build-time generator executable (not comptime parsing of XML)Full Zig program can parse XML + embedded C and run zig fmt; output is inspectable, vendorableTwo-stage build; users must wire vk.xml through build.zig
Generate everything; no feature/extension selectionPromoted extensions lose author tags, making sliced generation inconsistentLarge single vk.zig; unsupported functions exist as null traps rather than absent symbols
Dynamic loading only; no extern libvulkan symbolsMatches loader reality; per-device tables skip loader trampolinesStartup loads every pointer; validity checking pushed to the programmer
VkResult → per-command error sets + returned success codesCompiler-enforced handling; try ergonomics; statuses like suboptimal_khr preservedelse => error.Unknown lumps future codes; error names lose the numeric code
Flags as one packed struct of bools + FlagsMixinField-named bits, defaults, set algebra; no FlagBits/Flags duplication"Exactly one bit" contracts unchecked; ABI needs the align trick + IntType reinterpretation
Handles as non-exhaustive enum(usize)/enum(u64)Nominal typing on all targets, typed null_handle, zero costNo ownership/lifetime semantics; parent registry data unused
externsync ignoredKeeps the binding thin; host sync is the validation layers' jobThe registry's only thread-safety metadata vanishes — not even surfaced in doc comments
Proxies hold (handle, *wrapper) with the table behind a pointerLLVM "vtable" optimization on an immutable dispatch struct; ergonomic call sitesOne more indirection and one more lifetime for the user to keep straight

For the cross-language synthesis — especially how a D library could consume the same registry metadata vulkan-zig drops (externsync, parent) using CTFE where Zig uses a generator binary — see the comparison and concepts docs.


Sources