Skip to content

Latest commit

 

History

52 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vk-zig

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.raw and through the vulkan-raw module;
  • a typed runtime loader with entry, instance, physical-device, device, queue, surface, and swapchain wrappers;
  • resource-safe deinit methods and Zig errors for common VkResult failures;
  • 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

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

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

Use the idiomatic layer

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.

Surfaces and debug utilities

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 caller

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

Commands

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

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

Linux GCC 16 .sframe linker failure

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

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

Vulkan Memory Allocator

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.

Host allocations through a Zig allocator

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 API

Raw bindings

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

Licenses

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.

About

Zig 0.16 Vulkan bindings with generated raw APIs and an idiomatic runtime wrapper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages