31 - ovk Framework Commands
Step 30 wrapped every major Vulkan object in an ovk struct. But the command buffer and synchronisation code - create_command_pool, begin_command_buffer, transition_image_layout, submit_command_buffer, semaphore and fence helpers - was still in main.odin. This step finishes the refactoring by moving command recording, synchronisation primitives, buffer transfers, and the model loader into ovk. On top of that, utils.odin was split up: what was a single file mixing error helpers, physical device queries, and buffer transfers now lives where it belongs.
The full source for this step lives in src/31_ovk_framework_commands/main.odin and libs/ovk/.
What we’re doing
Nothing changes visually. The viking room still rotates, MSAA is still there. What changes is where things live and how they’re organised:
- libs/ovk/command_pool.odin -
Command_Poolstruct, create/destroy. - libs/ovk/command_buffer.odin -
Command_Bufferstruct, lifecycle (create/destroy/begin/end/submit), and the one-time command buffer helpers (create_one_time_command_buffer,end_one_time_command_buffer). - libs/ovk/commands.odin - all the
cmd_*recording helpers (transition, rendering, binding, drawing, copy, mipmap generation). Previously mixed intocommand_buffer.odin. - libs/ovk/fence.odin -
Fencestruct, create/destroy, wait, reset. - libs/ovk/semaphore.odin -
Semaphorestruct, create/destroy. - libs/ovk/queue.odin -
Queuestruct andget_queue. - libs/ovk/buffer.odin - now also holds
Mapped_Buffer,create_mapped_buffers,destroy_mapped_buffers,mem_copy_to_buffer, andtransfer_to_buffer. - libs/ovk/sampler.odin -
Samplerstruct, create/destroy. - libs/ovk/model.odin -
Meshstruct,load_mesh,destroy_mesh. - libs/ovk/math.odin -
matrix4_perspective_vulkanmoved here. - libs/ovk/descriptor_set.odin - added
update_descriptor_set. - libs/ovk/logical_device.odin - added
wait_idle_device. - libs/ovk/error.odin - now has
checkandassert, both returning theErrorunion. No morecheck_panic. - libs/ovk/instance.odin - now has
are_layers_supported, which belongs with instance creation. - libs/ovk/physical_device.odin - now has
find_memory_type,get_max_usable_sample_count,find_depth_format,find_supported_format. All physical-device queries grouped together. - libs/ovk/utils.odin - removed. Its functions were distributed across
error.odin,instance.odin,physical_device.odin, andbuffer.odin.
And main.odin:
- The
Appstruct now holds everything: command pool, command buffers, semaphores, fences, vertex/index buffers, texture, sampler, UBO buffers and their mapped pointers. - Initialisation and the render loop are split into
init_appandrun_app. run_appreturns errors from the event loop.destroy_appis the single cleanup point.record_command_buffertakes ovk types instead of raw Vulkan handles.- The application code dropped from about 1248 lines to about 635 lines.
The split of utils.odin
Step 30 concentrated everything in utils.odin: error helpers, physical device format queries, buffer transfer logic, layer support checking. There was no real cohesion between those things - they just hadn’t been moved yet. Now the functions live where they belong:
| What | Where it went |
|---|---|
check, assert | error.odin - they construct Vulkan_Error / Assert_Error instances |
are_layers_supported | instance.odin - layers are an instance-level concept |
find_memory_type, get_max_usable_sample_count, find_depth_format, find_supported_format | physical_device.odin - all are queries against the physical device |
transfer_to_buffer | buffer.odin - it creates and manages a staging buffer |
The check_panic function from step 30 was removed entirely. Now every operation uses check which returns an Error, and the caller decides what to do with it. The last vestiges of panic-based error handling in ovk are gone.
The Error union also gained an Assert_Error variant to support the new assert proc:
Assert_Error :: struct {
message: string,
loc: runtime.Source_Code_Location,
}
Error :: union {
General_Error,
Vulkan_Error,
Assert_Error,
}
assert takes a condition and returns an error on failure - it’s not a panic. This means load_mesh can do assert(obj.success, "Failed to read obj file:", path) or_return instead of calling os.exit(1).
Physical device queries now take ^Physical_Device
Previously get_max_usable_sample_count and find_depth_format accepted a raw vk.PhysicalDevice. Now they take ^Physical_Device, consistent with everything else in ovk:
// Step 30:
app.samples = ovk.get_max_usable_sample_count(app.physical_device.vk_physical_device)
app.depth_format = ovk.find_depth_format(app.physical_device.vk_physical_device)
// Step 31:
app.samples = ovk.get_max_usable_sample_count(&app.physical_device)
app.depth_format = ovk.find_depth_format(&app.physical_device)
Command pools and command buffers
command_pool.odin and command_buffer.odin follow the same struct + args + create/destroy pattern as everything else:
Command_Pool :: struct {
device: ^Device,
vk_command_pool: vk.CommandPool,
queue_family: u32,
}
Command_Buffer :: struct {
command_pool: ^Command_Pool,
vk_command_buffer: vk.CommandBuffer,
}
The singular create_command_buffer calls the plural create_command_buffers with a count of 1, extracts the first element, frees the temporary slice, and returns a single value - same pattern as descriptor sets from step 30.
One-time command buffers
Staging operations (buffer copies, texture transfers) need a temporary command buffer that is submitted immediately and freed:
create_one_time_command_buffer :: proc(command_pool: ^Command_Pool) -> (command_buffer: Command_Buffer, err: Error)
end_one_time_command_buffer :: proc(command_buffer: ^Command_Buffer, queue: ^Queue) -> (err: Error)
The first allocates a buffer and begins recording with {.ONE_TIME_SUBMIT}. The second ends recording, submits, waits for the queue to idle, and frees the buffer. Between the two calls you record whatever commands you need.
The cmd_ helpers moved to commands.odin
All the functions that write commands into a command buffer are prefixed with cmd_ and live in their own file, commands.odin. They take ^Command_Buffer as the first parameter:
cmd_transition_image_layout(command_buffer, image, old_layout, new_layout, ...)
cmd_begin_rendering(command_buffer, color_image, resolve_image, extent, depth_image)
cmd_bind_graphics_pipeline(command_buffer, pipeline)
cmd_set_viewport(command_buffer, width, height)
cmd_set_scissor(command_buffer, width, height)
cmd_bind_vertex_buffer(command_buffer, first_binding, count, buffer, offset)
cmd_bind_index_buffer(command_buffer, buffer, offset, index_type)
cmd_bind_graphics_descriptor_set(command_buffer, pipeline, descriptor_set)
cmd_draw_indexed(command_buffer, index_count, instance_count, ...)
cmd_end_rendering(command_buffer)
cmd_copy_buffer(command_buffer, src, src_offset, dest, dest_offset, size)
cmd_copy_buffer_to_image(command_buffer, buffer, image)
cmd_generate_mipmaps(command_buffer, image, format, width, height, mip_levels)
Previously they were mixed into command_buffer.odin alongside the buffer’s lifecycle functions (create/destroy/begin/end/submit). Separating them makes it clearer what’s a lifecycle operation and what’s a recording operation.
Submission
submit_command_buffer takes a Submit_Command_Buffer_Args struct:
Submit_Command_Buffer_Args :: struct {
command_buffer: ^Command_Buffer,
queue: ^Queue,
// fence can be nil when no fence is needed (one-time submissions)
fence: ^Fence,
wait_semaphores: []^Semaphore,
wait_dest_stages: []vk.PipelineStageFlags,
signal_semaphores: []^Semaphore,
}
The fence can be nil for one-time submissions. The queue_wait_idle helper is used by end_one_time_command_buffer to drain the queue before freeing the buffer.
Mipmap generation
cmd_generate_mipmaps uses the old vk.ImageMemoryBarrier API (not ImageMemoryBarrier2), because the blit barrier structure doesn’t map cleanly to the vk2 equivalents. The image layout transitions (cmd_transition_image_layout) use the vk2 API. Both work correctly, it’s just a reminder that the final mip level needs a separate transition.
Fences and semaphores
Fences and semaphores follow the same pattern:
Fence :: struct {
device: ^Device,
vk_fence: vk.Fence,
}
Semaphore :: struct {
device: ^Device,
vk_semaphore: vk.Semaphore,
}
Creation comes in singular and plural variants:
create_fence(args) -> (Fence, Error)
create_fences(args, count) -> ([]Fence, Error)
create_semaphore(args) -> (Semaphore, Error)
create_semaphores(args, count) -> ([]Semaphore, Error)
The plural versions allocate an Odin slice and call the singular version for each element. destroy_fences / destroy_semaphores iterate, destroy each handle with the correct Vulkan call, and free the slice.
The Queue type
Queues were raw vk.Queue handles. Now they have a thin wrapper:
Queue :: struct {
vk_queue: vk.Queue,
}
Unlike the other ovk types, Queue doesn’t store a back-reference to Device - queues live as long as the device lives and aren’t created or destroyed independently. The Device struct stores three of them (graphics_queue, compute_queue, transfer_queue) and creates them with vk.GetDeviceQueue during create_logical_device.
Buffer transfers and mapped memory
transfer_to_buffer moved from the old utils.odin to buffer.odin. It creates a staging buffer, copies data, starts a one-time command buffer, records a CmdCopyBuffer, submits, waits, and frees everything:
ovk.transfer_to_buffer(&app.graphics_command_pool, &app.device.graphics_queue, mesh.vertices, &app.vertex_buffer)
Mapped_Buffer
Mapping a buffer the Vulkan way is vk.MapMemory, get a rawptr, use it, vk.UnmapMemory. The library now has a Mapped_Buffer type that bundles the pointer with the buffer:
Mapped_Buffer :: struct {
buffer: ^Buffer,
ptr: rawptr,
}
create_mapped_buffers :: proc(buffers: []Buffer, ...) -> ([]Mapped_Buffer, Error)
destroy_mapped_buffers :: proc(mapped_buffers: []Mapped_Buffer)
The uniform buffer setup in init_app now reads:
app.ubo_buffers = ovk.create_buffers(
{device = &app.device, size = u64(size_of(Uniform_Buffer_Object)), usage = {.UNIFORM_BUFFER}, mem_properties = {.HOST_VISIBLE, .HOST_COHERENT}},
NB_FRAMES_IN_FLIGHT,
) or_return
app.ubo_mapped_buffers = ovk.create_mapped_buffers(app.ubo_buffers) or_return
And in the render loop, app.ubo_mapped_buffers[frame_index].ptr replaces the old ubo_map_memory_ptrs[frame_index].
Sampler
create_sampler moved into the library with a Sampler struct:
Sampler :: struct {
device: ^Device,
vk_sampler: vk.Sampler,
}
The sampler creation parameters are still hardcoded (the same LINEAR / REPEAT / max anisotropy settings as before). The Create_Sampler_Args struct has only device for now - it can be extended if you need configurable parameters later.
Model loading
The standalone load_model in main.odin is replaced by ovk.load_mesh in model.odin. It returns a Mesh:
Mesh :: struct {
vertices: []Vertex,
indices: []u32,
}
The indices are u32 instead of u16. The old code used u16 because the viking room has fewer than 65535 vertices, but u32 is more standard. The CmdBindIndexBuffer call in record_command_buffer uses .UINT32.
The mesh data is freed right after the GPU buffers are populated:
mesh := ovk.load_mesh("../../assets/models/viking_room/viking_room.obj") or_return
defer ovk.destroy_mesh(&mesh)
app.vertex_buffer = ovk.create_buffer({...}) or_return
ovk.transfer_to_buffer(&app.graphics_command_pool, &app.device.graphics_queue, mesh.vertices, &app.vertex_buffer)
app.index_buffer = ovk.create_buffer({...}) or_return
ovk.transfer_to_buffer(&app.graphics_command_pool, &app.device.graphics_queue, mesh.indices, &app.index_buffer)
Descriptor set updates
update_descriptor_set takes the descriptor set and a slice of Descriptor_Write:
Descriptor_Write :: struct {
binding: u32,
type: vk.DescriptorType,
image: ^Image,
sampler: ^Sampler,
buffer: ^Buffer,
offset: u64,
size: u64,
}
update_descriptor_set :: proc(descriptor_set: ^Descriptor_Set, descriptor_writes: []Descriptor_Write) -> (err: Error)
It currently supports UNIFORM_BUFFER and COMBINED_IMAGE_SAMPLER. Adding a new descriptor type is just another branch in the iteration. The call site in init_app becomes:
for i in 0 ..< NB_FRAMES_IN_FLIGHT {
ovk.update_descriptor_set(
&app.descriptor_sets[i],
{{type = .UNIFORM_BUFFER, binding = 0, buffer = &app.ubo_buffers[i]}, {type = .COMBINED_IMAGE_SAMPLER, binding = 1, image = &app.texture, sampler = &app.sampler}},
) or_return
}
The application code is now minimal
main is 27 lines:
main :: proc() {
fmt.println("Odin Vulkan Tutorial")
fmt.println("-------------------------------------------")
app: App
err := init_app(&app)
if err != nil {
fmt.eprintfln("Failed to initialize vulkan:\n%#v", err)
os.exit(1)
}
err = run_app(&app)
if err != nil {
fmt.eprintfln("Error while running the application:\n%#v", err)
os.exit(1)
}
ovk.wait_idle_device(&app.device)
destroy_app(&app)
}
Every resource is initialised by init_app, used by run_app, and cleaned up by destroy_app. There is no manual loop over fences or semaphores in main.
The App struct now owns everything:
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,
graphics_command_pool: ovk.Command_Pool,
graphics_command_buffers: []ovk.Command_Buffer,
acquire_semaphores: []ovk.Semaphore,
submit_semaphores: []ovk.Semaphore,
draw_fences: []ovk.Fence,
vertex_buffer: ovk.Buffer,
index_buffer: ovk.Buffer,
texture: ovk.Image,
sampler: ovk.Sampler,
ubo_buffers: []ovk.Buffer,
ubo_mapped_buffers: []ovk.Mapped_Buffer,
}
The event loop as a function
In step 30 the event loop was inline in main. Now it lives in run_app, which returns ovk.Error. If acquire_next_image or submit_command_buffer fails during the loop, the error propagates up.
acquire_next_image and queue_present each return (value, bool, Error). The bool indicates whether the swap chain needs recreation. The or_return only checks the Error part, so the recreation flag still works:
swap_chain_image_index, swap_chain_recreation_needed := ovk.acquire_next_image(
&app.swap_chain,
&app.draw_fences[frame_index],
&app.acquire_semaphores[frame_index],
) or_return
record_command_buffer takes ovk types
The function signature got simpler - eleven raw Vulkan handles replaced with pointers to ovk types:
record_command_buffer :: proc(
command_buffer: ^ovk.Command_Buffer,
image: ^ovk.Image,
swap_chain_extent: vk.Extent2D,
graphics_pipeline: ^ovk.Graphics_Pipeline,
vertex_buffer: ^ovk.Buffer,
index_buffer: ^ovk.Buffer,
index_count: u32,
descriptor_set: ^ovk.Descriptor_Set,
depth_image: ^ovk.Image,
color_image: ^ovk.Image,
)
The index count is computed from the buffer size (u32(app.index_buffer.size / size_of(u32))), avoiding a separate counter in App.
The swap chain recreation path
When the swap chain is out of date, run_app calls destroy_swap_chain(app) then create_swap_chain(app) or_return. Because create_swap_chain now returns an error instead of panicking, a failed recreation propagates up to main instead of crashing mid-frame.
What’s next
Every Vulkan object and every reusable operation now lives in ovk, but main.odin is still carrying the swap chain plumbing - the acquire/submit/present trio, the semaphores and fences, the color and depth images, and the recreation path - plus the texture loading code. The next step bundles all of that into a Swap_Chain_Helper that owns the whole cycle and recreates the swap chain on its own, and moves texture loading into ovk too. That’s 32 - ovk Framework Helpers.