odin-lang · vulkan 1.3 · game engine
engine architecture & render pipeline
Three Odin packages communicate through a versioned ABI defined in shared/game_api.odin.
The engine owns all memory — the game DLL holds only code. build.lua orchestrates
shader compilation (GLSL→SPIR-V via glslc) and both package builds. Dashed lines show type imports;
solid lines show runtime data flow.
graph LR
classDef buildCls fill:#18102e,stroke:#a855f7,stroke-width:2px,color:#e9d5ff
classDef engineCls fill:#0d1636,stroke:#818cf8,stroke-width:2px,color:#c7d2fe
classDef gameCls fill:#042318,stroke:#34d399,stroke-width:2px,color:#a7f3d0
classDef sharedCls fill:#1c1200,stroke:#fbbf24,stroke-width:2px,color:#fde68a
classDef gpuCls fill:#031828,stroke:#38bdf8,stroke-width:2px,color:#bae6fd
classDef shaderCls fill:#130a22,stroke:#c084fc,stroke-width:1.5px,color:#f0abfc
Build(["build.lua"]):::buildCls
subgraph SH ["shared/"]
SharedAPI["game_api.odin
Engine_API · Game_API
api_version: u32"]:::sharedCls
end
subgraph ENG ["engine/ → odingame.exe"]
Main["main.odin
Vulkan init + frame loop
GpuContext · SwapchainContext"]:::engineCls
HotR["hot_reload.odin
Game_Module
dynlib.load/unload"]:::engineCls
Shaders["shaders/
triangle.vert
triangle.frag"]:::shaderCls
end
subgraph GAMEDLL ["game/ → game.dll"]
GameCode["game.odin
Game_State
game_update()"]:::gameCls
end
GPU["Vulkan 1.3 GPU
GpuContext · SwapchainContext
Pipeline · CommandBuffer
Semaphores · Fence"]:::gpuCls
Build -->|"glslc → .spv"| Shaders
Build -->|"odin build engine"| Main
Build -->|"odin build game -build-mode:dll"| GameCode
Shaders --> Main
Main -. "imports types" .-> SharedAPI
GameCode -. "imports types" .-> SharedAPI
HotR -->|"dynlib.load
game_loaded.dll
(file-watch mtime)"| GameCode
GameCode -->|"Engine_API fn ptrs
draw_quad · set_clear_color
log · get_dt · is_key_down"| Main
Main -->|"Frame_Commands
record + submit"| GPU
Every iteration: GLFW events poll, the game DLL mtime is compared for hot-reload,
game_update() populates a Frame_Commands list, then Vulkan
records a command buffer using dynamic rendering — no render passes or framebuffer
objects. Quads use PushConstants and procedural vertex generation via
gl_VertexIndex; no vertex buffers are needed.
flowchart TD
classDef normalCls fill:#0d1636,stroke:#818cf8,stroke-width:1.5px,color:#c7d2fe
classDef hotCls fill:#280810,stroke:#fb7185,stroke-width:2px,color:#fecdd3
classDef vulkanCls fill:#031828,stroke:#38bdf8,stroke-width:1.5px,color:#bae6fd
classDef decisionCls fill:#0e0c24,stroke:#a855f7,stroke-width:1.5px,color:#e9d5ff
classDef startCls fill:#062818,stroke:#34d399,stroke-width:2px,color:#a7f3d0
classDef recreateCls fill:#1c1200,stroke:#fbbf24,stroke-width:1.5px,color:#fde68a
FrameStart(["● Frame Start"]):::startCls
FrameStart --> Poll
Poll["glfw.PollEvents
free_all temp_allocator
clear frame_commands.quads
Δt = now − prev_time"]:::normalCls
Poll --> DLLCheck
DLLCheck{"game.dll mtime
changed?"}:::decisionCls
DLLCheck -- no --> GameUpdate
DLLCheck -- yes --> ReadBytes
ReadBytes["Read game.dll bytes
into temp memory"]:::hotCls
ReadBytes --> DevWait
DevWait["vk.DeviceWaitIdle
game_unload(api, memory)
— serialize state"]:::hotCls
DevWait --> DynUnload
DynUnload["dynlib.unload_library
clear Game_Module.api"]:::hotCls
DynUnload --> WriteLoad
WriteLoad["Write bytes → game_loaded.dll
dynlib.load_library
(releases lock on game.dll)"]:::hotCls
WriteLoad --> BindSyms
BindSyms["Bind exported symbols:
game_get_api_version
game_get_memory_size
game_load / update / unload / reload"]:::hotCls
BindSyms --> VerAPI
VerAPI{"API version
match?
engine == game"}:::decisionCls
VerAPI -- "no → log warn" --> Poll
VerAPI -- yes --> GameReload
GameReload["game_reload(api, memory)
state.reload_count++
— restore live state"]:::hotCls
GameReload --> GameUpdate
GameUpdate["game_update(api, memory)
— reads/writes Game_State
— calls api.set_clear_color()
— calls api.draw_quad() N times
→ produces Frame_Commands"]:::normalCls
GameUpdate --> WaitFence
WaitFence["WaitForFences
in_flight_fence
(infinite timeout)"]:::vulkanCls
WaitFence --> Acquire
Acquire["AcquireNextImageKHR
wait: image_available_semaphore
→ image_index"]:::vulkanCls
Acquire --> AcqR
AcqR{"acquire
result?"}:::decisionCls
AcqR -- SUCCESS --> RstCmd
AcqR -- "SUBOPTIMAL / OUT_OF_DATE" --> RecreateA
RecreateA["recreate_swapchain
+ create_graphics_pipeline
+ recreate semaphores"]:::recreateCls
RecreateA --> FrameStart
RstCmd["vk.ResetCommandBuffer
vk.BeginCommandBuffer"]:::vulkanCls
RstCmd --> Barrier1
Barrier1["Pipeline Barrier (Sync2)
UNDEFINED
→ COLOR_ATTACHMENT_OPTIMAL
srcStage: TOP_OF_PIPE
dstStage: COLOR_ATTACHMENT_OUTPUT"]:::vulkanCls
Barrier1 --> BeginRender
BeginRender["CmdBeginRendering
colorAttachment: image_view
loadOp: CLEAR → clear_color
storeOp: STORE"]:::vulkanCls
BeginRender --> BindPipe
BindPipe["CmdBindPipeline GRAPHICS
CmdSetViewport
CmdSetScissor"]:::vulkanCls
BindPipe --> QuadLoop
QuadLoop["For each Quad_Command in Frame_Commands:
CmdPushConstants(rect[4], color[4])
CmdDraw(6 verts, gl_VertexIndex → NDC)
no VBO — procedural in vertex shader"]:::vulkanCls
QuadLoop --> EndRender
EndRender["CmdEndRendering"]:::vulkanCls
EndRender --> Barrier2
Barrier2["Pipeline Barrier (Sync2)
COLOR_ATTACHMENT_OPTIMAL
→ PRESENT_SRC_KHR
srcStage: COLOR_ATTACHMENT_OUTPUT
dstStage: BOTTOM_OF_PIPE"]:::vulkanCls
Barrier2 --> EndCmd
EndCmd["vk.EndCommandBuffer"]:::vulkanCls
EndCmd --> ResetFence
ResetFence["vk.ResetFences in_flight_fence"]:::vulkanCls
ResetFence --> Submit
Submit["QueueSubmit (graphics queue)
wait: image_available_semaphore
stage: COLOR_ATTACHMENT_OUTPUT
signal: render_finished_semaphore[image_index]
fence: in_flight_fence"]:::vulkanCls
Submit --> Present
Present["QueuePresentKHR (present queue)
wait: render_finished_semaphore[image_index]
swapchain + image_index"]:::vulkanCls
Present --> PresR
PresR{"present
result?"}:::decisionCls
PresR -- SUCCESS --> FrameStart
PresR -- "SUBOPTIMAL / OUT_OF_DATE" --> RecreateB
RecreateB["recreate_swapchain
+ create_graphics_pipeline
+ recreate semaphores"]:::recreateCls
RecreateB --> FrameStart
Game state lives in an engine-allocated []byte slice. The DLL owns
zero heap memory — only code. On reload, game_reload(api, memory) receives
the same pointer it left behind, so the live game state fully survives DLL swaps.
The engine copies game.dll → game_loaded.dll before
loading. This releases the OS file lock on the original, letting the Odin compiler
overwrite it while the engine is running — enabling zero-friction hot reload without
a build system dance.
No VkRenderPass or VkFramebuffer objects exist.
CmdBeginRendering takes attachment info inline. Swapchain recreation
only recreates the swapchain + pipeline — no framebuffer rebuild needed.
The vertex shader contains 6 hardcoded unit-quad positions. gl_VertexIndex
indexes them at draw time, so CmdDraw(6, 1, 0, 0) needs no VBO.
PushConstants deliver rect(xy+zw) and color
per quad in NDC space.
image_available_semaphore signals when the swapchain image is safe
to write. render_finished_semaphores[image_index] gates presentation.
in_flight_fence (pre-signaled) prevents the CPU from outrunning the GPU.
GAME_API_VERSION = 1 is checked on every reload. A mismatch
logs a warning and skips the reload — the engine keeps running with the previous
DLL unloaded, preventing crashes from incompatible structs.