KDGpu Handle-Based Ownership Model
Introduction
KDGpu uses a handle-based resource management system that provides safe, efficient access to GPU resources while preventing common errors like use-after-free and resource leaks. This system is inspired by modern entity-component-system (ECS) architectures.
Handle Design
Handle Structure
Every KDGpu resource (Buffer, Texture, Pipeline, etc.) contains an opaque Handle<T> that uniquely identifies the underlying Vulkan resource:
| template<typename T>
class Handle {
uint32_t m_index; // Index into resource pool
uint32_t m_generation; // Generation counter for validation
public:
bool isValid() const;
uint32_t index() const;
uint32_t generation() const;
};
|
Key components:
- Index: Slot in the resource pool where this resource lives
- Generation: Counter that increments when the slot is reused, allowing detection of stale handles
Handle Types
KDGpu provides two handle variants:
RequiredHandle<T>: A handle that MUST be valid (wrapper around Handle)
OptionalHandle<T>: An alias for Handle that MAY be invalid
| // Required handle - must always be valid
RequiredHandle<Texture_t> textureHandle = texture.handle();
// Optional handle - can be invalid
OptionalHandle<RenderPass_t> renderPassHandle; // Default constructed = invalid
|
Implicit Conversion to Handles
KDGpu resources provide implicit conversion operators to their handle types, making code more readable by allowing resources to be passed directly where handles are expected:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41 | KDGpu::Texture texture = device.createTexture(KDGpu::TextureOptions{
/* ... */
});
KDGpu::TextureView view = texture.createView(KDGpu::TextureViewOptions{
/* ... */
});
KDGpu::Sampler sampler = device.createSampler(KDGpu::SamplerOptions{
/* ... */
});
KDGpu::BindGroupLayout bindGroupLayout = device.createBindGroupLayout(KDGpu::BindGroupLayoutOptions{
/* .... */
});
// Explicit handle extraction (verbose)
KDGpu::BindGroup bindGroup = device.createBindGroup(KDGpu::BindGroupOptions{
.layout = bindGroupLayout.handle(), // Explicit .handle() call
.resources = {
{
.binding = 0,
.resource = KDGpu::TextureViewSamplerBinding{
.textureView = view.handle(), // Explicit .handle() call
.sampler = sampler.handle(), // Explicit .handle() call
},
},
},
});
// Implicit conversion (cleaner)
// Works with any API expecting a handle
KDGpu::BindGroup bindGroup2 = device.createBindGroup(KDGpu::BindGroupOptions{
.layout = bindGroupLayout, // Implicitly converts to Handle<BindGroupLayout_t>
.resources = {
{
.binding = 0,
.resource = KDGpu::TextureViewSamplerBinding{
.textureView = view, // Implicitly converts to Handle<TextureView_t>
.sampler = sampler, // Implicitly converts to Handle<Sampler_t>
},
},
},
});
|
Filename: kdgpu_doc_snippets.cpp
This implicit conversion:
- Reduces boilerplate code and improves readability
- Is type-safe (only converts to the correct handle type)
- Does not affect performance (zero-cost abstraction)
- Still allows explicit
.handle() calls when needed for clarity
Resource Ownership
Ownership Model
KDGpu Resources follow the expect Vulkan ownship:
- Instance owns: Adapters
- Adapter owns: Nothing (references Instance resources)
- Device owns: All created resources (Buffers, Textures, Pipelines, CommandRecorders, etc.)
This means that you must be careful about destruction order. Textures, Buffers, Pipelines ... must be released prior to the device.
Lifetime rules:
- Resources live until their owner is destroyed or the resource is explicitly moved
- Resources use RAII - destruction is automatic
- Resources cannot be copied, only moved (enforced by deleted copy constructors)
Resource Creation
Resources are always created through factory methods on their owner:
| // Handle is created and manages GPU resource lifetime
// KDGpu::Buffer buffer = device.createBuffer(options);
// Resource exists until 'buffer' goes out of scope
|
Filename: kdgpu_doc_snippets.cpp
Each create*() method:
- Allocates a slot in the internal resource pool
- Creates the underlying Vulkan resource
- Returns a KDGpu wrapper object containing the handle
- Registers cleanup for automatic destruction
Handle Validity
Checking Validity
Every KDGpu resource has an isValid() method:
| KDGpu::Buffer buffer;
if (buffer.isValid()) {
// Safe to use
void *ptr = buffer.map();
}
// Or check the handle directly
if (buffer.handle().isValid()) {
// Handle is valid
}
|
Filename: kdgpu_doc_snippets.cpp
A handle becomes invalid when:
- Default constructed (never initialized)
- Moved from (ownership transferred)
- Underlying resource was destroyed
Stale Handle Detection
The generation counter prevents use of stale handles:
| // Explicit resource release before destruction
// KDGpu::Buffer buffer = device.createBuffer(options);
// // ... use buffer ...
// buffer = {}; // Explicitly destroy resource now
|
Filename: kdgpu_doc_snippets.cpp
Move Semantics
Move-Only Resources
All KDGpu resources are move-only:
| // Handles support move semantics (no copying)
// KDGpu::Buffer buffer1 = device.createBuffer(options);
// KDGpu::Buffer buffer2 = std::move(buffer1); // Ownership transferred
// buffer1 is now invalid, buffer2 owns the resource
|
Filename: kdgpu_doc_snippets.cpp
| // Handles cannot be copied (deleted copy constructor/assignment)
// KDGpu::Buffer buffer1 = device.createBuffer(options);
// KDGpu::Buffer buffer2 = buffer1; // ERROR: Won't compile
|
Filename: kdgpu_doc_snippets.cpp
Storing Resources
Use std::vector or other STL containers for collections:
| // Handles can be stored in containers
// std::vector<KDGpu::Buffer> buffers;
// buffers.push_back(device.createBuffer(options1));
// buffers.push_back(device.createBuffer(options2));
// All resources freed when vector destroyed
|
Filename: kdgpu_doc_snippets.cpp
Resource Pools
Pool Architecture
Internally, KDGpu uses a pool allocator pattern:
1
2
3
4
5
6
7
8
9
10
11
12 | // Simplified internal structure
template<typename VulkanResource, typename KDGpuResource>
class Pool {
std::vector<VulkanResource> resources;
std::vector<uint32_t> generations;
std::vector<uint32_t> freeList;
public:
Handle<T> allocate(const Options& opts);
void free(Handle<T> handle);
VulkanResource* get(Handle<T> handle);
};
|
Benefits:
- O(1) allocation and deallocation
- Automatic handle validation via generation counters
- Cache-friendly memory layout
- Minimal memory overhead per resource
Vulkan Resource Mapping
Vulkan Lifecycle
When you create a KDGpu resource, here's what happens:
| // 1. User calls KDGpu API
KDGpu::Buffer buffer = device.createBuffer(options);
// 2. Internally:
// a. Allocate pool slot → get index and generation
// b. Call vkCreateBuffer() → get VkBuffer handle
// c. If memory is needed, call vkAllocateMemory() and vkBindBufferMemory()
// d. Allocates a VulkanBuffer to store VkBuffer
// e. Store VulkanBuffer in pool[index]
// f. Return Handle<Buffer_t> to user wrapped in Buffer object
|
When the KDGpu resource is destroyed:
| // Resource automatically destroyed when handle goes out of scope
// {
// KDGpu::Buffer buffer = device.createBuffer(options);
// // ... use buffer ...
// } // buffer destroyed here, GPU memory freed
|
Filename: kdgpu_doc_snippets.cpp
Accessing Vulkan Handles
For advanced use cases, KDGpu provides access to the underlying Vulkan handles through the GraphicsApi:
1
2
3
4
5
6
7
8
9
10
11
12 | KDGpu::Buffer buffer = device.createBuffer(KDGpu::BufferOptions{
/* ... */
});
KDGpu::Handle<KDGpu::Buffer_t> kdgpuHandle = buffer.handle();
// Access underlying VulkanBuffer
KDGpu::GraphicsApi *api = device.graphicsApi();
KDGpu::VulkanResourceManager *rm = api->resourceManager();
KDGpu::VulkanBuffer *vkBuffer = rm->getBuffer(kdgpuHandle);
// Access raw VkBuffer
VkBuffer rawVkBuffer = vkBuffer->buffer; // Use with care!
|
Filename: kdgpu_doc_snippets.cpp
Warning: Direct access to Vulkan resources bypasses KDGpu's safety mechanisms. Only use this for:
- Interoperability with other Vulkan libraries
- Performance-critical paths after profiling
- Debugging and introspection
Best Practices
Do's
✓ Store resources in class members or STL containers
| // Handles as class members
// class Mesh {
// KDGpu::Buffer m_vertexBuffer;
// KDGpu::Buffer m_indexBuffer;
// public:
// Mesh(KDGpu::Device& device) {
// m_vertexBuffer = device.createBuffer(vertexOptions);
// m_indexBuffer = device.createBuffer(indexOptions);
// }
// // Buffers automatically destroyed with Mesh
// };
|
Filename: kdgpu_doc_snippets.cpp
✓ Check isValid() after default construction
| // Optional handles for conditional resources
// std::optional<KDGpu::Texture> depthTexture;
// if (needsDepth) {
// depthTexture = device.createTexture(options);
// }
|
Filename: kdgpu_doc_snippets.cpp
✓ Use move semantics for resource transfer
| // Returning handles from functions
// KDGpu::Buffer createVertexBuffer(KDGpu::Device& device) {
// return device.createBuffer(options); // Move semantics
// }
// KDGpu::Buffer buffer = createVertexBuffer(device);
|
Filename: kdgpu_doc_snippets.cpp
✓ Let resources destruct automatically via RAII
| // Command buffers follow same ownership rules
// KDGpu::CommandRecorder recorder = device.createCommandRecorder();
// // ... record commands ...
// KDGpu::CommandBuffer cmdBuffer = recorder.finish();
// queue.submit(SubmitOptions{ .commandBuffers = { cmdBuffer } });
// // cmdBuffer can be destroyed after submit() returns
|
Filename: kdgpu_doc_snippets.cpp
Don'ts
| // Best practices:
// - Prefer stack allocation over heap when possible
// - Use RAII - let handles manage lifetimes automatically
// - Ensure parent resources outlive dependent resources
// - Use move semantics to transfer ownership
// - Avoid manual resource management (no need for destroy())
|
Filename: kdgpu_doc_snippets.cpp
Anti-Pattern Examples
✗ Don't cache bare handles without the owning object
| // BAD: Handle outlives the resource
Handle<Buffer_t> handle;
{
KDGpu::Buffer buffer = device.createBuffer(options);
handle = buffer.handle();
} // buffer destroyed, handle is now stale
// GOOD: Keep the resource alive
KDGpu::Buffer buffer = device.createBuffer(options);
Handle<Buffer_t> handle = buffer.handle(); // Both alive together
|
✗ Don't use handles after moving the resource
| // BAD
KDGpu::Buffer buffer1 = device.createBuffer(options);
Handle<Buffer_t> handle = buffer1.handle();
KDGpu::Buffer buffer2 = std::move(buffer1);
// handle now refers to buffer2, not buffer1!
// GOOD: Re-get handle after move
Handle<Buffer_t> handle = buffer2.handle();
|
✗ Don't manually delete or free resources
| // BAD: No manual memory management needed
// delete &buffer; // NO!
// vkDestroyBuffer(...); // NO!
// GOOD: Let RAII handle it
{
KDGpu::Buffer buffer = device.createBuffer(options);
} // Automatic cleanup
|
Comparison with Other APIs
vs. Raw Vulkan
| Aspect |
Vulkan |
KDGpu |
| Resource Lifetime |
Need to keep VkHandle around and call vkDestroy* functions |
Move-only RAII objects with handle-based ref |
| Handle Validation |
VK_NULL_HANDLE check only |
Generation-based stale handle detection |
| Memory Management |
Manual (or use VMA separately) |
Integrated with pool allocation |
| API Simplicity |
(verbose) |
Higher-level abstractions |
vs. Vulkan-Hpp
| Aspect |
Vulkan-Hpp |
KDGpu |
| Resource Lifetime |
vk::UniqueHandle with std::unique_ptr semantics |
Move-only RAII objects with handle-based ref |
| Handle Validation |
nullptr check only |
Generation-based stale handle detection |
| Memory Management |
Manual (or use VMA separately) |
Integrated with pool allocation |
| API Simplicity |
1:1 with Vulkan (verbose) |
Higher-level abstractions |
See Also
Updated on 2026-03-14 at 00:03:56 +0000