- Rust 100%
gpu #
This is my opinionated Vulkan rendering interface. It targets Vulkan 1.3 with VK_EXT_shader_object and VK_EXT_descriptor_buffer.
Buffer resources are entirely bindless and addressed through GpuPtr<T>, a shared host/shader abstraction over Vulkan device addresses: it is an opaque typed address on the CPU and a directly dereferenceable pointer in rust-gpu shaders. Images and samplers live in bindless descriptor buffers. Together with shader objects and dynamic rendering, this removes per-draw descriptor-set updates, graphics-pipeline management, render-pass objects, and framebuffers from client code.
My renderer builds on this with persistently mapped per-frame arenas whose reuse is gated by timeline semaphores. Per-frame draw and dispatch data can be copied directly into GPU-visible memory and passed to shaders as device addresses through push constants, without per-object bindings or staging copies.
Architecture #
flowchart LR
subgraph CPU[CPU / renderer]
DATA["Draw and dispatch data"]
ARENA["Persistently mapped frame arena<br/>Ptr<T> = CPU pointer + GpuPtr<T>"]
RES["Images and samplers"]
SLOTS["Typed heap slots<br/>SampledSlot / StorageSlot / SamplerSlot"]
RECORD["Thin command API"]
end
subgraph VK[Vulkan backend]
PC["Push constants<br/>1 compute or 3 graphics addresses"]
DB["Descriptor buffers<br/>sampled / storage / samplers"]
SO["Shader objects + dynamic state"]
DR["Dynamic rendering"]
TL["Timeline semaphores"]
end
subgraph GPU[GPU]
SHADER["rust-gpu shader"]
MEMORY["Buffer device-address memory"]
IMAGES["Sampled and storage images"]
end
DATA -->|"write / memcpy"| ARENA
ARENA --> MEMORY
ARENA -->|"GpuPtr address"| PC
PC --> SHADER
SHADER -->|"typed dereference"| MEMORY
RES --> SLOTS --> DB
DB --> SHADER
SHADER --> IMAGES
RECORD --> SO --> SHADER
RECORD --> DR --> IMAGES
TL -. "retires command buffers and gates arena reuse" .-> ARENA
Buffers use Vulkan buffer device addresses as their shader-facing identity. A Ptr<T> pairs the persistently mapped CPU address with its GpuPtr<T> device address; only the device address crosses the push-constant boundary. Images cannot be accessed through buffer device addresses, so sampled images, storage images, and samplers occupy three device-owned descriptor heaps, bound at the start of every command buffer and referenced by typed slot indices.
Examples #
A complete windowed sample lives in examples/triangle.rs (cargo run --example triangle): one indexed draw pulling vertex data through device-address pointers, with geometry placed by gpu_util::Arena and rust-gpu SPIR-V built from examples/triangle_shaders/. The snippets below are excerpts of the same API.
Typed data from the host to a compute shader #
A shared ABI struct can contain pointers to any other GPU-visible allocations:
use gpu::GpuPtr;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ScaleData {
pub input: GpuPtr<f32>,
pub output: GpuPtr<f32>,
pub count: u32,
pub scale: f32,
}
The host fills mapped memory, then passes only the dispatch-data address through push constants:
use gpu::{Gpu, GpuDesc, Memory, Queue};
let gpu = Gpu::new(GpuDesc {
validation: true,
..Default::default()
})
.expect("Vulkan 1.3 device");
let input = gpu.alloc_slice::<f32>(values.len() as u64, Memory::Default);
let output = gpu.alloc_slice::<f32>(values.len() as u64, Memory::Default);
let data = gpu.alloc::<ScaleData>(Memory::Default);
unsafe {
std::ptr::copy_nonoverlapping(values.as_ptr(), input.cpu, values.len());
data.cpu.write(ScaleData {
input: input.gpu,
output: output.gpu,
count: values.len() as u32,
scale: 2.0,
});
}
let shader = gpu.shader_create_compute(&compiled_spirv, 64, 1, 1, "scale");
let done = gpu.semaphore_create(0);
let cb = gpu.commands_begin(Queue::Compute);
gpu.cmd_set_compute_shader(cb, shader);
gpu.cmd_dispatch(cb, data.gpu, (values.len() as u32).div_ceil(64), 1, 1);
gpu.queue_submit(Queue::Compute, &[cb], &[], &[(done, 1)]);
// The allocations remain live until this timeline value completes.
gpu.semaphore_wait(done, 1);
The same ScaleData definition is compiled for SPIR-V. On the shader target, GpuPtr<T> implements typed dereference and indexing:
use glam::UVec3;
use gpu_ptr::GpuPtr;
use spirv_std::spirv;
#[spirv(compute(threads(64)))]
pub fn scale(
#[spirv(push_constant)] data_ptr: &GpuPtr<ScaleData>,
#[spirv(global_invocation_id)] gid: UVec3,
) {
let data = &**data_ptr;
if gid.x < data.count {
let mut output = data.output;
output[gid.x] = data.input[gid.x] * data.scale;
}
}
Registering bindless resources and recording a draw #
Registering a resource with the device's heap writes its descriptor once and yields a typed slot. Client code carries slot indices rather than descriptor sets:
use gpu::{
Gpu, LoadOp, Queue, RenderAttachment, RenderPassDesc, SamplerDesc, StoreOp,
TextureViewDesc,
};
// Long-lived setup. Slot 0 in each heap is reserved as the null sentinel;
// heap capacities are set in `GpuDesc`.
let albedo = gpu.heap_add_sampled(albedo_texture, TextureViewDesc::default());
let linear_sampler = gpu.heap_add_sampler(SamplerDesc::default());
#[repr(C)]
#[derive(Clone, Copy)]
struct MaterialData {
albedo: u32,
sampler: u32,
}
let material = gpu.alloc::<MaterialData>(gpu::Memory::Default);
unsafe {
material.cpu.write(MaterialData {
albedo: albedo.index(),
sampler: linear_sampler.index(),
});
}
// Per-frame recording. `vert`, `frag`, `vertices`, `indices`, the timeline
// semaphore, and the window's physical size come from the surrounding
// renderer; acquire rebuilds the swapchain when the size changes.
let Some(backbuffer) = gpu.swapchain_acquire_next(window_size) else {
return; // surface mid-transition; skip this frame
};
let cb = gpu.commands_begin(Queue::Main);
gpu.cmd_begin_render_pass(
cb,
RenderPassDesc {
color_attachments: &[RenderAttachment {
texture: backbuffer,
load_op: LoadOp::Clear,
store_op: StoreOp::Store,
clear_color: [0.02, 0.02, 0.03, 1.0],
..Default::default()
}],
..Default::default()
},
);
gpu.cmd_set_shaders(cb, vert, frag);
gpu.cmd_draw_indexed(cb, vertices.gpu, material.gpu, indices, index_count, 1);
gpu.cmd_end_render_pass(cb);
gpu.queue_submit(Queue::Main, &[cb], &[], &[(frame_timeline, frame_value)]);
gpu.swapchain_present(Queue::Main, frame_timeline, frame_value);
commands_begin binds the heaps, so every command buffer can index them without setup. Draw and dispatch calls then provide buffer data as device addresses, while shaders use the material's integer slots to index the image and sampler heaps.
Vulkan requirements #
The current initialization path has no compatibility fallback: Gpu::new probes every physical device against the complete set below, picks the best that passes (discrete > virtual > integrated), and otherwise returns Error::NoSuitableDevice listing each device's first unmet requirement. swapchain_init additionally asserts that the chosen device's main queue family can present to the window's surface.
When more than one queue family is in use (e.g. a dedicated compute family on AMD), buffers and images are created CONCURRENT across them: the barrier model is global memory barriers only, with no queue-family ownership transfers. Some drivers disable image compression for concurrent images; single-family devices are unaffected.
Instance #
- Vulkan 1.3 loader and driver
VK_KHR_surface- One supported platform surface extension:
VK_KHR_win32_surfaceVK_KHR_wayland_surfaceVK_KHR_xlib_surface
VK_EXT_debug_utilsVK_LAYER_KHRONOS_validationis optional; when requested but unavailable, initialization prints a warning and continues without validation
Device extensions #
VK_KHR_swapchainVK_EXT_shader_objectVK_EXT_descriptor_bufferVK_EXT_depth_clip_enableVK_KHR_draw_indirect_count- Optional:
VK_NV_device_diagnostic_checkpoints— when present,cmd_begin_debug_labelalso records a crash marker that a device loss or wait timeout dumps to stderr
Device features #
Vulkan 1.1
shaderDrawParameters
Vulkan 1.2
bufferDeviceAddresstimelineSemaphoreruntimeDescriptorArrayshaderSampledImageArrayNonUniformIndexingshaderStorageImageArrayNonUniformIndexingdrawIndirectCountscalarBlockLayouthostQueryResetvulkanMemoryModelvulkanMemoryModelDeviceScope
Vulkan 1.3
dynamicRenderingsynchronization2
Extension features
descriptorBuffershaderObjectdepthClipEnable
Core device features requested by the current shader and renderer paths
shaderInt64vertexPipelineStoresAndAtomicsfragmentStoresAndAtomicsgeometryShader— used to make fragment-stagePrimitiveIdavailable; no geometry shader objects are createdsamplerAnisotropy
The implementation creates three shared descriptor-set layouts with 65,535 entries each for sampled images, storage images, and samplers. A device must expose descriptor limits sufficient to create those layouts. Descriptor-buffer implementations must also report sampled-image and storage-image descriptor sizes no greater than 64 bytes and sampler descriptor sizes no greater than 32 bytes, matching the crate's fixed descriptor storage.
Workspace #
gpu— this crate: resources, commands, synchronization, WSI.gpu-ptr—GpuPtr<T>, compiled for both the host and the SPIR-V target.gpu-util— renderer-side helpers built only ongpu's public API:Arena(bump allocator over mapped GPU memory; the one place host-side writes go),PassTimer(rolling per-pass GPU timing),StaticTexture(PNG / RGBA8 → bindless slot),dump_texture_png, and thepasstrait seams. Featurepngenables the PNG paths.
License #
Licensed under either of the following, at your option: