Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

clean_gfx

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_heap for the resource/sampler heaps and vkCmdPushDataEXT;
  • VK_EXT_mesh_shader for mesh pipelines and direct mesh-workgroup draws;
  • VK_KHR_device_address_commands for index binding, indirect draws/dispatch, and buffer/image copies;
  • VK_KHR_unified_image_layouts to keep every normal image access in VK_IMAGE_LAYOUT_GENERAL;
  • VK_KHR_swapchain plus VK_EXT_swapchain_maintenance1 for 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.

Requirements

  • 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, and VK_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, and VK_EXT_swapchain_maintenance1, including its swapchainMaintenance1 feature.
  • 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_heap capability. 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.

Build and run

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_pipeline

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

Optional shared root ABI

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];

Scope

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.

About

Clean graphics API on top of Vulkan with latest extensions. As close as possibly to my blog post and SIGGRAPH talk.

Resources

Stars

92 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages