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 isCopy - 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
RenderGraphtype based onKajiya - 2026 — Project renamed
vk-graph(v0.14)
-
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
-
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/vrfor 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::submitQueue::submit_resourceQueue::submit_resource_dependencies
Caution
Do not call any
Queuesubmission 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:
- Submit all commands the swapchain depends on
- Acquire swapchain
- Submit swapchain commands
- Present swapchain
- 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
GraphandSendto another thread for submission - Build
GraphandDropit without submission Sendresources to other threads or share asArc<T>Clonedevice or pipelines andSendto 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.
-
The internal implementation of
GraphicPipelinedoes 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-windowprovides additional documentation and examples.
Swapchain
The bifurcation of vk-graph along the window abstraction results in two Swapchain types, one in
each crate.
| Type | Usage |
|---|---|
vk_graph::driver::swapchain::Swapchain | Vulkan swapchain smart pointer, contains “raw” functions |
vk_graph_window::swapchain::Swapchain | High-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
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
- VulkanSDK
(Required when setting
debugtotrue) - NVIDIA: nvidia-smi
- AMD: RadeonTop
- RenderDoc
Resources
Caution
All pipelines and resources (buffers, images, and acceleration structures) used in a
Graphmust have been created using the sameDevice.
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:
R | N |
|---|---|
Buffer | BufferNode |
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:
N | R |
|---|---|
BufferNode | Arc<Buffer> |
BufferLeaseNode | Arc<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
Graphmust have been created using the sameDevice.
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 Type | Shaders |
|---|---|
ComputePipeline | Single: must be compute stage |
GraphicPipeline | Multiple: must be a raster stage |
RayTracePipeline | Multiple: must be a ray tracing stage |
Caution
All
Shaderconstructors 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
bytemuckis 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:
| Type | Usage |
|---|---|
AccessType::General | Covers any access - useful for debug, generally avoid for performance reasons |
AccessType::ColorAttachmentWrite | Written as a color attachment during rendering |
AccessType::ComputeShaderReadUniformBuffer | Read as a uniform buffer in a compute shader |
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
blit_imageclear_color_imageclear_depth_stencil_imagecopy_buffercopy_buffer_to_imagecopy_imagecopy_image_to_bufferfill_bufferupdate_buffer
Computing
Graphics
bind_index_bufferbind_vertex_buffersdrawdraw_indexeddraw_indexed_indirectdraw_indexed_indirect_countdraw_indirectdraw_indirect_countset_scissorset_viewport