clean_gfx is a small Vulkan-only rendering layer inspired by
No Graphics API. It follows the proposal's
64-bit GPU-pointer and application-owned descriptor-heap model, while using Vulkan's CPU push-data
path for optional small roots. The exact similarities, deliberate differences, and current scope
limits are documented in the
design comparison. Its C++ API lives in the short gfx
namespace.
The implementation deliberately creates no VkDescriptorSetLayout, VkDescriptorPool,
VkDescriptorSet, or VkPipelineLayout. Internally it uses:
VK_EXT_descriptor_heapfor the resource/sampler heaps andvkCmdPushDataEXT;VK_EXT_mesh_shaderfor mesh pipelines and direct mesh-workgroup draws;VK_KHR_device_address_commandsfor index binding, indirect draws/dispatch, and buffer/image copies;VK_KHR_unified_image_layoutsto keep every normal image access inVK_IMAGE_LAYOUT_GENERAL;VK_KHR_swapchainplusVK_EXT_swapchain_maintenance1for the Win32 presentation path;- Slang pointers for custom vertex fetch and arbitrary BDA-backed structures;
- Vulkan 1.4 dynamic rendering and synchronization2 for the remaining fixed-function work.
There is no public buffer object or owning memory class. gpu_malloc<T>() returns the plain
GpuAllocation<T> {cpu, gpu, size} aggregate. Its default MemoryType::cpu_visible is strictly
device-local, host-visible, and host-coherent, so ordinary GPU allocations expose both a
persistently mapped CPU pointer and their GPU pointer. MemoryType::readback uses the same required
memory properties and additionally prefers host-cached memory. MemoryType::gpu_only is
non-host-visible, so its cpu pointer is null. gpu_free() takes the unchanged allocation by
reference. Address-based command APIs take the separate GpuRange {gpu, size} aggregate by
reference, and gpu_range() converts
an allocation to its full range. This lets a caller bind all or part of an allocation
without exposing a Vulkan buffer handle. gpu_malloc_resource_heap() and
gpu_malloc_sampler_heap() create coherent, directly writable descriptor heaps. The application
chooses 32-bit resource/sampler slot indices, writes descriptors through the returned CPU pointer,
and explicitly binds the corresponding GPU range on command lists that use them.
The public API is a set of free functions in namespace gfx. Devices, swapchains, textures,
pipelines, and command lists are opaque raw pointer handles rather than C++ ownership wrappers.
Device and swapchain creation return handle/error aggregates; other creation functions return a
handle directly. Matching destroy_*() functions release them, and applications destroy owned
handles explicitly in reverse creation order. A submitted command list is consumed by submit();
destroy_command_list() abandons one that was not submitted.
submit() derives its device from the command-list handle, and write_texture_descriptor()
derives it from the texture, so neither call repeats an owner argument.
get_device_caps() returns a reference into the device and does not copy the capability record;
that reference remains valid until destroy_device().
CPU arrays in public descriptors use Span<T> {data, size}, a non-owning pointer/count view with no
iterator or accessor layer. It constructs from a pointer and count or a C array. Span<const T> also
accepts an initializer list for concise call arguments. Initializer-list storage remains alive only
through that call, so a span backed by it must never be retained.
Every public descriptor field has a useful default. Call sites use C++20 designated initializers,
name each explicitly supplied field, and omit fields whose defaults are already correct. Span is
the constructor-bearing exception because its three concise input forms are part of the API.
The library is built without C++ exception handling. Programming errors and unexpected Vulkan
failures assert and abort. create_device() and create_swapchain() return small handle/error
aggregates because device support and window-system compatibility are recoverable startup results.
CPU allocation failure is deliberately not handled. GPU heap exhaustion is the one
allocation-specific case: gpu_malloc(), gpu_malloc_resource_heap(), and
gpu_malloc_sampler_heap() return the default null GpuAllocation {}. Test
gpu if an application wants to recover; the examples deliberately assume their tiny
allocations succeed and perform no such checks.
- A 64-bit, little-endian host, a Vulkan 1.4 loader/device, and the loader's development library.
VK_EXT_descriptor_heap,VK_EXT_mesh_shader,VK_KHR_device_address_commands,VK_KHR_shader_untyped_pointers, andVK_KHR_unified_image_layouts.- On Windows,
VK_KHR_surface,VK_KHR_win32_surface,VK_KHR_get_surface_capabilities2,VK_EXT_surface_maintenance1,VK_KHR_swapchain, andVK_EXT_swapchain_maintenance1, including itsswapchainMaintenance1feature. - The BDA, descriptor-heap, device-address-command, untyped-pointer, unified-image-layout, mesh-shader, extended-dynamic-state, depth-bias-clamp, dynamic-rendering, synchronization2, scalar-layout, and 16-bit features checked at startup.
- A device-local memory type on an at least 256 MiB heap that is both host-visible and host-coherent. This is the default CPU-visible allocation class and is also used for readback and application-owned descriptor heaps. Host-only and non-coherent fallback memory are unsupported.
- A separate device-local, non-host-visible memory type on an at least 256 MiB heap for GPU-only allocations and textures.
- CMake 3.24+, a C++20 compiler, Slang 2026.14.1 or newer, SPIRV-Tools 2026.3 or
newer, and a system Vulkan SDK whose headers report version 1.4.357 or newer. The cube requires
Slang's native
SPV_EXT_descriptor_heapcapability. Shaders sharing C++ POD structures use-fvk-use-c-layout; matrix-bearing structures additionally use-matrix-layout-row-major.
The build uses the Vulkan headers and loader development library supplied by that system SDK and rejects versions below 1.4.357 during configuration.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
./build/examples/triangle/clean_gfx_triangle
./build/examples/cube/clean_gfx_cube
./build/examples/render_pipeline/clean_gfx_render_pipelineOn Windows, each example opens a Win32 window and runs a normal acquire/render/present loop against a Vulkan swapchain. Presentation stays on the GPU; there is no CPU readback, GDI blit, image dump, per-frame idle wait, or artificial sleep. FIFO presentation supplies pacing.
The 512x512 triangle follows the first triangle in the
Khronos Vulkan Tutorial:
a traditional vertex shader produces three constant positions/colors from SV_VertexID, and the
CPU records one rootless draw(nullptr, 3) into the swapchain. It has no vertex allocation, mesh
shader, sampled texture, descriptor heap, compute stage, or depth/stencil attachment.
The 500x500 cube follows the official
Khronos/LunarG vkcube sample.
It spins four degrees per frame and reproduces the sample's cube geometry, camera, nearest-filtered
sRGB texture, face lighting, clear color, and depth test. Vertex data is fetched through BDA, while
the application allocates, writes, and binds one resource heap and one sampler heap. The embedded
texture is the exact upstream
lunarg.ppm.h asset,
distributed with the sample under the upstream
Apache-2.0 license.
render_pipeline is the feature-oriented example. Its mesh shader fills a two-target G-buffer
(rgba8_unorm albedo and rgba16_float normal/roughness) plus d32_float depth. A second
fullscreen pass reads all three through the application-owned resource heap and writes simple
deferred lighting to the swapchain. The example recreates its G-buffer when the acquired extent
changes and deliberately keeps MSAA and mip generation out of this first pipeline.
The examples will not run through MoltenVK. On this development Mac they are expected to stop during Vulkan initialization; run them on a driver that exposes all required extensions.
When a shader needs root data, the same structure header is included by C++ and Slang:
struct RootArguments
{
Vertex* vertices;
};The root is an ordinary CPU POD. Its pointer fields carry GPU addresses and must not be dereferenced
by the CPU. shader_types.h supplies C-layout-compatible POD equivalents for Slang vector types
and float3x4. Shared structures use -fvk-use-c-layout, plus -matrix-layout-row-major when they
contain the matrix. Slang names matrix dimensions as row count by column count, so the C++
float3x4 stores three float4 rows. The draw/dispatch template copies the complete root through
vkCmdPushDataEXT while the command is recorded, so the CPU root need not outlive the call.
The first argument is nullptr when a draw, meshlet draw, or dispatch has no root. No push-data
command is emitted in that case:
gfx::draw(commands, nullptr, 3);
gfx::draw_meshlets(commands, nullptr, 1);
RootArguments root{.vertices = vertices.gpu};
gfx::draw(commands, &root, vertex_count);The triangle is deliberately rootless. The cube uses a root containing a vertex pointer, a shared
float3x4 clip transform, and two depth coefficients. Its single texture and sampler occupy slot
zero in their respective heaps:
Texture2D<float4> texture = ResourceDescriptorHeap[0];
SamplerState sampler = SamplerDescriptorHeap[0];Implemented now: capability-driven device creation, plain GPU allocation and range aggregates, coherent device-local mapped CPU-visible and readback memory, GPU-only memory, application-owned resource/sampler heap allocations, sampled and storage image descriptor writers, six texture types, alternate-format mip/layer views, pitched subresource copies, sampler descriptor writers, explicit heap binding, vertex/fragment, mesh/fragment, and compute pipelines with null layouts, optional CPU-root draw/dispatch calls, multiple color attachments, independent color/depth/stencil load-store operations, dynamic depth/stencil state, dynamic rendering, direct/indexed/indirect draws, direct/indirect meshlet draws and compute dispatches, device-address copies, global memory barriers, unified image layouts, timeline-backed asynchronous submission, and Win32 swapchain presentation.
GraphicsPipelineDesc and MeshPipelineDesc use a caller-owned
Span<const ColorTargetDesc>. Each entry supplies one color format and an RGBA write mask;
depth and stencil formats remain separate optional fields. A mesh pipeline replaces the vertex
stage and fixed-function topology with mesh SPIR-V while retaining the fragment stage, attachment
formats, sample count, and culling. draw_meshlets() supplies mesh workgroup counts directly,
while draw_meshlets_indirect() reads one or more workgroup-count records from a GpuRange.
Stage::mesh exposes mesh-shader memory dependencies without adding a task-shader stage.
RenderingDesc supplies a span of ColorAttachment values plus optional DepthAttachment
and StencilAttachment values. Every attachment selects LoadOp::load, clear, or
discard and StoreOp::store or discard; clear data is stored next to the texture it
affects. set_depth_stencil_state() sets depth testing, writes, comparison, bias, and independent
front/back stencil operations as dynamic command state. Format::s8_uint and
Format::d32_float_s8_uint provide stencil-only and combined depth/stencil attachment formats,
and TextureUsage::depth_stencil_attachment covers both aspects.
Ordinary value memory is suballocated from 256 MiB internal pages, with one fully bound universal
VkBuffer per page and separate pools for CPU-visible, GPU-only, and readback memory. Suballocation uses
a pinned revision of OffsetAllocator, and a new page is
created when existing pages cannot satisfy a request.
Every resource or sampler heap instead owns one exact-sized descriptor-capable VkBuffer and one
dedicated allocation from the same coherent mapped device-local memory used by CPU-visible
allocations.
The buffer covers the aligned user bytes, suffix padding, and Vulkan-required implementation
reservation; no 256 MiB descriptor page is created. The public GpuAllocation exposes only the
requested user range. Slot zero is therefore exactly
cpu/gpu; slot i is i * DeviceCaps::image_descriptor_size bytes into a resource heap
or i * DeviceCaps::sampler_descriptor_size bytes into a sampler heap.
Textures are normally suballocated from separate 256 MiB GPU-only image-memory heaps; only images
that Vulkan requires to be dedicated or that cannot fit a page use the dedicated-allocation path.
create_texture() does not submit work: the next begin_commands() batches the one required
VK_IMAGE_LAYOUT_UNDEFINED to VK_IMAGE_LAYOUT_GENERAL metadata initialization for every newly
created texture. TextureType exposes 1D, 2D, 3D, cube, 2D-array, and cube-array images.
TextureDesc::layer_count and TextureViewDesc layer ranges count logical cubes for cube types;
each logical cube occupies six physical image slices. The backend declares every compatible public
view format while creating an image, so alternate-format support adds no creation-time API field.
TextureViewDesc::format == Format::undefined inherits the texture format, while zero mip or layer
count selects the remaining range. Sampled/storage descriptors are generated directly from image
create information; a real VkImageView is created only when fixed-function attachment use requires
one.
TextureCopyDesc selects one mip, an offset and extent, and a range of physical slices. Cube faces
therefore occupy six consecutive slice indices even though creation and descriptor views count
logical cubes. Zero width, height, depth, or slice count selects the remaining subresource range;
zero row and slice pitches mean tightly packed memory. Nonzero row_pitch_bytes and
slice_pitch_bytes describe padded data inside the supplied GpuRange. To start at an interior
byte address, pass an interior GpuRange with its gpu pointer and size adjusted accordingly.
Mip-chain construction is intentionally not a core API operation; applications can upload authored
levels with these direct copy commands or provide their own higher-level utility.
TextureDesc::sample_count must match the active graphics or mesh pipeline's sample_count
when the texture is used as an attachment. All active color, depth, and stencil attachments use the
same count. Multisample 2D and 2D-array attachments are supported, as are multisample storage images
when the corresponding optional core feature is available, but
VK_EXT_descriptor_heap cannot encode sampled multisample descriptors. Multisample textures
therefore cannot be passed to write_texture_descriptor() as TextureDescriptorType::sampled;
they also have one mip and are not valid memory-copy operands. Rendering attachments do not expose
resolve targets or implicit resolve behavior.
Two persistent frame contexts own reusable command pools, command buffers, and timeline values.
submit(commands) queues headless work and returns without waiting. Swapchain frames call
acquire(), begin_commands(), and present() explicitly. Acquire and present keep Vulkan's
required binary semaphores private, while the timeline gates command-pool reuse. Per-present fences
make swapchain recreation and destruction precise without vkDeviceWaitIdle; destroying the
swapchain is the examples' final completion point.
The backend retains no allocations, textures, pipelines, descriptor slots, or other user resources
for a command list. The caller must keep every referenced
object/allocation alive and must not call gpu_free() or reuse descriptor storage until
wait_idle(), destroy_swapchain(), or another known completion point has retired the work.
This remains a focused, deliberately single-threaded prototype. Device and command-list operations
are not thread-safe; the implementation contains no mutex or atomic synchronization. Ray tracing,
task shaders, sparse heaps, capture/replay, device-generated commands, non-Win32 WSI,
multi-queue scheduling, and pipeline caching are out of scope. Normal texture accesses remain in
VK_IMAGE_LAYOUT_GENERAL; the backend alone performs the required swapchain transitions to and
from VK_IMAGE_LAYOUT_PRESENT_SRC_KHR.
The unified-layout extension deliberately does not remove the one-time initialization out of
VK_IMAGE_LAYOUT_UNDEFINED; clean_gfx records it lazily in the next command-list's batched
texture-initialization dependency.
Address-based command functions consume GpuRange aggregates directly. Command recording performs no
hidden allocation lookup or command-list lifetime retention. GPU pointers copied from root data and
descriptor-heap contents are opaque to the backend, so all pointed-to allocations, textures,
pipelines, heaps, and descriptor slots remain the caller's lifetime responsibility through GPU completion. Callers
synchronize genuine read/write hazards with barrier(); no image-layout state is
exposed by the public API.
See the comparison with No Graphics API, Vulkan support and cross-reference, and Slang integration and ABI for the design rationale, exact contracts, and current driver notes.