Zig 0.17 bindings for Vulkan, generated from the official Khronos registry and headers. The package provides:
- a complete target-specific raw ABI at
vulkan.rawand through thevulkan-rawmodule; - a typed runtime loader with entry, instance, physical-device, device, queue, surface, and swapchain wrappers;
- resource-safe
deinitmethods and Zig errors for commonVkResultfailures; - generated command descriptors that prevent PFN/name and dispatch-scope mismatches;
- generated extension-name descriptors and a bounded, allocation-free extension set;
- domain modules for buffers, images, memory, synchronization, commands, queues, queries, presentation, direct displays, presentation extensions, formats, capabilities, and debug utilities;
- reproducible offline builds from vendored Khronos inputs; and
- an explicit command to pull, verify, and vendor a new Vulkan registry revision.
The normal build does not use the network. The vendored C headers are generated by Khronos from
vk.xml; Zig's build translates and verifies those headers for the selected target on demand.
Add the package to your application's build.zig.zon with Zig 0.17:
zig fetch --save=vulkan git+https://github.com/zmscode/vk-zig.gitFor local development, use the equivalent path dependency:
.dependencies = .{
.vulkan = .{ .path = "../vk-zig" },
},Then expose the module in build.zig:
const vulkan_dependency = b.dependency("vulkan", .{
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "vulkan", .module = vulkan_dependency.module("vulkan") },
},
}),
});Declaring the import when the module is created keeps every dependency in one place. For a module
that already exists, exe.root_module.addImport("vulkan", vulkan_dependency.module("vulkan")); does
the same thing.
Metal declarations are enabled by default for Apple targets and Win32 declarations for Windows. Linux and BSD default to the platform-independent API so consuming projects do not unexpectedly need window-system headers. Enable any compatible declaration groups your application supports:
const vulkan_dependency = b.dependency("vulkan", .{
.target = target,
.optimize = optimize,
.platform_xlib = true,
.platform_xcb = true,
.platform_wayland = true,
});The switches are .platform_metal, .platform_win32, .platform_xlib, .platform_xcb,
.platform_wayland, and .platform_android. Xlib/XCB/Wayland may be combined on Linux and BSD;
the other families are target-specific and mutually exclusive. The old single .platform = .xcb
form remains supported for compatibility. At runtime-independent compile time, inspect
vk.platform_support.xlib, .xcb, .wayland, and the other fields to see which constructors and
raw declarations are available. Platform headers are represented by stable forward declarations;
your windowing library still owns the concrete native objects passed to a surface constructor.
The loader is opened at runtime, so applications do not need to link the Vulkan loader directly:
const std = @import("std");
const vk = @import("vulkan");
pub fn main(init: std.process.Init) !void {
var loader = try vk.Loader.init();
defer loader.deinit();
const entry = try loader.entry();
const version = try entry.apiVersion();
std.log.info("Vulkan {d}.{d}.{d}", .{ version.major, version.minor, version.patch });
var instance = try entry.createInstance(.{
.application_name = "my-zig-app",
.engine_name = "my-engine",
.api_version = .{ .major = 1, .minor = 3, .patch = 0 },
.enumerate_portability = vk.platform_support.metal,
});
defer instance.deinit();
const physical_devices = try instance.physicalDevices(init.gpa);
defer init.gpa.free(physical_devices);
for (physical_devices) |*physical_device| {
const properties = physical_device.properties();
std.log.info("{s}", .{properties.name()});
}
}For an actionable loader failure, opt into bounded caller-owned diagnostics (no allocator is used):
var loader_diagnostics: vk.LoaderDiagnostics = .{};
var loader = vk.Loader.initWithDiagnostics(&loader_diagnostics) catch |err| {
for (loader_diagnostics.attempts()) |attempt| {
std.log.err("loader candidate {s}: {s}", .{ attempt.path(), @tagName(attempt.outcome) });
}
return err;
};
defer loader.deinit();
std.log.info("Vulkan runtime: {s}", .{try loader.selectedPath()});initFromPathWithDiagnostics provides the same record for an explicit runtime path. Paths are
copied into fixed-capacity records; path_truncated and overflowed make any loss visible.
Logical-device creation is a typed requirements contract. Extension dependencies, promoted
extensions, requested features, and queue counts are checked before vkCreateDevice:
const priorities = [_]f32{1.0};
const queues = [_]vk.DeviceQueueOptions{.{
.family_index = graphics_family,
.priorities = &priorities,
}};
var device = try physical_device.createDevice(.{
.queues = &queues,
.extensions = &.{vk.extension.khr_swapchain},
.features = .init(&.{ .synchronization2, .dynamic_rendering }),
.enabled_instance_extensions = &.{vk.extension.khr_surface.name},
.enable_portability_subset = vk.platform_support.metal,
});
defer device.deinit();
const queue = try device.queue(graphics_family, .first);
std.debug.assert(device.supportsFeature(.dynamic_rendering));Extension-only feature structures are generated from the registry and composed in stable, caller-owned chains. The same typed values are queried, checked, and reused for device creation:
const MeshFeatures = vk.ExtensionFeature.MeshShaderFeaturesEXT;
const MeshChain = vk.ExtensionFeatureChain(&.{MeshFeatures});
var requested_mesh = MeshChain.init(.{MeshFeatures{
.mesh_shader = true,
.task_shader = true,
}});
var device = try physical_device.createDeviceWithExtensionFeatures(.{
.queues = &queues,
.extensions = &.{vk.extension.ext_mesh_shader},
}, &requested_mesh);The chain rejects duplicate/aliased node types at compile time, queries support before dispatch,
and returns error.FeatureNotPresent rather than silently enabling an unsupported feature.
Generated descriptors prevent mixing instance and device scopes. InstanceExtensionSet combines
platform/windowing requirements without duplicates:
var extensions: vk.InstanceExtensionSet(8) = .{};
try extensions.append(vk.extension.khr_surface);
try extensions.append(vk.extension.ext_debug_utils);
try extensions.appendAll(vk.Portability.instanceExtensions());
try extensions.appendAll(vk.SurfaceConfiguration.instanceExtensions());
var instance = try entry.createInstance(.{
.extensions = extensions.slice(),
.enumerate_portability = vk.platform_support.metal,
});Instance and device discovery return typed properties. Names and descriptions are bounded views into each returned record, and caller-storage/count forms are available when allocation is not appropriate:
const available_extensions = try entry.instanceExtensions(gpa, null);
defer gpa.free(available_extensions);
if (vk.supportsExtension(available_extensions, vk.extension.ext_debug_utils.name)) {
for (available_extensions) |*property| {
std.log.info("{s} revision {d}", .{ property.name(), property.revision });
}
}Use queueFamilies and the typed memory snapshot instead of repeating flag and bit-index
arithmetic:
const families = try physical_device.queueFamilies(gpa);
defer gpa.free(families);
const graphics = for (families) |family| {
if (family.supports(.graphics)) break family;
} else return error.NoGraphicsQueue;
const memory_properties = try physical_device.memoryProperties();
const memory_type = try memory_properties.findType(.{
.type_bits = requirements.memory_type_bits,
.required_flags = .init(&.{.host_visible}),
.preferred_flags = .init(&.{.host_coherent}),
});Buffers, their requirements, memory binding, views, and device addresses are available without raw Vulkan declarations. The convenience path selects and owns the allocation alongside the buffer:
const memory_properties = try physical_device.memoryProperties();
var vertex_buffer = try device.createAllocatedBufferForProperties(.{
.buffer = .{
.size = .fromBytes(4096),
.usage = .init(&.{ .transfer_src, .vertex_buffer }),
},
.memory_properties = &memory_properties,
.required_memory_flags = .init(&.{ .host_visible, .host_coherent }),
});
defer vertex_buffer.deinit();Use createBuffer, memoryRequirements, allocateMemory, and Buffer.bindMemory when placing
several resources into one allocation. See examples/buffer_setup.zig for the complete setup.
Ray tracing uses one typed KHR context; normal application code never constructs a Vulkan union, loads an extension function, or names a raw handle. Geometry is a tagged union, so triangle, AABB, and instance pointer layouts cannot be mixed:
const rt_properties = try physical_device.rayTracingProperties();
const rt = try device.rayTracingContext(.{ .properties = rt_properties });
const geometries = [_]vk.ray_tracing.Geometry{.{
.triangles = .{ .data = .{
.vertex_format = .r32g32b32_sfloat,
.vertex_data = .{ .device = vertex_address },
.vertex_stride = .fromBytes(@sizeOf([3]f32)),
.max_vertex = vertex_count - 1,
.indices = .{ .uint32 = index_address },
} },
}};
const primitive_counts = [_]u32{index_count / 3};
const sizes = try rt.buildSizes(
.device,
.bottom_level,
.init(&.{.prefer_fast_trace}),
&geometries,
&primitive_counts,
);
var blas = try rt.createStructure(.{
.type = .bottom_level,
.storage = &acceleration_storage,
.size = sizes.structure,
});
defer blas.deinit();Use buildCommand or buildHost, copyCommand/copyHost, and the typed serialization methods
for the rest of the acceleration-structure lifecycle. createPipeline, shaderGroupHandles,
ShaderBindingTables, and trace cover ray dispatch. rt.micromaps provides the same ownership,
build, compact/copy, serialization, compatibility, and property-query model for opacity micromaps.
Acceleration-structure storage, scratch, geometry input, instance targets, and shader-binding-table
buffers must remain alive until every submission that uses them completes. After build or copy,
record the appropriate acceleration-structure/micromap write-to-read barrier before tracing or a
dependent build. Host operations require externally synchronized objects and live host memory for
the duration of the call. Scratch sizes come from buildSizes; device scratch addresses are checked
against RayTracingProperties.scratch_alignment.
Mapped allocations expose only their requested range and normalize non-coherent flushes to the device atom size:
var mapped = try allocation.map(.{
.offset = .fromBytes(upload_offset),
.range = .{ .bytes = .fromBytes(source.len) },
});
defer mapped.deinit();
@memcpy(try mapped.bytes(), source);
try mapped.flush();Dynamic rendering, synchronization2, and transfer recording use typed image references and
slices. The scope is idempotent, so explicit cleanup and defer are both safe:
var rendering_scope = try command_buffer.beginRendering(.{
.render_area = .{ .offset = .{ .x = 0, .y = 0 }, .extent = extent },
.color_attachments = &.{.{
.view = &color_view,
.layout = .color_attachment_optimal,
.load = .clear,
.store = .store,
.clear = .{ .color = .{ .float = .{ 0.03, 0.05, 0.09, 1.0 } } },
}},
});
defer rendering_scope.deinit();
// Bind a graphics pipeline and record draws here.
try rendering_scope.end();Compatibility workloads can use owned render passes and framebuffers without exposing Vulkan
handles or pointer graphs. Choose a pipeline target explicitly with
.compatibility = .{ .render_pass = ... }; dynamic-rendering pipelines use
.compatibility = .{ .dynamic_rendering = ... }.
var render_pass = try device.createRenderPass(.{
.attachments = &.{.{
.format = format,
.load = .clear,
.store = .store,
.final_layout = .color_attachment_optimal,
}},
.subpasses = &.{.{
.color_attachments = &.{.{
.attachment = .{ .index = 0, .layout = .color_attachment_optimal },
}},
}},
});
defer render_pass.deinit();
var framebuffer = try device.createFramebuffer(.{
.render_pass = &render_pass,
.width = extent.width,
.height = extent.height,
.attachments = .{ .views = &.{&color_view} },
});
defer framebuffer.deinit();beginRenderPass returns an idempotent scope. Call scope.next(...) for additional subpasses.
Imageless framebuffers use the .imageless attachment variant at creation and provide the live
views through RenderPassBeginOptions.imageless_attachments. See
examples/legacy_render_pass.zig for a complete raw-free recording function.
Physical memory properties are owned typed snapshots, so their slices remain valid as long as the snapshot does. Counts and heap indices are validated before any slice is exposed:
const memory = try physical_device.memoryProperties();
for (memory.heaps()) |heap| {
if (heap.isDeviceLocal()) {
std.log.info("heap {d}: {d} bytes", .{ heap.index.toRaw(), heap.size_bytes });
}
}
const device_local_bytes = try memory.deviceLocalBytes();Use memoryPropertiesInto when initializing stable caller-owned storage. The explicitly named
memoryPropertiesRaw method remains available for diagnostics and interop.
createInstanceRaw and createDeviceRaw retain direct create-info control. Live wrapper handles
are private and non-null by construction; use the checked rawHandle() methods at FFI boundaries.
Loader.init() checks normal dynamic-loader names and common Homebrew, /usr/local, and MacPorts
locations on macOS. Applications with a custom SDK layout can select it explicitly:
var loader = try vk.Loader.initFromPath("/custom/VulkanSDK/macOS/lib/libvulkan.dylib");
defer loader.deinit();The typed wrapper covers normal discovery, resource ownership, memory, pipelines, descriptors, synchronization, rendering, and command recording without hiding Vulkan's explicit model. Generated command descriptors remain available for advanced extension work; each descriptor binds the command's name, function-pointer type, and dispatch scope:
const create_surface = (try instance.load(
vk.command.create_metal_surface_ext,
)) orelse return error.MetalSurfaceUnavailable;Use require when a missing command is an error rather than an optional capability:
const create_surface = try instance.require(vk.command.create_metal_surface_ext);For provisional or vendor commands absent from the registry, loadUnchecked(PFN, name) remains
available as an explicitly unchecked escape hatch.
Use vk.checkSuccess(result) for raw commands whose only successful result is VK_SUCCESS.
Do not use it for enumeration, wait, acquire, or presentation commands: status values such as
VK_INCOMPLETE, VK_TIMEOUT, and VK_SUBOPTIMAL_KHR require command-specific handling.
The library takes no hidden Vulkan locks. See the complete host synchronization and GPU lifetime contract before sharing queues, pools, command buffers, memory, descriptors, or presentation objects across threads. Owning wrappers are also protected against accidental Zig struct copies; see the ownership and borrowed-handle contract for cleanup and stale-parent behavior.
The configured platform constructor owns the native create-info and uses the instance's allocation policy. Add its required extensions before creating the instance:
try instance_extensions.appendAll(vk.SurfaceConfiguration.instanceExtensions());
// Metal build (`-Dplatform=metal`): `layer` is a stable CAMetalLayer pointer.
var surface = try instance.createMetalSurface(.{ .layer = layer });
defer surface.deinit();Equivalent typed constructors exist for Win32, Xlib, XCB, Wayland, and Android builds. Native
display/window pointers must remain valid for the surface lifetime. For off-screen use, request
SurfaceConfiguration.headlessInstanceExtensions() and call createHeadlessSurface(.{}).
Window libraries can expose a checked SurfaceAdapter; adoptSurface is retained only for
advanced foreign-handle interop.
Surface discovery remains fully typed:
const can_present = try physical_device.surfaceSupport(&surface, graphics_family);
const capabilities = try physical_device.surfaceCapabilities(&surface);
const extent = capabilities.extent_current orelse chooseWindowExtent(capabilities);
const image_count_max = capabilities.image_count_max; // null means no advertised maximum
const formats = try physical_device.surfaceFormats(gpa, &surface);
defer gpa.free(formats);
const present_modes = try physical_device.presentModes(gpa, &surface);
defer gpa.free(present_modes);Direct-display applications use instance.displayContext(&physical_device) to enumerate displays,
planes, and modes and to create display-plane surfaces without raw handles or structures. Each
enumeration has Count, Into, and allocating forms, and an unavailable display extension returns
error.MissingCommand.
Advanced swapchain behavior is collected under device.presentationController(). Swapchain and
present options accept compatible present modes, low-latency mode, present IDs, desired display
times, damaged regions, presentation fences, and per-present modes through typed fields. The
controller covers present waiting, maintenance1 image release, HDR metadata, GOOGLE display timing,
display power/events, vertical-blank counters, full-screen exclusive control, NV low latency and
queue notification, and AMD anti-lag. Its status unions preserve timeout, suboptimal, out-of-date,
and full-screen-loss outcomes rather than flattening them into errors.
Capability helpers apply Vulkan's clamping rules while reporting whether a preference was met:
const selected_format = try vk.chooseSurfaceFormat(formats, &.{.{
.format = .b8g8r8a8_srgb,
.color_space = .srgb_nonlinear,
}});
const selected_mode = try vk.choosePresentMode(present_modes, &.{ .mailbox, .fifo });
const extent = vk.clampSurfaceExtent(capabilities, window_extent);
const image_count = vk.chooseSwapchainImageCount(capabilities, 3);
const transform = vk.chooseSurfaceTransform(capabilities, &.{.identity});
const composite_alpha = try vk.chooseCompositeAlpha(
capabilities.composite_alpha_supported,
&.{.opaque_},
);The returned .preferred field is false when a valid fallback or clamp was required. Selection
policy remains explicit: callers supply ordered preferences and can reject any fallback.
Fixed-capacity callers can avoid allocation by querying the count and providing storage:
var format_storage: [128]vk.SurfaceFormat = undefined;
const format_count = try physical_device.surfaceFormatCount(&surface);
if (format_count > format_storage.len) return error.TooManySurfaceFormats;
const formats = try physical_device.surfaceFormatsInto(&surface, &format_storage);Core format selection is also raw-free. The promoted query resolves its Vulkan 1.1/KHR command alias internally and preserves unknown driver feature bits:
const format = try physical_device.formatProperties2(.d32_sfloat);
if (!format.optimal_tiling_features.contains(.depth_stencil_attachment)) {
return error.DepthFormatUnsupported;
}
const limits = (try physical_device.imageFormatProperties2(.{
.format = .d32_sfloat,
.image_type = ._2d,
.tiling = .optimal,
.usage = .init(&.{.depth_stencil_attachment}),
})) orelse return error.DepthFormatUnsupported;
const maximum_extent = limits.properties.extent_max;imageFormatProperties2 returns null for VK_ERROR_FORMAT_NOT_SUPPORTED. Add
.external_memory_handle_type to receive typed external-memory compatibility, or add a
DrmFormatModifierQuery with .tiling = .drm_format_modifier_ext; vk-zig owns both input and
output chains. Use drmFormatModifierPropertyCount/drmFormatModifierPropertiesInto or the
allocating drmFormatModifierProperties convenience to enumerate modifiers. Sparse image-format
properties follow the same count/Into/allocating pattern. See examples/format_queries.zig for
a depth-format selection example without vk.raw.
External memory and synchronization handles are available through device.externalInterop().
Declare exportability when creating the object, then use the dedicated namespace without raw
create structures, command loading, or result mapping:
var allocation = try device.allocateMemory(.{
.size = .fromBytes(4096),
.memory_type_index = memory_type,
.external = .{ .export_handles = .{
.handle_types = .init(&.{.opaque_fd}),
} },
});
defer allocation.deinit();
const interop = try device.externalInterop();
const fd = try interop.exportMemoryFd(&allocation, .opaque_fd);
defer std.posix.close(fd.native()); // exported fds belong to the callerThe same context imports and exports semaphore and fence fds, Win32 and Zircon handles, Android
hardware buffers, Metal objects, and host pointers where the target ABI supports them. FD and
Zircon imports transfer ownership only on success. Win32 imports retain caller ownership. Methods
for an inapplicable target return error.UnsupportedOperation; an enabled extension whose command
is unavailable returns error.MissingCommand. DMA-BUF uses .dma_buf_ext with the fd methods,
while DRM modifier compatibility remains in the typed format/image queries.
Mesh/task shading keeps EXT and NV semantics explicit. Query limits from
meshShaderPropertiesExt or meshShaderPropertiesNv, request the generated feature node through
an ExtensionFeatureChain, and record through device.meshShaderRecorder(). The recorder offers
typed EXT work groups, NV task ranges, and variant-tagged indirect/count draws. Fragment shading
rate follows the same pattern: enumerate supported rates with fragmentShadingRates, add
fragment_shading_rate to graphics pipeline options, attach a rate image through dynamic
rendering, and use device.fragmentShadingRateController() for dynamic KHR rates or NV images and
palettes. Unsupported vendor commands return error.MissingCommand; there is no silent fallback
between EXT, KHR, and NV behavior.
Vulkan Video is isolated under vk.video. Start with physical_device.videoQueries() for typed
codec profiles, capabilities, formats, and encode quality levels. Create sessions and parameter
objects through device.videoContext(), enumerate each session's memory requirements, allocate and
bind every required index, then use CommandBuffer.beginVideoCoding, controlVideoCoding,
decodeVideo, and encodeVideo. H.264/H.265/AV1/VP9 profile combinations are tagged; incompatible
parameter, reference-picture, decode, and encode codec data is rejected before dispatch. Khronos
StdVideo payload types are re-exported from vk.video, so applications do not import vk.raw.
Session memory allocations must outlive the session. Bitstream buffers, parameter objects, image views, and every DPB/reference-picture resource used by a recorded coding scope must remain alive and externally synchronized until the GPU has completed that submission.
Specialty compute is separated by Vulkan family. Use physical_device.opticalFlowFormats and
device.createOpticalFlowSession for NV optical flow; device.tensorContext() for ARM tensor
objects, memory, views, and copies; physical_device.cooperativeMathQueries() plus
device.cooperativeMatrixConverter() for cooperative matrix/vector discovery and conversion; and
device.dataGraphContext() for ARM data-graph pipelines, sessions, bind-point memory, and dispatch.
Each context loads commands independently and reports error.MissingCommand when its generated
extension requirement is not enabled.
The following highly vendor-specific metadata paths remain intentional advanced raw-only escape
hatches: tensor opaque-capture/external-property queries, NV flexible-dimension cooperative-matrix
properties, and ARM data-graph engine-operation and arbitrary property-blob queries. Their command
and extension metadata remains generated under vk.command; object lifecycle, memory binding, and
dispatch no longer require raw Vulkan.
deviceExtensions enumerates per-device support. Once VK_KHR_swapchain is enabled on the
logical device, create and own a swapchain without manually loading its commands:
var swapchain = try device.createSwapchain(.{
.surface = &surface,
.min_image_count = capabilities.image_count_min,
.image_format = format.format,
.image_color_space = format.color_space,
.image_extent = extent,
.image_usage = .init(&.{.color_attachment}),
.pre_transform = capabilities.transform_current,
});
defer swapchain.deinit();
const swapchain_metadata = try swapchain.metadata();
var swapchain_views = try swapchain.createImageViews(gpa, .{});
defer {
for (swapchain_views) |*view| view.deinit();
gpa.free(swapchain_views);
}
var image_available = try device.createSemaphore(.{});
defer image_available.deinit();
var render_finished = try device.createSemaphore(.{});
defer render_finished.deinit();
const acquired = try swapchain.acquireNextImage(.{ .semaphore = &image_available });
const image_index = switch (acquired) {
.success, .suboptimal => |index| index,
.timeout, .not_ready => return,
.out_of_date => return recreateSwapchain(),
};
const status = try queue.present(.{
.swapchain = &swapchain,
.image_index = image_index,
.wait_semaphores = &.{&render_finished},
});
if (status != .success) try recreateSwapchain();The frame-resource layer also owns image views, command pools, semaphores, and fences. Command
buffers are borrowed from their pool, and swapchain images are borrowed from their swapchain.
Normal clear/submit/present code does not need vk.raw:
Timestamp, occlusion, pipeline-statistics, and performance queries use an owned, kind-tagged pool. The library derives result stride and allocation size, and keeps delayed availability explicit:
var timestamps = try device.createQueryPool(.{
.kind = .timestamp,
.count = 2,
});
defer timestamps.deinit();
try timestamps.resetRecorded(&command_buffer, 0, 2);
try timestamps.writeTimestamp2(&command_buffer, 0, .init(&.{.top_of_pipe}));
// Record work...
try timestamps.writeTimestamp2(&command_buffer, 1, .init(&.{.bottom_of_pipe}));
var readback = try timestamps.getResults(gpa, .{ .count = 2 });
defer readback.deinit(gpa);
switch (readback) {
.ready => |results| useTimestamps(results.values),
.partial => |results| useAvailableTimestamps(results.values, results.availability.?),
.not_ready => {},
}PhysicalDevice.performanceCounters, performanceQueryPasses,
Device.acquireProfilingLock, and Device.calibratedTimestamps cover the profiling extensions
without exposing extension structs or result flags.
Descriptor layouts, pools, borrowed sets, variable counts, updates, copies, update templates, and push descriptors are typed as well. The wrapper validates layout metadata and assembles all temporary pointer/count graphs internally:
var layout = try device.createDescriptorSetLayout(.{ .bindings = &.{.{
.binding = 0,
.descriptor_type = .uniform_buffer,
.stages = .init(&.{.vertex}),
}} });
defer layout.deinit();
var pool = try device.createDescriptorPool(.{
.max_sets = 1,
.sizes = &.{.{ .descriptor_type = .uniform_buffer, .count = 1 }},
});
defer pool.deinit();
var set = try pool.allocate(&layout);
try device.updateDescriptorSets(&.{.{
.destination = &set,
.binding = 0,
.data = .{ .uniform_buffer = &.{.{ .buffer = &uniform_buffer }} },
}}, &.{});Call descriptorSetLayoutSupport before creation when variable descriptor limits matter. A pool
reset invalidates all borrowed sets; stale-set use returns InactiveObject in every build mode.
Alternative descriptor, pipeline, and dispatch models are independent domain modules. Query each family's properties before device creation, enable its generated feature node and extension, then load only the context the application uses:
const shader_properties = try physical_device.shaderObjectProperties();
const descriptor_properties = try physical_device.descriptorBufferProperties();
const generated_properties = try physical_device.generatedCommandsProperties();
const shader_objects = try device.shaderObjectContext();
const descriptor_buffers = try device.descriptorBufferContext(descriptor_properties);
const generated = try device.generatedCommandsContext(generated_properties);vk.shader_objects creates owned shader objects from SPIR-V or captured binary bytes, returns
binary data through allocating and caller-storage forms, and binds typed stage/shader pairs. An
explicit null shader unbinds that stage. Shader stages must match their objects.
vk.descriptor_buffers queries descriptor-set-layout sizes and binding offsets, encodes tagged
sampler/image/buffer/address data into property-sized byte storage, binds typed descriptor-buffer
addresses, sets aligned per-set offsets, and captures opaque replay data for buffers, images,
views, samplers, and KHR acceleration structures. The data tag selects the Vulkan union member;
applications never construct VkDescriptorGetInfoEXT.
vk.generated_commands owns EXT indirect layouts and pipeline- or shader-object execution sets.
Its token union prevents execution-set, push-constant, vertex/index-buffer, draw, dispatch, mesh,
and trace tokens from being interchanged. memoryRequirements, preprocess, and execute use
typed layouts, command buffers, device addresses, and sizes. The older, incompatible NV contract
is intentionally isolated at vk.generated_commands.nv rather than sharing EXT handle types.
Execution graphs are provisional Vulkan functionality and are exposed separately at
vk.execution_graphs. Query executionGraphProperties, load executionGraphContext, create a
graph pipeline with per-stage node metadata, bind the resulting .execution_graph pipeline,
query and initialize its scratch range, then use direct, indirect, or indirect-count dispatch.
Host payload pointers are tagged separately from device addresses. Building vk-zig enables the
official Khronos beta header so all AMDX declarations and command descriptors remain generated.
Every extension operation returns error.MissingCommand when its command was not enabled. Owned
shader objects, indirect layouts, execution sets, and execution-graph pipelines support normal
debug naming through device.setObjectName, checked rawHandle escape hatches, copy-safe owners,
rollback on failed creation, and idempotent deinit.
var command_pool = try device.createCommandPool(.{
.family_index = graphics_family,
.flags = .init(&.{.reset_command_buffer}),
});
defer command_pool.deinit();
var command_buffer = try command_pool.allocateCommandBuffer(.{});
defer command_buffer.deinit();
var frame_finished = try device.createFence(.{ .signaled = true });
defer frame_finished.deinit();
try command_buffer.begin(.{ .flags = .init(&.{.one_time_submit}) });
// imageBarrier and clearColorImage accept typed stages, access, layouts, and ranges.
try command_buffer.end();
try frame_finished.reset();
try queue.submit(.{
.command_buffers = &.{&command_buffer},
.signals = &.{&render_finished},
.fence = &frame_finished,
});
if (try frame_finished.wait(.infinite) == .success) {
try command_buffer.markComplete();
}Submitted command buffers remain .pending until markComplete is called after the associated
fence, semaphore, or queue synchronization completes. Buffers recorded with .simultaneous_use
count each outstanding submission separately. Command pools are externally synchronized and must
not be moved while their command buffers are alive. CommandPool.reset advances its generation;
child buffers observe the reset and return to their initial state on their next operation.
Timeline semaphores use the same owned type without exposing a pNext chain:
var timeline = try device.createSemaphore(.{
.kind = .timeline,
.initial_value = 0,
});
defer timeline.deinit();
try timeline.signal(1);
const reached = try timeline.wait(1, .{ .nanoseconds = 1_000_000 });Use Queue.submit2 for synchronization2 stage masks, timeline values, device-group masks, and
protected submission. Core and VK_KHR_synchronization2 command names are resolved internally:
try queue.submit2(.{
.submits = &.{.{
.waits = &.{.{
.semaphore = &timeline,
.value = 1,
.stage = .init(&.{.all_commands}),
}},
.command_buffers = &.{.{
.command_buffer = &command_buffer,
.device_mask = 1,
}},
.signals = &.{.{
.semaphore = &timeline,
.value = 2,
.stage = .init(&.{.all_commands}),
}},
}},
.fence = &frame_finished,
});See examples/frame_resources.zig for the complete undefined → transfer-destination → present
transition, clear, acquire, submit, and present sequence. submitRaw remains available as an
explicit advanced escape hatch.
The debug-utils wrapper owns Vulkan's C callback trampoline, create-info chain, extension loading,
and messenger lifetime. Application handlers receive a typed Message and do not need vk.raw:
const diagnostic_support = vk.diagnostics.detect(.{
.validation = true,
.debug_messenger = true,
}, available_layers, available_extensions);
const layers: []const [:0]const u8 = if (diagnostic_support.validation_enabled)
&.{vk.layer.khronos_validation.name}
else
&.{};
const debug_messenger: ?vk.debug_utils.Config =
if (diagnostic_support.debug_messenger_enabled)
vk.debug_utils.Config.fromHandler(debugMessage, .{})
else
null;
var instance = try entry.createInstance(.{
.layers = layers,
.validation = .{
.enabled = &.{ .best_practices, .synchronization_validation },
},
.layer_settings = &.{.{
.layer_name = vk.layer.khronos_validation.name,
.name = "validate_sync",
.values = .{ .bools = &.{true} },
}},
.debug_messenger = debug_messenger,
.enumerate_portability = vk.platform_support.metal,
});
defer instance.deinit(); // Destroys the messenger before the instance.
fn debugMessage(message: vk.debug_utils.Message) void {
const text = message.text() orelse "(no message)";
if (message.isError()) {
std.log.err("Vulkan: {s}", .{text});
} else if (message.isWarning()) {
std.log.warn("Vulkan: {s}", .{text});
} else {
std.log.info("Vulkan: {s}", .{text});
}
}Entry.createInstance adds VK_EXT_debug_utils, includes the handler during instance
creation/destruction, creates the persistent messenger, and rolls back partial creation. For a
stateful handler, pass a stable pointer whose lifetime covers the instance:
const Diagnostics = struct {
warning_count: usize = 0,
fn handle(state: *Diagnostics, message: vk.debug_utils.Message) void {
if (message.isWarning()) state.warning_count += 1;
}
};
var diagnostics: Diagnostics = .{};
const debug_messenger = vk.debug_utils.Config.fromHandlerWithContext(
&diagnostics,
.{},
Diagnostics.handle,
);Vulkan may invoke a handler concurrently, so synchronize shared mutable state. A handler may
return vk.debug_utils.HandlerResult instead of void when it intentionally needs to abort
the triggering Vulkan call. The default configuration accepts warning/error severity and
general/validation/performance message types; customize ConfigOptions.severity and
ConfigOptions.message_types with the typed flag sets.
GPU labels use the same extension, but configuring labels without a messenger still requires
adding vk.extension.ext_debug_utils to InstanceOptions.extensions:
try enabled_extensions.append(vk.extension.ext_debug_utils);
try device.setObjectName(&device, "main-device");
try device.setObjectName(&image, "scene-color");
var label = try queue.beginLabelScope(.{
.name = "opaque-pass",
.color = .{ 0.2, 0.4, 1.0, 1.0 },
});
defer label.deinit();The logger and whether unavailable diagnostics are fatal remain application policy. The wrapper
owns the raw callback ABI. debug_utils.advanced.messageFromRawCallback,
debug_utils.advanced.callbackData, and raw command loading are explicit advanced escape hatches
for unusual integrations.
Destroy swapchains before their device and surface, and extension objects/surfaces before their
parent instance. See examples/debug_utils.zig for a complete typed handler.
The implementation layout and dependency direction are documented in
src/README.md.
# Generate raw bindings and command descriptors in zig-out/bindings/.
zig build
zig build bindings
# Compile and run unit tests.
zig build test
zig build test-platforms # Cross-target Xlib + XCB + Wayland declaration check.
# Build the loader/version example, or every example.
zig build example
zig build examples
zig build run-example
# Every example also has a descriptive run step.
zig build run-physical-devices
zig build run-logical-device
# Generate another target/platform combination.
zig build bindings -Dtarget=x86_64-windows-gnu
zig build bindings -Dtarget=x86_64-linux-gnu \
-Dplatform_xlib=true -Dplatform_xcb=true -Dplatform_wayland=true
# Pull Vulkan-Headers main, verify it translates, and update vendored docs/headers.
zig build update
# Pull a particular Vulkan-Headers tag or branch.
zig build update -Dvulkan-ref=v1.4.356The generated command module records dispatch scope, core promotion version, extension provenance,
and every equivalent core/extension spelling. Its core_command_coverage table accounts for every
Vulkan 1.0–1.4 registry command as .wrapped or .raw_only; generation fails if a core command is
missing. Typed wrapper dispatch uses this metadata to resolve promoted aliases automatically.
Running the example requires an installed Vulkan loader; on macOS that normally means a Vulkan
SDK or MoltenVK installation discoverable as libvulkan or libMoltenVK.
See examples/README.md for the complete example matrix and named run commands.
Some current Linux hosts use GCC 16 startup objects containing .sframe relocations that Zig's
linker cannot consume. If a native zig build test or zig build examples fails in
crt1.o:.sframe with unhandled relocation type R_X86_64_PC64, select an explicit target so Zig
uses its target runtime instead of the incompatible host startup object:
# Match the host glibc ABI when testing native loader/runtime integration.
zig build test -Dtarget=x86_64-linux-gnu.2.43
zig build examples -Dtarget=x86_64-linux-gnu.2.43
# Or use musl for portable compile/test validation.
zig build test -Dtarget=x86_64-linux-musl
zig build examples -Dtarget=x86_64-linux-muslReplace x86_64 and the glibc version with the host architecture and ABI when necessary. This
failure occurs while linking the system C runtime before vk-zig tests or Vulkan loader discovery
run; it does not indicate invalid generated bindings or a missing Vulkan installation.
zig build update requires Git and network access. It translates and checks the downloaded
headers before modifying the vendored files, then records the exact commit in
vendor/VULKAN_HEADERS_COMMIT. The matching canonical API inputs are kept in
vendor/registry/vk.xml and vendor/registry/video.xml.
Build with -Dvma for a vulkan_memory module wrapping
VMA 3.3.0, fetched lazily so it
costs nothing when unused.
const vma = @import("vulkan_memory");
var allocator = try vma.Allocator.init(.{
// vk-zig's wrappers, not raw handles -- the allocator reads them itself.
.instance = &instance,
.physical_device = physical_device,
.device = &device,
.api_version = .{ .major = 1, .minor = 3, .patch = 0 },
.functions = .{
.get_instance_proc_addr = loader.vkGetInstanceProcAddr,
.get_device_proc_addr = loader.vkGetDeviceProcAddr,
},
});
defer allocator.deinit(); // outlives every buffer and image
var staging = try allocator.createBuffer(.{
.size = .fromBytes(64 * 1024),
.usage = .init(&.{.transfer_src}),
.allocation = .{ .usage = .auto_prefer_host, .flags = .staging },
});
defer staging.deinit();
var mapping = try staging.map();
defer mapping.unmap();
try mapping.write(Vertex, vertices);Sizes are DeviceSize, usage is BufferUsageFlags, memory properties are
MemoryPropertyFlags -- the same types the rest of vk-zig uses, so nothing here asks you to name a
raw. constant. createBufferRaw and createImageRaw take a Vulkan create-info struct directly
for the cases this does not model, such as a pNext chain.
VMA's CPU-side bookkeeping can go through any std.mem.Allocator, so a leak check or an arena
covers it:
var host: vma.HostAllocator = .init(gpa);
var allocator = try vma.Allocator.init(.{
...,
.host_allocator = &host, // must outlive the allocator
});
defer allocator.deinit();Vulkan's free callback is handed a bare pointer with no size, while a Zig allocator needs the original length and alignment. Each block therefore carries a small header immediately before the pointer VMA sees. Device memory still comes from Vulkan; this is host bookkeeping only.
VMA's public interface is already C, so vulkan_memory.raw is translate-c over vk_mem_alloc.h
with no shim in between. It is compiled with both VMA_STATIC_VULKAN_FUNCTIONS and
VMA_DYNAMIC_VULKAN_FUNCTIONS off, so it calls only the entry points it is handed: it composes with
this package's dynamic loader rather than linking Vulkan itself.
What the wrapper adds over the C API:
| VMA | vulkan_memory |
|---|---|
vmaCreateBuffer + vmaDestroyBuffer |
Buffer with deinit; create, allocate and bind in one call |
VkResult |
an Error set |
vmaMapMemory / vmaUnmapMemory |
Mapping with unmap, write and as(T) |
VmaAllocationCreateFlags bitmask |
AllocationFlags — a packed struct, with a .staging preset |
VmaMemoryUsage constants |
a Usage enum |
zeroed VmaAllocatorCreateInfo |
AllocatorOptions with defaults, and a checked function table |
Allocation-flag bits are asserted against VMA's own constants:
zig build test-vma -Dvma # the wrapper's tests
zig build vma-bindings -Dvma # inspect the translated VMA APIUse vulkan.raw for the full ABI:
const vk = @import("vulkan");
const create_info: vk.raw.VkBufferCreateInfo = .{
.sType = vk.raw.VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = 4096,
.usage = vk.raw.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
.sharingMode = vk.raw.VK_SHARING_MODE_EXCLUSIVE,
};Raw instance flags, application/instance chains, and allocation callbacks are intentionally kept
out of InstanceOptions. Interop code passes them through the explicitly advanced entry point:
var instance = try entry.createInstanceAdvanced(.{
.application_name = "ffi-app",
}, .{
.next = custom_instance_chain,
.allocation_callbacks = custom_callbacks,
});The raw file is generated for the selected target because Vulkan handle representation and platform declarations are target-sensitive. The generated file is useful for inspection and tooling, while applications should normally import the package module instead of copying it.
The Zig package is MIT licensed. Vendored Khronos files retain their upstream
Apache-2.0-or-MIT licensing; see vendor/LICENSE.md and the SPDX headers in those files.
The Vulkan Memory Allocator, fetched only when -Dvma is set, is MIT licensed by
Advanced Micro Devices, Inc. Nothing from it is vendored in this repository.