Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

vk-graph is a high-performance Vulkan driver for the Rust programming language featuring automated resource management and execution. It is blazingly-fast, built for real-world use, and supports modern Vulkan commands1.

This guide book will walk you through the mental model of this crate and help explain how it maps to Vulkan API usage.

Important

Users should be familiar with the Vulkan specification .

Design

This guide provides a tour of the main public types:

Driver
Buffer, Image, Shader, etc..
Graph
Builder-pattern for Vulkan commands
Queue
Automated graph execution

A Graph is data built dynamically by your program every frame. Once complete, the graph is optimized into a Queue which may be used to submit commands to the Vulkan implementation.

The overhead of building and submitting each graph is typically a few hundred microseconds.

Philosophy

Vulkan is hard. Synchronization is extremely hard. vk-graph makes Vulkan less painful to write and a joy to maintain.

The driver is based off the popular ash crate and vk-sync; reasoned as follows:

  • Everything is constructed from “Info” structs; all info is Copy
  • Match the naming described in the specification
  • Support all modern Vulkan usage1 except video2
  • Don’t use macro-magic or anything that needs to be learned
  • Don’t rely on “helper” functions unless absolutely required

History

  • 2018 — Project started privately as a game engine using Corange
  • 2020 — Project migrated to Github and named screen-13
  • 2022 — v0.2 released with RenderGraph type based on Kajiya
  • 2026 — Project renamed vk-graph (v0.14)

  1. Modern Vulkan usage means no pixel queries. Anything else unsupported is due to there being better options, no current need, or no interest. Please open an issue. ↩2

  2. Video encode/decode is interesting but unsupported. As an alternative consider ffmpeg, libavcodec, or one of the experimental Rust bindings to the Vulkan video API.

Installation

To get started with vk-graph, add it as a project dependency to your Cargo.toml:

# Cargo.toml

[dependencies]
vk-graph = "0.14"

Features

vk-graph puts a lot of functionality behind optional features in order to optimize compile time for the most common use cases. The following features are available.

  • loaded (enabled by default) — Support searching for the Vulkan loader manually at runtime.
  • linked — Link the Vulkan loader at compile time.
  • profile_with_ — Use the specified profiling backend
    • puffin
    • optick
    • superluminal
    • tracy

Required Development Packages

Linux (Debian-like):

  • sudo apt install cmake uuid-dev libfontconfig-dev libssl-dev

Mac OS (10.15 or later):

  • Xcode 12
  • Python 2.7
  • brew install cmake ossp-uuid

Windows:

  • TODO

Vulkan SDK

Debug mode (setting the debug field of DeviceInfo or InstanceInfo to true) is only supported when certain validation layers are installed. The Vulkan SDK provides these layers and a number of helpful tools.

Important

The installed Vulkan SDK version must be at least v1.3.281.

Optional Distribution-Provided Validation Layers

Linux (Debian-like):

  • sudo apt install vulkan-validationlayers

Usage

vk-graph acts as a safe builder-pattern for the Vulkan API.

Typical usage contains:

#![allow(unused)]
fn main() {
use vk_graph::driver::DriverError;
struct Foo { device: vk_graph::driver::device::Device }
impl Foo {
fn test(
    &self,
) {
use vk_graph::driver::ash::vk;
use vk_graph::driver::device::Device;

// A borrow of Device is an argument of many vk-graph functions
let device: &Device = &self.device;
} }
}

Resources

Resources, such as buffers and images, may be created from “Info” structs:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device, sync::AccessType};
use vk_graph::driver::buffer::{Buffer, BufferInfo};
use vk_graph::driver::image::{Image, ImageInfo};
fn test(
    device: &Device,
) -> Result<(), DriverError> {
let usage = vk::BufferUsageFlags::TRANSFER_SRC;
let buffer_info = BufferInfo::device_mem(320 * 200 * 4, usage);
let buffer = Buffer::create(device, buffer_info)?;

let usage = vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST;
let image_info = ImageInfo::image_2d(320, 200, vk::Format::R8G8B8A8_UNORM, usage);
let image = Image::create(device, image_info)?;
Ok(()) }
}

Memory Allocation

vk-graph uses an external memory allocator (currently gpu-allocator) for resource memory allocations.

The allocation strategy provides a large section of memory which is then sub-allocated for any resources which use it. This may lead to fragmentation and memory exhaustion in some scenarios.

Individual buffers or images may use dedicated memory allocations by setting their dedicated field:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device, sync::AccessType};
use vk_graph::driver::buffer::{Buffer, BufferInfo};
use vk_graph::driver::image::{Image, ImageInfo};
fn test(
    device: &Device,
) -> Result<(), DriverError> {
let buffer_info = BufferInfo::device_mem(1, vk::BufferUsageFlags::empty());
let image_info = ImageInfo::image_2d(32, 32, vk::Format::R16_UNORM, vk::ImageUsageFlags::empty());
// The info fields may be used or set directly
let uber_mesh_buf = Buffer::create(
    device,
    BufferInfo {
        dedicated: true,
        ..buffer_info
    }
)?;

// Builder functions are also availble
// (builder and info types are interchangable)
let dedicated_info = image_info.into_builder().dedicated(true);
let important_image = Image::create(device, dedicated_info)?;
Ok(()) }
}

Resources may be bound to a graph as usize handles referred to as “nodes”:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device, sync::AccessType};
use vk_graph::driver::buffer::{Buffer, BufferInfo};
use vk_graph::driver::image::{Image, ImageInfo};
use vk_graph::node::{BufferNode, ImageNode};
fn test(
    device: &Device,
    buffer: Buffer,
    image: Image,
) -> Result<(), DriverError> {
let mut graph = Graph::default();
let buffer: BufferNode = graph.bind_resource(buffer);
let image: ImageNode = graph.bind_resource(image);
Ok(()) }
}

Bound resources may be borrowed from graphs, commands, pipeline commands, or command buffers using their node handle:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::image::Image;
use vk_graph::node::ImageNode;
use std::sync::Arc;
fn test(
    graph: &mut Graph,
    image: ImageNode,
) {
let shared_image: &Arc<Image> = graph.resource(image);

assert_eq!(shared_image.info.width, 320);
}
}

Commands

Nodes may be used with built-in graph commands:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::cmd::ClearColorValue;
use vk_graph::node::ImageNode;
use std::sync::Arc;
fn test(
    graph: &mut Graph,
    image: ImageNode,
) {
graph.clear_color_image(image, ClearColorValue::BLACK_ALPHA_ZERO);
}
}

Graphs may contain many commands:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::cmd::ClearColorValue;
use vk_graph::node::{BufferNode, ImageNode};
use std::sync::Arc;
fn test(
    graph: &mut Graph,
    buffer: BufferNode,
    image: ImageNode,
) {
graph
    .fill_buffer(buffer, 0..320 * 200, 0)
    .copy_buffer_to_image(buffer, image);
}
}

Custom commands enable advanced Vulkan behavior:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::cmd::ClearColorValue;
use vk_graph::driver::{ash::vk, sync::AccessType};
use vk_graph::node::{BufferNode, ImageNode};
use std::sync::Arc;
fn test(
    graph: &mut Graph,
    buffer: BufferNode,
    image: ImageNode,
) {
graph
    .begin_cmd()
    .resource_access(image, AccessType::TransferRead)
    .resource_access(buffer, AccessType::TransferWrite)
    .record_cmd_buf(move |cmd_buf| {
        // Borrow resources from nodes we move into the closure
        let buffer = cmd_buf.resource(buffer);
        let image = cmd_buf.resource(image);

        // Run *any* Vulkan code using ash::Device
        unsafe {
            // Note: for example only, use safe versions!
            cmd_buf.device.cmd_copy_image_to_buffer2(
                cmd_buf.handle,
                &vk::CopyImageToBufferInfo2::default()
                    .src_image(image.handle)
                    .dst_buffer(buffer.handle),
            );
        }
    })
    .end_cmd();
}
}

Pipelines

Pipelines allow shader code to execute as a graph command. A borrow of a pipeline may be bound to record shader-stage specific commands:

// compute.glsl
#version 460 core
#pragma shader_stage(compute)

layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

layout(binding = 0, rgba8) writeonly uniform image2D dstImage;

void main() {
    imageStore(
        dstImage,
        ivec2(gl_GlobalInvocationID.x, gl_GlobalInvocationID.y),
        vec4(0.0)
    );
}
# See: "Shader Compilation"
glslc compute.glsl -o compute.spv
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device, sync::AccessType};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
use vk_graph::node::ImageNode;
fn test(
    graph: &mut Graph,
    device: &Device,
    image: ImageNode,
) -> Result<(), DriverError> {
let pipeline = ComputePipeline::create(
    device,
    ComputePipelineInfo::default(),
    include_bytes!("compute.spv").as_slice(),
)?;

graph
    .begin_cmd()
    .bind_pipeline(&pipeline)
    .shader_resource_access(0, image, AccessType::ComputeShaderWrite)
    .record_cmd_buf(|cmd_buf| {
        cmd_buf.dispatch(320, 200, 1);
    });
Ok(()) }
}

Queue Submission

Completed graphs are submitted to a Vulkan implementation queue for execution.

Note

While executing, resources used in a graph may be bound and used by other graphs. Graph commands access resources in the logical state defined by all prior commands and previously submitted graphs.

Typical programs rely on a single Graph per frame and let their window implementation submit the graph, but they may do so manually:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::pool::lazy::LazyPool;
fn test(
    graph: Graph,
    device: &Device,
) -> Result<(), DriverError> {
// NOTE: This will stall! Use the async functions to check periodically instead
graph
    .into_queue()
    .submit(&mut LazyPool::new(device), 0, 0)?
    .wait_until_executed()?;
Ok(()) }
}

Device Usage

Buffers, images, and acceleration structure resources are created and used by a single Device. All commands which use a resource must execute on the same Device which created the resource.

Device Creation

Most Vulkan operations occur within the context of a logical device, provided by Device (a smart pointer for ash::Device).

Warning

Vulkan has no global state and does not share resources between devices by default.

Do not combine resources from multiple devices! The steps required to share resources across devices are not currently documented.

Headless Operation

For any sort of server-based rendering or similar Vulkan usage without a display, the following is production-ready code used to create a device:

#![allow(unused)]
fn main() {
use vk_graph::driver::DriverError;
use vk_graph::driver::device::{Device, DeviceInfo};
fn test() -> Result<(), DriverError> {
let info = DeviceInfo::default();
let device = Device::new(info)?;

assert_eq!(device.physical_device.instance.info.debug, false);
Ok(()) }
}

Windowed Operation

Prototype and demo code might use the built-in window handler, which creates a Device during window creation:

# Cargo.toml

[dependencies]
vk-graph-window = "0.14"
#![allow(unused)]
fn main() {
use vk_graph::driver::device::Device;
use vk_graph_window::WindowError;
fn test() -> Result<(), WindowError> {
use vk_graph_window::WindowBuilder;

let window = WindowBuilder::default().build()?;

// Before run
let _: &Device = &window.device;

window.run(|frame| {
    // During any frame
    let _: &Device = frame.device;
})?;
Ok(()) }
}

Advanced

There are several scenarios that require advanced Device creation techniques:

  • Allowing user-selection of device
  • Custom Window(s) handling
  • FFI with OpenXR (or similar)
  • Unsupported drivers/platforms

Device Selection

The entrypoint is an Instance from which the available hardware is enumerated and inspected:

#![allow(unused)]
fn main() {
use vk_graph::driver::DriverError;
use vk_graph::driver::device::Device;
use vk_graph::driver::instance::{Instance, InstanceInfo};
fn test() -> Result<(), DriverError> {
let instance = Instance::new(InstanceInfo::default())?;
let physical_devices = Instance::physical_devices(&instance)?;

for physical_device in physical_devices {
    // We are looking for a device with support for these features
    if !physical_device.swapchain_ext
    || !physical_device.ray_trace_features.ray_tracing_pipeline {
        continue;
    }

    let _: Device = physical_device.try_into_device()?;
}
Ok(()) }
}

Native Device Usage

Some scenarios require the Vulkan instance and/or device be created by other code and accepted for use by vk-graph:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::DriverError;
use vk_graph::driver::ash::{self, vk};
use vk_graph::driver::device::Device;
use vk_graph::driver::instance::Instance;
fn test() -> Result<(), DriverError> {
// Native ash types from somewhere else
let entry: ash::Entry = todo!();
let instance: vk::Instance = todo!();
let physical_device: vk::PhysicalDevice = todo!();

// vk-graph types
let instance = Instance::from_entry(entry, instance)?;
let physical_device = Instance::physical_device(&instance, physical_device)?;

// Use our PhysicalDevice to create a native ash::Device (OpenXR requires this)
let device: ash::Device = unsafe {
    physical_device
        .create_ash_device(|create_info| {
            // Somewhere else also provides the logical device!
            let device: vk::Device = todo!();

            let device: ash::Device = unsafe {
                ash::Device::load(instance.fp_v1_0(), device)
            };

            Ok(device)
        })
}.unwrap();

// Create a Device from their native stuff
let device = Device::try_from_ash_device(device, physical_device)?;
Ok(()) }
}

Tip

See examples/vr for an in-depth example of native device usage.

Shader Compilation

vk-graph does not provide any shader compiler or require any specific shading language. Users must provide SPIR-V binary-format shaders.

Tip

See Hot Reload for details on a shader compiler provided as a separate crate.

Examples using multiple shading languages and compilers are provided in the examples/ directory.

Shader-stage #pragma

This applies to GLSL and Shaderc generally but you might find similar functionality with other languages and compilers.

// shader.glsl
#version 460 core
#pragma shader_stage(compute)

void main() {
    // Some code here
}
glslc shader.glsl -o shader.spv
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::driver::shader::Shader;
let spirv = include_bytes!("shader.spv");

// #pragma allows for from_spirv syntax:
let shader = Shader::from_spirv(
    spirv.as_slice(),
);

// Without this #pragma we must specify stage:
let shader = Shader::new_compute(
    spirv.as_slice(),
);
}

Threading Behavior

vk-graph is intended to provide scalable performance when used on multiple host threads. All commands support being called concurrently from multiple threads, but resources are defined to be externally synchronized. This means that the caller must guarantee that no more than one thread is submitting a resource at a given time.

More precisely, vk-graph stores the most recent access type of each subresource of a resource. As commands are submitted to the Vulkan implementation queue, the internal state of these resources is updated.

Resource state is updated during the following function calls:

  • Queue::submit
  • Queue::submit_resource
  • Queue::submit_resource_dependencies

Caution

Do not call any Queue submission function accessing buffers, images, or acceleration structures currently being submitted on other threads.

Execution

The provided Queue submission functions are designed to support a typical swapchain-based workflow:

  1. Submit all commands the swapchain depends on
  2. Acquire swapchain
  3. Submit swapchain commands
  4. Present swapchain
  5. Submit any final unrelated commands

Safe Patterns

Resources (buffers, images, or acceleration structures) are the only mutable types which require any thread safety notes. All other types provided by vk-graph are immutable data structures or Vulkan handle smart pointers.

For example, there is no race condition or thread contention caused by using the same pipeline on two threads.1 In fact, there is no runtime overhead at all from this.

Additionally, it is safe to build Graph instances, bind resources, record command buffers, and call Graph::into_queue at any time on any thread.

These patterns are safe:

  • Build Graph and Send to another thread for submission
  • Build Graph and Drop it without submission
  • Send resources to other threads or share as Arc<T>
  • Clone device or pipelines and Send to other threads

Risky Patterns

Host-mappable buffers require extra understanding to use properly.

The contents of a buffer are undefined from the time of submission until that Queue has been fully executed, as indicated by CommandBuffer::has_executed. This means that you should not call Buffer::mapped_slice during any submission or execution accessing that memory.

See: examples/cpu_readback.rs


  1. The internal implementation of GraphicPipeline does do a bit of caching in order to improve performance, however this behavior should not generate issues with any reasonable workload.

Window Handling

vk-graph does not directly provide any window implementation. Instead an accessory crate, vk-graph-window is provided, based on winit.

Tip

vk-graph-window provides additional documentation and examples.

Swapchain

The bifurcation of vk-graph along the window abstraction results in two Swapchain types, one in each crate.

TypeUsage
vk_graph::driver::swapchain::SwapchainVulkan swapchain smart pointer, contains “raw” functions
vk_graph_window::swapchain::SwapchainHigh-level display interface for building window handlers

OpenXR

Virtual reality support via OpenXR is provided as an example which also implements a swapchain.

MoltenVK

Vulkan is emulated on Apple platforms using MoltenVK.

Warning

MoltenVK does not support all Vulkan features and has limited extension and format support. Pay particular attention to these areas:

  • Bindless descriptor count limit
  • Hardware queues provided for execution
  • Indirect drawing command support
  • Image format support

Support for MoltenVK is best-effort and may not always be up to date. In the event that any vk-graph workflow does not work using MoltenVK please open an issue .

Debugging

Debug mode (setting the debug field of DeviceInfo or InstanceInfo to true) is supported only when a compatible Vulkan SDK is installed.

Important

The installed Vulkan SDK version must be at least v1.3.281.

While in debug mode vk-graph watches for errors, warnings, and certain performance warnings emitted from any currently enabled Vulkan debug application layers. Emitted events will cause the active thread to be parked and log a message indicating how to attach a debugger.

Logging

vk-graph uses log v0.4 for low-overhead logging.

To enable logging, set the RUST_LOG environment variable to trace, debug, info, warn or error and initialize the logging provider of your choice. Examples use pretty_env_logger.

You may also filter messages, for example:

RUST_LOG=vk_graph::driver=trace,vk_graph=warn cargo run --example ray_trace
TRACE vk_graph::driver::instance > created a Vulkan instance
DEBUG vk_graph::driver::physical_device > physical device: NVIDIA GeForce RTX 3090
DEBUG vk_graph::driver::physical_device > extension "VK_KHR_16bit_storage" v1
DEBUG vk_graph::driver::physical_device > extension "VK_KHR_8bit_storage" v1
DEBUG vk_graph::driver::physical_device > extension "VK_KHR_acceleration_structure" v13
...

Performance Profiling

vk-graph uses profiling v1.0 and supports multiple profiling providers. When not in use profiling has zero cost.

To enable profiling, compile with one of the profile-with-* features enabled and initialize the profiling provider of your choice.

Example using puffin:

cargo run --features profile-with-puffin --release --example vsm_omni
Flamegraph of performance data

Comparing Results

Always profile code using a release-mode build.

You may need to disable CPU thermal throttling in order to get consistent results on some platforms. The inconsistent results are certainly valid, but they do not help in accurately measuring potential changes. This may be done on Intel Linux machines by modifying the Intel P-State driver:

echo 100 | sudo tee /sys/devices/system/cpu/intel_pstate/min_perf_pct

(Source )

Helpful tools

Resources

Caution

All pipelines and resources (buffers, images, and acceleration structures) used in a Graph must have been created using the same Device.

Owned resources are created from Device references. They may be bound directly to graphs.

An Arc<T> or &Arc<T> of any resource may be bound to a graph if the resource needs to be referenced in future graphs.

Binding

Binding resources to a graph produces a “Node” handle which may be used in commands and shader pipelines.

Example for buffers using Graph::bind_resource<R, N>(&mut self, resource: R) -> N:

RN
BufferBufferNode
Arc<Buffer>BufferNode
Lease<Buffer>BufferLeaseNode
Arc<Lease<Buffer>>BufferLeaseNode

Borrowing

Resources may be borrowed from a graph.

Example for buffers using Graph::resource<N, R>(&self, node: N) -> &R:

NR
BufferNodeArc<Buffer>
BufferLeaseNodeArc<Lease<Buffer>>

Bound Resource Nodes

The concept of binding resources to graphs as node handles exists to support the callback-style command buffer recording provided by vk-graph.

Commands are recorded in logical order, but the execution is re-ordered for performance and so a closure argument is provided to call Vulkan command buffer functions. The use of a small and Copy node handle allows resource handles to be moved into command buffer closures without Arc::clone.

Additionally, node handles support internal optimizations by providing direct indexed access to graph data structures.

Pooling Resources

Pooled resources are leased from Pool implementations. Dropped leases return to the pool.

The Lease<T> type otherwise acts identically to an owned resource.

Aliased Resources

Resource aliasing is available using the AliasWrapper and any Pool.

Aliased resources allow extremely optimized programs to ensure minimal resources during complex graphs.

Buffers

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device};
use vk_graph::driver::buffer::{Buffer, BufferInfo, BufferInfoBuilder};
fn test(
    device: &Device,
) -> Result<(), DriverError> {
let size = 1_024;
let usage = vk::BufferUsageFlags::STORAGE_BUFFER;

// Create buffer info multiple ways:
let info = BufferInfo {
    alignment: 1,
    dedicated: false,
    host_read: false,
    host_write: false,
    size,
    usage,
};
let device_mem = BufferInfo::device_mem(size, usage);
let host_mem = BufferInfo::host_mem(size, usage);

assert_eq!(info, device_mem);
assert_ne!(info, host_mem);

// Builder pattern
let same_info = BufferInfoBuilder::default()
    .size(size)
    .usage(usage);

// Info built from other info
let more_info = host_mem
    .into_builder()
    .usage(usage | vk::BufferUsageFlags::INDIRECT_BUFFER)
    .build();

// There is a helper function for creating buffers from a slice
let data = [1u8, 2, 3, 4];
let buffer = Buffer::create_from_slice(device, usage, &data)?;

// This is equivalent to:
let mut buffer = Buffer::create(device, host_mem)?;
buffer.copy_from_slice(0, &data);

// Or use the std copy_from_slice (it panics if size != range)
let mut buffer = Buffer::create(device, host_mem)?;
buffer.mapped_slice_mut().copy_from_slice(&data);

// The provided fields are helpful:
assert_eq!(buffer.device, *device);
assert_eq!(buffer.info, host_mem);
assert_ne!(buffer.handle, vk::Buffer::null());

// Buffer "subresources" are just ranges of that buffer
let my_subresource = 0..size;
Ok(()) }
}

Images

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device};
use vk_graph::driver::image::{Image, ImageInfo, ImageInfoBuilder, SampleCount};
use vk_graph::driver::image::{ImageViewInfo, ImageViewInfoBuilder};
fn test(
    device: &Device,
) -> Result<(), DriverError> {
let (width, height) = (320, 200);
let usage = vk::ImageUsageFlags::SAMPLED;
let fmt = vk::Format::R8G8B8A8_UNORM;

// Create image info multiple ways
let info = ImageInfo {
    array_layer_count: 1,
    dedicated: false,
    depth: 1,
    flags: vk::ImageCreateFlags::empty(),
    fmt,
    height,
    mip_level_count: 1,
    sample_count: SampleCount::Type1,
    tiling: vk::ImageTiling::OPTIMAL,
    ty: vk::ImageType::TYPE_2D,
    usage,
    width,
};
let other_info = ImageInfo::image_2d(width, height, fmt, usage);
let cube_info = ImageInfo::cube(width, fmt, usage);

assert_eq!(info, other_info);
assert_ne!(info, cube_info);

// Builder pattern
let same_info = ImageInfoBuilder::default()
    .width(width)
    .height(height)
    .depth(1)
    .fmt(fmt)
    .usage(usage)
    .ty(vk::ImageType::TYPE_2D);

// Info built from other info
let array_info = cube_info
    .into_builder()
    .flags(vk::ImageCreateFlags::TYPE_2D_ARRAY_COMPATIBLE)
    .build();

// Images are created simply
let image = Image::create(device, info)?;

// For interop this may be handy:
let image = Image::from_raw(device, vk::Image::null(), info);

// The provided fields are helpful:
assert_eq!(image.device, *device);
assert_eq!(image.info, info);
assert_ne!(image.handle, vk::Image::null());

// Image "subresources" are the native type:
let my_subresource = vk::ImageSubresourceRange {
    aspect_mask: vk::ImageAspectFlags::COLOR,
    base_mip_level: 0,
    level_count: 1,
    base_array_layer: 0,
    layer_count: 1,
};

// Image views are also subresources:
let image_view = ImageViewInfo {
    array_layer_count: 1,
    aspect_mask: vk::ImageAspectFlags::COLOR,
    base_array_layer: 0,
    base_mip_level: 0,
    fmt,
    mip_level_count: 1,
    ty: vk::ImageViewType::TYPE_2D,
};

// Image views have the same builder functionality:
let other_view = ImageViewInfoBuilder::default();

// Image views can be inferred from the whole image info:
let addl_view = info.into_image_view();

assert_eq!(image_view, addl_view);
Ok(()) }
}

Acceleration Structures

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device};
use vk_graph::driver::accel_struct::{
  AccelerationStructure, AccelerationStructureGeometry, AccelerationStructureGeometryData,
  AccelerationStructureGeometryInfo, AccelerationStructureInfo, AccelerationStructureInfoBuilder,
  AccelerationStructureSize, DeviceOrHostAddress
};
use vk_graph::driver::buffer::Buffer;
fn test(
    device: &Device,
) -> Result<(), DriverError> {
// Some buffer holding geometry data
let buffer: Buffer = todo!();

// Some sample geometry to put into a BLAS:
let geometry = AccelerationStructureGeometryData::Triangles {
    index_addr: DeviceOrHostAddress::DeviceAddress(
        buffer.device_address()
    ),
    index_type: vk::IndexType::UINT16,
    max_vertex: 100,
    transform_addr: None,
    vertex_addr: DeviceOrHostAddress::DeviceAddress(
        buffer.device_address() + 2_048
    ),
    vertex_format: vk::Format::R32G32B32_SFLOAT,
    vertex_stride: 12,
};
let geom = AccelerationStructureGeometry {
    max_primitive_count: 120,
    flags: vk::GeometryFlagsKHR::OPAQUE,
    geometry,
};
let build_range = vk::AccelerationStructureBuildRangeInfoKHR {
    primitive_count: 120,
    primitive_offset: 0,
    first_vertex: 0,
    transform_offset: 0,
};
let ty = vk::AccelerationStructureTypeKHR::BOTTOM_LEVEL;
let geom_info = AccelerationStructureGeometryInfo {
    ty,
    flags: vk::BuildAccelerationStructureFlagsKHR::ALLOW_UPDATE,
    geometries: vec![
        (geom, build_range),
    ].into_boxed_slice(),
};

// Use helper function to find size
let AccelerationStructureSize {
    build_size,
    ..
} = AccelerationStructure::size_of(device, &geom_info);

// Create acceleration structure info multiple ways:
let info = AccelerationStructureInfo {
    ty,
    size: build_size,
};
let other_info = AccelerationStructureInfo::blas(build_size);

assert_eq!(info, other_info);

// Builder pattern
let same_info = AccelerationStructureInfoBuilder::default()
    .ty(ty)
    .size(build_size);

// Create directly from info
let blas = AccelerationStructure::create(device, info)?;

// Info built from other info
// Note: Never calculate size/always get from function
let more_info = blas
    .info
    .into_builder()
    .size(build_size * 2)
    .build();

// The provided fields are helpful:
assert_eq!(blas.buffer.device, *device);
assert_eq!(blas.info, info);
assert_ne!(blas.buffer.handle, vk::Buffer::null());
assert_ne!(blas.handle, vk::AccelerationStructureKHR::null());

// Acceleration structures have no "subresources" and are bound whole
Ok(()) }
}

Pipelines

Caution

All pipelines and resources (buffers, images, and acceleration structures) used in a Graph must have been created using the same Device.

Pipelines are created from Device references. They may be bound to graph commands.

#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
fn test(device: &Device) -> Result<(), DriverError> {
let info = ComputePipelineInfo::default();
let shader = include_bytes!("shader.spv");
let pipeline = ComputePipeline::create(device, info, shader.as_slice())?;

let mut graph = Graph::default()
    .begin_cmd()
    .bind_pipeline(&pipeline)
    .record_cmd_buf(|cmd_buf| {
        // Record vulkan commands here
    })
    .end_cmd();
Ok(()) }
}

Pipelines are cheap to Clone and should be cached in between use. The recommendation is to bind a borrow of a pipeline to when beginning a command.

Commands

A graph command is the smallest unit which the Queue type will schedule for execution.

Calls to Graph::begin_cmd (and, optionally Graph::end_cmd) define a single graph command which will execute in physical order as recorded. During graph command recording you may change pipelines, modify shader descriptor bindings, or otherwise modify the state of the command buffer.

Example:

#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
fn test(device: &Device) -> Result<(), DriverError> {
let info = ComputePipelineInfo::default();

let fire = include_bytes!("fire.spv");
let fire = ComputePipeline::create(device, info, fire.as_slice())?;

let water = include_bytes!("water.spv");
let water = ComputePipeline::create(device, info, water.as_slice())?;

let mut graph = Graph::default();
graph
    .begin_cmd()
    .bind_pipeline(&fire)
    .record_cmd_buf(|cmd_buf| {
        println!("1st");
    })
    .bind_pipeline(&water)
    .record_cmd_buf(|cmd_buf| {
        println!("2nd");
    })
    .bind_pipeline(&fire)
    .record_cmd_buf(|cmd_buf| {
        println!("3rd");
    })
    .end_cmd()
    .begin_cmd()
    .bind_pipeline(&water)
    .record_cmd_buf(|cmd_buf| {
        println!("4th");
    });
Ok(()) }
}

A call to Graph::end_cmd is not requried. The end-command method exists to support builder-style function-chaining. In the above example two commands are built and added to the graph.

Shaders

Compute, graphic, and ray trace pipelines require one or more shaders:

Pipeline TypeShaders
ComputePipelineSingle: must be compute stage
GraphicPipelineMultiple: must be a raster stage
RayTracePipelineMultiple: must be a ray tracing stage

Caution

All Shader constructors panic when provided with invalid SPIR-V shader code.

The Shader type uses a builder pattern:

#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
use vk_graph::driver::shader::{SamplerInfo, Shader};
fn test(device: &Device) -> Result<(), DriverError> {
// Pipelines may be created using "shader" or "custom":
let code = include_bytes!("raygen.spv");
let shader = Shader::from_spirv(code.as_slice());
let custom = shader
                .entry_name("main_but_faster")
                .image_sampler(0, SamplerInfo::default())
                .image_sampler(1, SamplerInfo::LINEAR);
Ok(()) }
}

Hot Reload

An accessory crate is provided to support automatic reloading of changed shader pipelines.

vk-graph-hot uses a file watcher and Shaderc. It may be used directly or may be swapped out using a build feature:

# Cargo.toml

[features]
default = []
hot = ["dep:vk-graph-hot"]

[dependencies]
vk-graph = "0.14"
vk-graph-hot = { version = "0.14", optional = true }
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::driver::{DriverError, compute::ComputePipelineInfo, device::Device};

#[cfg(feature = "hot")]
use vk_graph_hot::{
    HotComputePipeline as ComputePipeline,
    HotShader,
};

#[cfg(not(feature = "hot"))]
use vk_graph::driver::{
    compute::ComputePipeline,
    shader::Shader,
};

pub fn create_fire_pipeline(
    device: &Device,
) -> Result<ComputePipeline, DriverError> {
    let info = ComputePipelineInfo::default();

    #[cfg(feature = "hot")]
    let shader = HotShader::from_path("fire.glsl");

    #[cfg(not(feature = "hot"))]
    let shader = Shader::from_spirv(include_bytes!("fire.spv").as_slice());

    ComputePipeline::create(device, info, shader)
}
}

Note

The hot versions of each type support all features, options, and usage provided by the normal types. This include public fields, available information, and graph binding features.

Push Constants

Command buffers may update a very small data cache which shaders may read during execution using push constants.

It is recommended to target 128 bytes as the maximum push constant data size.

// render_mesh.glsl
#version 460 core

layout(push_constant) uniform PushConstants {
    layout(offset = 0) uint mesh_index;
};

...
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
use vk_graph::driver::shader::Shader;
fn test(device: &Device) -> Result<(), DriverError> {
let info = ComputePipelineInfo::default();
let code = include_bytes!("render_mesh.spv");
let shader = Shader::new_compute(code.as_slice());
let pipeline = ComputePipeline::create(device, info, shader)?;

let mut graph = Graph::default();
let data = 42u32.to_ne_bytes();

graph
    .begin_cmd()
    .bind_pipeline(&pipeline)
    .record_cmd_buf(move |cmd_buf| {
        cmd_buf
            .push_constants(0, &data)
            .dispatch(1, 1, 1);
    });
Ok(()) }
}

Tip

A crate such as bytemuck is helpful for converting Rust structures to bytes suitable for push constant usage. See the example code for more.

Specialization

Pipeline specialization allows pre-compiled SPIR-V binary shaders to be specialized with constant values specified at run-time.

The Vulkan implementation may use these constant values to generate optimized shader code.

vk-graph provides SpecializationMap as an easy-to-use way of storing the data and lookup entries required to use this feature.

// kaboom.glsl
#version 460 core

layout(constant_id = 0) const float INFERNO_EPSILON = 0.999;
layout(constant_id = 1) const float COEFF_OF_BOOM = 1.4;
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::driver::{DriverError, device::Device};
use vk_graph::driver::shader::{Shader, SpecializationMap};
fn test(device: &Device) -> Result<(), DriverError> {
use bytemuck::bytes_of;

let kaboom = include_bytes!("kaboom.spv");

// Use this shader for the glsl-specified values:
let shader = Shader::new_compute(kaboom.as_slice());

let better_consts = [
    0.99999f32,
    1.0,
];
let better_consts = bytes_of(&better_consts);
let spec = SpecializationMap::new(better_consts)
    .constant(0, 0, 4)
    .constant(1, 4, 8);

// Use this shader for the updated run-time values:
let spec_shader = shader.specialization(spec);
Ok(()) }
}

Synchronization

vk-graph provides a high-performance abstraction over Vulkan synchronization which retains the low driver overhead of correctly synchronized command buffers.

Pipeline Barriers

Vulkan specifies that resources and pipelines will have synchronized access when barriers are inserted into the command stream. Unsynchronized access results in undefined behavior.

Tip

Unsynchronized access may be detected through debug assertions or Vulkan SDK debugging layers.

Access Type Abstraction

vk-graph uses an enumeration of possible states to define all supported pipeline barriers in an easy-to-use way.

Sample access types:

TypeUsage
AccessType::GeneralCovers any access - useful for debug, generally avoid for performance reasons
AccessType::ColorAttachmentWriteWritten as a color attachment during rendering
AccessType::ComputeShaderReadUniformBufferRead as a uniform buffer in a compute shader

(Full list)

Resource Access

The required access varies depending on the function being called and what the Vulkan specification requires for a given command.

Generally, access must be specified before each command uses a resource. It appears as an “access” function call:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device, sync::AccessType};
use vk_graph::node::{BufferNode, ImageNode};
fn test(
    device: &Device,
    some_buffer: BufferNode,
    some_image: ImageNode,
) -> Result<(), DriverError> {
let mut graph = Graph::default();
graph
    .begin_cmd()
    .resource_access(some_buffer, AccessType::TransferRead)
    .resource_access(some_image, AccessType::TransferWrite)
    .record_cmd_buf(|cmd_buf| {
        // we are synchronized!
        // You may:
        //  - Read some_buffer
        //  - Write some_image
    });
Ok(()) }
}

Resource access is specified for and consumed by the following command buffer recording. For multiple accesses, use multiple “access” and “record” function calls:

#![allow(unused)]
fn main() {
use vk_graph::Graph;
use vk_graph::driver::{DriverError, device::Device, sync::AccessType};
use vk_graph::node::{BufferNode, ImageNode};
fn test(
    device: &Device,
    buffer: BufferNode,
    image: ImageNode,
) -> Result<(), DriverError> {
let mut graph = Graph::default();
graph
    .begin_cmd()
    .resource_access(buffer, AccessType::TransferRead)
    .resource_access(image, AccessType::TransferWrite)
    .record_cmd_buf(|cmd_buf| {
        // Safe to copy buffer to image
    })
    .resource_access(image, AccessType::TransferRead)
    .resource_access(buffer, AccessType::TransferWrite)
    .record_cmd_buf(|cmd_buf| {
        // Safe to copy image to buffer
    });
Ok(()) }
}

Shader Resource Access

When a resource (buffer, image, or acceleration structure) is accessed from a shader the shader_resource_access function is used:

// clear_image.glsl
#version 460 core
#pragma shader_stage(compute)

layout(binding = 42, rgba8) writeonly uniform image2D dstImage;

void main() {
    imageStore(
        dstImage,
        ivec2(gl_GlobalInvocationID.x, gl_GlobalInvocationID.y),
        vec4(0)
    );
}
#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device, sync::AccessType};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
use vk_graph::driver::image::{Image, ImageInfo};
fn test(device: &Device) -> Result<(), DriverError> {
let mut graph = Graph::default();

let fmt = vk::Format::R8G8B8A8_UNORM;
let usage = vk::ImageUsageFlags::STORAGE;
let info = ImageInfo::image_2d(32, 32, fmt, usage);
let image = graph.bind_resource(Image::create(
    device,
    info,
)?);

graph
    .begin_cmd()
    .bind_pipeline(ComputePipeline::create(
        device,
        ComputePipelineInfo::default(),
        include_bytes!("clear_image.spv").as_slice(),
    )?)
    .shader_resource_access(42, image, AccessType::ComputeShaderWrite)
    .record_cmd_buf(|cmd_buf| {
        cmd_buf.dispatch(32, 32, 1);
    });
Ok(()) }
}

Subresource Access

Buffer ranges and image views are referred to as subresource ranges and accessed using “subresource” function variants:

#![allow(unused)]
fn main() {
macro_rules! include_bytes { ($path:expr) => { [0u8] }; }
use vk_graph::Graph;
use vk_graph::driver::{DriverError, ash::vk, device::Device, sync::AccessType};
use vk_graph::driver::compute::{ComputePipeline, ComputePipelineInfo};
use vk_graph::driver::image::{Image, ImageInfo};
fn test(device: &Device) -> Result<(), DriverError> {
let mut graph = Graph::default();

let fmt = vk::Format::R8G8B8A8_UNORM;
let usage = vk::ImageUsageFlags::STORAGE;
let info = ImageInfo::image_2d(32, 32, fmt, usage);
let image = graph.bind_resource(Image::create(
    device,
    info,
)?);

graph
    .begin_cmd()
    .bind_pipeline(ComputePipeline::create(
        device,
        ComputePipelineInfo::default(),
        include_bytes!("clear_image.spv").as_slice(),
    )?)
    .shader_subresource_access(42, image, info, AccessType::ComputeShaderWrite)
    .record_cmd_buf(|cmd_buf| {
        cmd_buf.dispatch(32, 32, 1);
    });
Ok(()) }
}

Built-In Commands

The commands directly attached to a Graph, such as Graph::copy_buffer_to_image, do not require any access function calls.

The source code for these built-in commands uses public graph functions and provides good examples of typical usage.

Commands

  1. blit_image
  2. clear_color_image
  3. clear_depth_stencil_image
  4. copy_buffer
  5. copy_buffer_to_image
  6. copy_image
  7. copy_image_to_buffer
  8. fill_buffer
  9. update_buffer

Computing

  1. dispatch
  2. dispatch_base
  3. dispatch_indirect

Graphics

  1. bind_index_buffer
  2. bind_vertex_buffers
  3. draw
  4. draw_indexed
  5. draw_indexed_indirect
  6. draw_indexed_indirect_count
  7. draw_indirect
  8. draw_indirect_count
  9. set_scissor
  10. set_viewport

Ray Tracing

  1. build_accel_struct
  2. build_accel_struct_indirect
  3. set_stack_size
  4. trace_rays
  5. trace_rays_indirect
  6. update_accel_struct
  7. update_accel_struct_indirect