30 - ovk Framework Objects
Step 29 extracted the foundation layer (instance, device, window, physical device) into libs/ovk/. This step goes further and wraps the remaining Vulkan objects: swap chain, buffers, images, shader modules, graphics pipelines, descriptor set layouts, descriptor pools and descriptor sets.
The full source for this step lives in src/30_ovk_framework_objects/main.odin and libs/ovk/.
What we’re doing
Same viking room with MSAA, same rendering loop. Nothing changes visually. What changes is the code organisation:
- libs/ovk/buffer.odin - wraps
vk.Buffer+vk.DeviceMemoryinto aBufferstruct. - libs/ovk/image.odin - wraps
vk.Image+vk.DeviceMemory+vk.ImageViewinto anImagestruct. - libs/ovk/swap_chain.odin - wraps the swap chain, its images, and image views into a
Swap_Chainstruct. - libs/ovk/shader.odin - wraps shader compilation and module creation into a
Shaderstruct. - libs/ovk/shader_compiler.odin - moved out of
main.odininto the library. - libs/ovk/graphics_pipeline.odin - wraps the pipeline + pipeline layout into a
Graphics_Pipelinestruct. - libs/ovk/descriptor_set_layout.odin / descriptor_pool.odin / descriptor_set.odin - each wrapper owns its Vulkan handle and knows how to destroy it.
- libs/ovk/descriptor_set.odin also introduces a pattern that will appear elsewhere: two creation methods -
create_descriptor_set(singular, returns oneDescriptor_Set) andcreate_descriptor_sets(plural, takes a count and returns[]Descriptor_Set).
The pattern is the same for every object: a struct that bundles the Vulkan handle with a back-reference to the parent Device, a create_* function that takes a Create_*_Args struct, and a destroy_* function that checks for nil handles before calling Vulkan.
The main.odin went from about 1960 lines (step 29) to about 1248 lines - roughly 700 lines removed, all of them raw Vulkan boilerplate that is now in the library.
The pattern: struct + args + create/destroy
Every wrapper follows the same shape. Here is Buffer as an example:
Buffer :: struct {
device: ^Device, // back-reference for destroy
vk_buffer: vk.Buffer,
vk_device_memory: vk.DeviceMemory,
}
Create_Buffer_Args :: struct {
device: ^Device,
size: u64,
usage: vk.BufferUsageFlags,
mem_properties: vk.MemoryPropertyFlags,
}
create_buffer :: proc(args: Create_Buffer_Args) -> (buffer: Buffer, err: Error) {
// ... create vk.Buffer, allocate memory, bind ...
buffer.device = args.device
return
}
destroy_buffer :: proc(buffer: ^Buffer) {
if buffer == nil || buffer.device == nil || buffer.device.vk_device == nil {
return
}
if buffer.vk_device_memory != 0 { vk.FreeMemory(...) }
if buffer.vk_buffer != 0 { vk.DestroyBuffer(...) }
}
Three design decisions worth calling out:
-
The
argsstruct pattern. Instead of a 7-parameter function, you get a struct with named fields. Odin doesn’t have default arguments, but struct literal fields can be omitted if they have a zero default. If a new field is added later, existing callers don’t break. -
Every wrapper stores a
devicepointer. This is what makesdestroy_*self-contained - no need to pass the device every time you want to clean up. The downside is that the device must outlive the objects that reference it, but since destruction happens in reverse creation order, that’s guaranteed as long as you’re careful. -
Safe destruction. Every
destroy_*function checks for nil devices and zero handles. Callingdestroy_bufferon a zero-initializedBufferis a no-op. This makes cleanup code simpler - no need to guard every call with a nil check.
Image follows the same pattern but goes one step further: create_image also creates the image view internally. The Create_Image_Args struct has an aspect_flags field specifically for the view. This means every Image wrapper always carries a view, which is practical (most images are sampled or rendered to through a view) but means you can’t create an image-only object without also getting a view.
Image :: struct {
device: ^Device,
vk_image: vk.Image,
vk_device_memory: vk.DeviceMemory,
vk_image_view: vk.ImageView,
}
Create_Image_Args :: struct {
device: ^Device,
width: u32,
height: u32,
mip_levels: u32,
samples: vk.SampleCountFlags,
format: vk.Format,
usage: vk.ImageUsageFlags,
mem_properties: vk.MemoryPropertyFlags,
aspect_flags: vk.ImageAspectFlags,
}
Single vs array: two creation methods
Most ovk objects are one-to-one with a Vulkan handle - one Buffer wraps one vk.Buffer. But some objects, like descriptor sets, are commonly created in batches. Vulkan’s AllocateDescriptorSets already works this way: you pass a count and get an array back.
descriptor_set.odin exposes both paths:
Create_Descriptor_Sets_Args :: struct {
descriptor_pool: ^Descriptor_Pool,
descriptor_set_layout: ^Descriptor_Set_Layout,
}
// Singular: returns a single Descriptor_Set value.
create_descriptor_set :: proc(args: Create_Descriptor_Sets_Args) -> (descriptor_set: Descriptor_Set, err: Error)
// Plural: takes a count, returns a slice.
create_descriptor_sets :: proc(args: Create_Descriptor_Sets_Args, descriptor_count: u32) -> (descriptor_sets: []Descriptor_Set, err: Error)
The singular version is a thin wrapper around the plural one - it calls create_descriptor_sets(args, 1), takes the first element, frees the temporary slice, and returns a single Descriptor_Set.
The Create_Descriptor_Sets_Args struct holds the shared dependencies (pool and layout) but not the count. The count is a separate parameter on create_descriptor_sets only. This way the singular create_descriptor_set never receives a count at all - there’s no risk of passing 1 to the wrong function.
This “args struct for dependencies, count as a separate parameter” will be the pattern for every ovk function that can create multiple objects at once. It keeps the singular API clean (no unused count parameter) and the plural API explicit (you always see how many you’re creating).
The swap chain owns everything
In step 29 the swap chain was a loose collection of local variables: swap_chain, swap_chain_extent, swap_chain_format, swap_chain_images, swap_chain_image_views. Creating and destroying them was done with separate functions that each had their own parameter list.
Now Swap_Chain bundles it all:
Swap_Chain :: struct {
device: ^Device,
vk_swap_chain: vk.SwapchainKHR,
extent: vk.Extent2D,
format: vk.Format,
color_space: vk.ColorSpaceKHR,
images: []Image,
}
The create_swap_chain function fills every field. The destroy_swap_chain function destroys image views, frees the slices, and destroys the swap chain handle. The main code no longer manages these individually.
The swap chain has its own create_swap_chain / destroy_swap_chain pair in main.odin rather than in the library, because it also creates the color and depth resources that depend on the swap chain extent. That’s an application-level concern - ovk doesn’t know about your multisampling setup or your depth format choice. The lib handles the raw swap chain creation (in libs/ovk/swap_chain.odin), and the app layer wraps it with the extra resources (in main.odin’s create_swap_chain).
create_swap_chain :: proc(app: ^App) -> (err: ovk.Error) {
app.swap_chain = ovk.create_swap_chain({...}) or_return
app.samples = ovk.get_max_usable_sample_count(&app.physical_device)
app.color_image = ovk.create_image({...}) or_return
app.depth_format = ovk.find_depth_format(&app.physical_device) or_return
app.depth_image = ovk.create_image({...}) or_return
return
}
Graphics pipeline: one proc, one struct
The old create_graphics_pipeline returned two values (a vk.Pipeline and a vk.PipelineLayout). The new one returns a Graphics_Pipeline struct:
Graphics_Pipeline :: struct {
device: ^Device,
vk_pipeline: vk.Pipeline,
vk_pipeline_layout: vk.PipelineLayout,
}
The vertex attributes are now passed as an argument instead of being hardcoded inside the function. The Create_Graphics_Pipeline_Args takes a vertex_attributes_stride and a []vk.VertexInputAttributeDescription slice, letting the caller describe the vertex layout without the function knowing about Vertex:
app.graphics_pipeline = ovk.create_graphics_pipeline({
device = &app.device,
shader = &app.shader,
vertex_entry_point = "vertMain",
fragment_entry_point = "fragMain",
swap_chain_format = app.swap_chain.format,
descriptor_set_layout = &app.descriptor_set_layout,
vertex_attributes_stride = size_of(Vertex),
vertex_attributes = {
{binding = 0, location = 0, format = .R32G32B32_SFLOAT, offset = u32(offset_of(Vertex, pos))},
{binding = 0, location = 1, format = .R32G32B32_SFLOAT, offset = u32(offset_of(Vertex, color))},
{binding = 0, location = 2, format = .R32G32_SFLOAT, offset = u32(offset_of(Vertex, texCoord))},
},
depth_format = app.depth_format,
samples = app.samples,
}) or_return
What stayed in main.odin
The rendering-specific code is still in main.odin, and that’s intentional. ovk wraps objects (buffers, images, pipelines, etc.) but not operations (recording command buffers, submitting work, presenting). Those are application-level concerns:
create_command_pool/create_command_buffers/begin_command_buffer/end_command_bufferbegin_rendering/end_renderingrecord_command_buffertransition_image_layout/generate_mipmapssubmit_command_buffer/acquire_next_image/queue_presentmem_copy_to_buffer/transfer_to_buffercreate_sampler/create_texture_image/load_modelupdate_uniform_buffer/update_descriptor_setcreate_semaphore/create_fence/wait_for_fence/reset_fence
These are the next candidates for the library. The pattern will be the same: a struct wrapper, an args struct, and a create/destroy pair. But the order matters - command pools depend on command buffers, which depend on the recording logic. That’s the next step.
The App struct grows
The App struct now owns every ovk object:
App :: struct {
instance: ovk.Instance,
window: ovk.Window,
physical_device: ovk.Physical_Device,
device: ovk.Device,
swap_chain: ovk.Swap_Chain,
shader: ovk.Shader,
descriptor_set_layout: ovk.Descriptor_Set_Layout,
descriptor_pool: ovk.Descriptor_Pool,
descriptor_sets: []ovk.Descriptor_Set,
samples: vk.SampleCountFlags,
color_image: ovk.Image,
depth_format: vk.Format,
depth_image: ovk.Image,
graphics_pipeline: ovk.Graphics_Pipeline,
}
And the cleanup in destroy_app reflects the creation order, reversed:
destroy_app :: proc(app: ^App) {
ovk.destroy_graphics_pipeline(&app.graphics_pipeline)
ovk.destroy_descriptor_sets(app.descriptor_sets)
ovk.destroy_descriptor_pool(&app.descriptor_pool)
ovk.destroy_descriptor_set_layout(&app.descriptor_set_layout)
ovk.destroy_shader(&app.shader)
destroy_swap_chain(app)
ovk.destroy_logical_device(&app.device)
ovk.destroy_window(&app.window)
ovk.destroy_instance(&app.instance)
ovk.destroy_glfw()
}
destroy_descriptor_sets frees both the Odin slice and, if the pool was created with {.FREE_DESCRIPTOR_SET}, the individual Vulkan descriptor sets. The flags live on Descriptor_Pool - the set just reads them from its pool reference, which avoids storing the same flag on every Descriptor_Set struct. If the pool was created with flags = {} (the default), vkFreeDescriptorSets is skipped and the sets are cleaned up when destroy_descriptor_pool runs.
What’s next
This step gets ovk to the point where every major Vulkan object has a wrapper. The command buffer and rendering code is still raw - begin_command_buffer, transition_image_layout, begin_rendering, end_rendering, submit_command_buffer, and friends still live in main.odin. The next step wraps command pools and command buffers, pulls the recording helpers into the library, and redistributes the remaining loose helpers into their proper files so the rendering loop becomes as clean as the initialisation path.