- Complete rewrite of Engine.Graphics.Vulkan with pure P/Invoke (no wrapper libs) - Vulkan 1.3: dynamic rendering (vkCmdBeginRendering/vkCmdEndRendering), synchronization2 (vkQueueSubmit2, vkCmdPipelineBarrier2) - Split types into VulkanHandles.cs, VulkanEnums.cs, VulkanStructs.cs - Staging buffer → device-local vertex buffer pattern - Correct swapchain semaphore indexing (per-image, not per-frame-in-flight) - VK_EXT_debug_utils debug messenger with validation layer fallback - Dynamic viewport/scissor (no pipeline recreation on resize) - Simplified Program.cs to triangle-only rendering - Removed old Silk.NET renderer, ImGui, PBR shaders, screenshot code - Updated VULKAN_IMPLEMENTATION_PLAN.md with full architecture decisions
34 KiB
CORTEX ENGINE — VULKAN RENDERER IMPLEMENTATION PLAN
Project State (June 2026)
What Exists
- Engine.Core — Sdl3Window (SDL3, Vulkan surface ready), IWindow, IInputState, Key enum, InputMapping, camera controllers (FreeFly, Orbit), components (Transform, Mesh, Material, Light, Camera, RigidBody), Vertex struct (Position, Color, Normal — 9 floats), Timing, IScreenshotProvider
- Engine.Graphics — Restored minimal interfaces: IRenderContext, IRenderer, RenderBackendFactory, IScreenshotProvider, SceneSerializer, MeshMath, ProceduralMesh, Loaders/ObjLoader
- Engine.Physics — JoltPhysicsSharp 2.21.0, PhysicsWorld wrapper, RigidBody component
- Engine.AI — AiCommandProcessor (7 commands), MCP HTTP + stdio servers, AiCommandQueue
- CortexEngine.App — main loop (broken, references deleted graphics projects)
- tests/Engine.Tests — 66 tests (broken, reference Engine.Graphics)
- Content/ — cube.obj, torusknot.obj, checker.png
What Was Deleted
- Engine.Graphics.Raylib
- Engine.Graphics.OpenTK
- Engine.Graphics.Vulkan (Silk.NET version — all previous PBR/ImGui/mesh/screenshot code gone)
Environment
- .NET 9 SDK at
$HOME/.dotnet - Vulkan 1.4.329, NVIDIA RTX 2080 Ti, validation layers available
- SDL3 (ppy.SDL3-CS 2026.520.0) — window + Vulkan surface
- glslangValidator: check availability (
glslangValidator --version); fallback:glslc - Linux (X11), cross-platform target (Windows:
vulkan-1.dll, Linux:libvulkan.so.1)
Key Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Vulkan version | 1.3 | Dynamic rendering (no VkRenderPass/VkFramebuffer), synchronization2, extended dynamic state. All modern GPUs (2022+) support it. |
| Wrapper libraries | None | Pure P/Invoke to libvulkan.so.1 / vulkan-1.dll. No Silk.NET, Vortice, OpenTK. |
| Windowing | SDL3 (ppy.SDL3-CS) | Already integrated, Vulkan surface support built in. |
| Type organisation | Multiple files | VulkanHandles.cs, VulkanEnums.cs, VulkanStructs.cs — easier to maintain. |
| Debug | Full debug messenger | VK_EXT_debug_utils with callback printing validation messages to console (Debug only). |
| Memory | Staging buffer from start | Staging buffer (HOST_VISIBLE) → command buffer copy → device-local vertex buffer. Correct pattern from day one. |
| Frame loop | Re-record every frame | Vulkan Guide recommends fresh command buffers per frame over reuse. Simpler, no cache invalidation logic. |
| Semaphore indexing | Per-swapchain-image for submit | Critical: submit semaphores indexed by swapchain image index, NOT frame-in-flight index. (Vulkan Guide §swapchain_semaphore_reuse) |
| Render pass | Dynamic rendering | vkCmdBeginRendering / vkCmdEndRendering (Vulkan 1.3). No VkRenderPass or VkFramebuffer objects. |
| Synchronisation API | synchronization2 | VkImageMemoryBarrier2, vkCmdPipelineBarrier2 — cleaner, 64-bit flags. (Vulkan 1.3) |
Implementation Phases
Phase 1: Vulkan P/Invoke Foundation
Create src/Engine.Graphics.Vulkan/ with the following files:
1.1 VulkanNative.cs
- Load
libvulkan.so.1(Linux) /vulkan-1.dll(Windows) viaNativeLibrary.Load() - Export
vkGetInstanceProcAddrdelegate — the only directly-loaded function - Helper:
GetExport<T>(string name)for static exports - Helper:
ToUtf8Terminated(string)for passing string names to Vulkan
1.2 VulkanHandles.cs
Opaque pointer handles (all are nint / ulong):
VkInstance, VkPhysicalDevice, VkDevice, VkQueue,
VkCommandPool, VkCommandBuffer,
VkSwapchainKHR, VkSurfaceKHR,
VkImage, VkImageView,
VkBuffer, VkDeviceMemory,
VkShaderModule, VkPipelineLayout, VkPipeline,
VkSemaphore, VkFence,
VkDebugUtilsMessengerEXT,
VkDescriptorSetLayout, VkDescriptorPool, VkDescriptorSet
Each defined as struct VkXxx { public nint Handle; } or using VkXxx = System.IntPtr;
1.3 VulkanEnums.cs
All enums needed for triangle + future expansion:
VkResult— Success=0, NotReady, Timeout, Incomplete, ErrorOutOfDateKHR, SuboptimalKHR, ErrorSurfaceLostKHR, ...VkStructureType— ApplicationInfo=0, InstanceCreateInfo=1, DeviceQueueCreateInfo=2, DeviceCreateInfo=3, ...VkFormat— Undefined=0, R8G8B8A8Unorm=37, B8G8R8A8Unorm=44, R8G8B8A8Srgb=43, B8G8R8A8Srgb=50, R32G32Sfloat=103, R32G32B32Sfloat=106, R32G32B32A32Sfloat=109, D32Sfloat=126, ...VkColorSpaceKHR— SrgbNonlinear=0VkPresentModeKHR— Immediate=0, Mailbox=1, Fifo=2, FifoRelaxed=3VkImageUsageFlags— TransferSrc, TransferDst, ColorAttachment, ...VkImageLayout— Undefined=0, General=1, ColorAttachmentOptimal=2, TransferSrcOptimal=6, TransferDstOptimal=7, PresentSrcKHR=1000001002, ...VkImageAspectFlags— Color=1, Depth=2VkAttachmentLoadOp— Load=0, Clear=1, DontCare=2VkAttachmentStoreOp— Store=0, DontCare=1VkSharingMode— Exclusive=0, Concurrent=1VkCompositeAlphaFlagsKHR— Opaque=1, ...VkSurfaceTransformFlagsKHR— Identity=1, ...VkPrimitiveTopology— PointList=0, LineList=1, TriangleList=3, ...VkPolygonMode— Fill=0, Line=1, Point=2VkCullModeFlags— None=0, Front=1, Back=2, FrontAndBack=3VkFrontFace— CounterClockwise=0, Clockwise=1VkBlendFactor— Zero=0, One=1, SrcAlpha=6, OneMinusSrcAlpha=7, ...VkBlendOp— Add=0, ...VkColorComponentFlags— R=1, G=2, B=4, A=8VkShaderStageFlags— Vertex=1, Fragment=0x10, AllGraphics=0x1FVkPipelineStageFlags2— None=0, TopOfPipe=1, ColorAttachmentOutput=0x400, AllGraphics=0x8000, Transfer=0x10000, ...VkAccessFlags2— None=0, ColorAttachmentWrite=0x400, TransferWrite=0x1000, ...VkDynamicState— Viewport=0, Scissor=1, ...VkCommandBufferLevel— Primary=0, Secondary=1VkCommandBufferUsageFlags— OneTimeSubmit=1, ...VkFenceCreateFlags— Signaled=1VkMemoryPropertyFlags— DeviceLocal=1, HostVisible=2, HostCoherent=4, HostCached=8VkBufferUsageFlags— TransferSrc=1, TransferDst=2, VertexBuffer=0x80, IndexBuffer=0x40, UniformBuffer=0x10VkQueueFlags— Graphics=1, Compute=2, Transfer=4VkPhysicalDeviceType— Other=0, IntegratedGpu=1, DiscreteGpu=2, ...VkSampleCountFlags— Count1=1VkImageViewType— Type2D=1VkComponentSwizzle— Identity=0, ...VkBool32— False=0, True=1VkRenderingFlags— None=0, ContentsSecondaryCommandBuffers=1VkPipelineBindPoint— Graphics=0, Compute=1VkDescriptorType— UniformBuffer=6, StorageBuffer=7, CombinedImageSampler=0, ...VkDescriptorPoolCreateFlags— FreeDescriptorSet=1, ...
1.4 VulkanStructs.cs
All structs with LayoutKind.Sequential:
VkApplicationInfo— sType, pNext, pApplicationName, applicationVersion, pEngineName, engineVersion, apiVersionVkInstanceCreateInfo— sType, pNext, flags, pApplicationInfo, enabledLayerCount, ppEnabledLayerNames, enabledExtensionCount, ppEnabledExtensionNamesVkDebugUtilsMessengerCreateInfoEXT— sType, pNext, flags, messageSeverity, messageType, pfnUserCallback, pUserDataVkDeviceQueueCreateInfo— sType, pNext, flags, queueFamilyIndex, queueCount, pQueuePrioritiesVkDeviceCreateInfo— sType, pNext, flags, queueCreateInfoCount, pQueueCreateInfos, enabledLayerCount, ppEnabledLayerNames, enabledExtensionCount, ppEnabledExtensionNames, pEnabledFeaturesVkPhysicalDeviceFeatures— all VkBool32 (can be zeroed for triangle)VkPhysicalDeviceDynamicRenderingFeatures— sType, pNext, dynamicRendering (VkBool32) — needed to enable dynamic renderingVkSwapchainCreateInfoKHR— sType, pNext, flags, surface, minImageCount, imageFormat, imageColorSpace, imageExtent, imageArrayLayers, imageUsage, imageSharingMode, queueFamilyIndexCount, pQueueFamilyIndices, preTransform, compositeAlpha, presentMode, clipped, oldSwapchainVkImageViewCreateInfo— sType, pNext, flags, image, viewType, format, components, subresourceRangeVkComponentMapping— r, g, b, a (VkComponentSwizzle)VkImageSubresourceRange— aspectMask, baseMipLevel, levelCount, baseArrayLayer, layerCountVkExtent2D— width, heightVkExtent3D— width, height, depthVkOffset2D— x, yVkOffset3D— x, y, zVkRect2D— offset, extentVkViewport— x, y, width, height, minDepth, maxDepthVkShaderModuleCreateInfo— sType, pNext, flags, codeSize, pCodeVkPipelineShaderStageCreateInfo— sType, pNext, flags, stage, module, pName, pSpecializationInfoVkPipelineVertexInputStateCreateInfo— sType, pNext, flags, vertexBindingDescriptionCount, pVertexBindingDescriptions, vertexAttributeDescriptionCount, pVertexAttributeDescriptionsVkVertexInputBindingDescription— binding, stride, inputRateVkVertexInputAttributeDescription— location, binding, format, offsetVkPipelineInputAssemblyStateCreateInfo— sType, pNext, flags, topology, primitiveRestartEnableVkPipelineViewportStateCreateInfo— sType, pNext, flags, viewportCount, pViewports, scissorCount, pScissorsVkPipelineRasterizationStateCreateInfo— sType, pNext, flags, depthClampEnable, rasterizerDiscardEnable, polygonMode, cullMode, frontFace, depthBiasEnable, depthBiasConstantFactor, depthBiasClamp, depthBiasSlopeFactor, lineWidthVkPipelineMultisampleStateCreateInfo— sType, pNext, flags, rasterizationSamples, sampleShadingEnable, minSampleShading, pSampleMask, alphaToCoverageEnable, alphaToOneEnableVkPipelineColorBlendAttachmentState— blendEnable, srcColorBlendFactor, dstColorBlendFactor, colorBlendOp, srcAlphaBlendFactor, dstAlphaBlendFactor, alphaBlendOp, colorWriteMaskVkPipelineColorBlendStateCreateInfo— sType, pNext, flags, logicOpEnable, logicOp, attachmentCount, pAttachments, blendConstants[4]VkPipelineDynamicStateCreateInfo— sType, pNext, flags, dynamicStateCount, pDynamicStatesVkPipelineLayoutCreateInfo— sType, pNext, flags, setLayoutCount, pSetLayouts, pushConstantRangeCount, pPushConstantRangesVkGraphicsPipelineCreateInfo— sType, pNext, flags, stageCount, pStages, pVertexInputState, pInputAssemblyState, pViewportState, pRasterizationState, pMultisampleState, pDepthStencilState, pColorBlendState, pDynamicState, layout, renderPass, subpass, basePipelineHandle, basePipelineIndexVkCommandPoolCreateInfo— sType, pNext, flags, queueFamilyIndexVkCommandBufferAllocateInfo— sType, pNext, commandPool, level, commandBufferCountVkCommandBufferBeginInfo— sType, pNext, flags, pInheritanceInfoVkSemaphoreCreateInfo— sType, pNext, flagsVkFenceCreateInfo— sType, pNext, flagsVkBufferCreateInfo— sType, pNext, flags, size, usage, sharingMode, queueFamilyIndexCount, pQueueFamilyIndicesVkMemoryAllocateInfo— sType, pNext, allocationSize, memoryTypeIndexVkMemoryRequirements— size, alignment, memoryTypeBitsVkPhysicalDeviceMemoryProperties— memoryTypeCount, memoryTypes[32], memoryHeapCount, memoryHeaps[16]VkMemoryType— propertyFlags, heapIndexVkMemoryHeap— size, flagsVkQueueFamilyProperties— queueFlags, queueCount, timestampValidBits, minImageTransferGranularityVkSurfaceCapabilitiesKHR— minImageCount, maxImageCount, currentExtent, minImageExtent, maxImageExtent, maxImageArrayLayers, supportedTransforms, currentTransform, supportedCompositeAlpha, supportedUsageFlagsVkSurfaceFormatKHR— format, colorSpaceVkPhysicalDeviceProperties— apiVersion, driverVersion, vendorID, deviceID, deviceType, deviceName[256], ...VkSubmitInfo— sType, pNext, waitSemaphoreCount, pWaitSemaphores, pWaitDstStageMask, commandBufferCount, pCommandBuffers, signalSemaphoreCount, pSignalSemaphoresVkSubmitInfo2— sType, pNext, flags, waitSemaphoreInfoCount, pWaitSemaphoreInfos, commandBufferInfoCount, pCommandBufferInfos, signalSemaphoreInfoCount, pSignalSemaphoreInfos (sync2)VkSemaphoreSubmitInfo— sType, pNext, semaphore, value, stageMask, deviceIndex (sync2)VkCommandBufferSubmitInfo— sType, pNext, commandBuffer, deviceMask (sync2)VkPresentInfoKHR— sType, pNext, waitSemaphoreCount, pWaitSemaphores, swapchainCount, pSwapchains, pImageIndices, pResultsVkClearValue— union: VkClearColorValue color / VkClearDepthStencilValue depthStencilVkClearColorValue— union: float[4] / int[4] / uint[4]VkClearDepthStencilValue— depth, stencilVkRenderingAttachmentInfo— sType, pNext, imageView, imageLayout, resolveMode, resolveImageView, resolveImageLayout, loadOp, storeOp, clearValueVkRenderingInfo— sType, pNext, flags, renderArea, layerCount, viewMask, colorAttachmentCount, pColorAttachments, pDepthAttachment, pStencilAttachmentVkImageMemoryBarrier2— sType, pNext, srcStageMask, srcAccessMask, dstStageMask, dstAccessMask, oldLayout, newLayout, srcQueueFamilyIndex, dstQueueFamilyIndex, image, subresourceRangeVkBufferMemoryBarrier2— sType, pNext, srcStageMask, srcAccessMask, dstStageMask, dstAccessMask, srcQueueFamilyIndex, dstQueueFamilyIndex, buffer, offset, sizeVkDependencyInfo— sType, pNext, dependencyFlags, memoryBarrierCount, pMemoryBarriers, bufferMemoryBarrierCount, pBufferMemoryBarriers, imageMemoryBarrierCount, pImageMemoryBarriersVkBufferCopy— srcOffset, dstOffset, sizeVkDebugUtilsMessengerCallbackDataEXT— sType, pNext, messageId, pMessageIdName, messageSeverity, messageType, pMessage, queueLabelCount, pQueueLabels, cmdBufLabelCount, pCmdBufLabels, objectCount, pObjectsVkDebugUtilsObjectNameInfoEXT— sType, pNext, objectType, objectHandle, pObjectName
1.5 Vk.cs
Function delegate types + loaded function pointers:
Instance-level functions (loaded via vkGetInstanceProcAddr):
vkCreateInstance,vkDestroyInstancevkEnumeratePhysicalDevices,vkGetPhysicalDeviceProperties,vkGetPhysicalDeviceMemoryPropertiesvkGetPhysicalDeviceQueueFamilyPropertiesvkGetPhysicalDeviceSurfaceSupportKHRvkGetPhysicalDeviceSurfaceCapabilitiesKHR,vkGetPhysicalDeviceSurfaceFormatsKHR,vkGetPhysicalDeviceSurfacePresentModesKHRvkCreateDevice,vkDestroyDevicevkDestroySurfaceKHRvkCreateDebugUtilsMessengerEXT,vkDestroyDebugUtilsMessengerEXT(extension — via getInstanceProcAddr)vkGetDeviceProcAddr
Device-level functions (loaded via vkGetDeviceProcAddr for best performance):
vkGetDeviceQueuevkCreateSwapchainKHR,vkDestroySwapchainKHR,vkGetSwapchainImagesKHRvkCreateImageView,vkDestroyImageViewvkCreateShaderModule,vkDestroyShaderModulevkCreatePipelineLayout,vkDestroyPipelineLayoutvkCreateGraphicsPipelines,vkDestroyPipelinevkCreateCommandPool,vkDestroyCommandPoolvkAllocateCommandBuffers,vkFreeCommandBuffersvkBeginCommandBuffer,vkEndCommandBuffer,vkResetCommandBuffervkCreateSemaphore,vkDestroySemaphorevkCreateFence,vkDestroyFence,vkResetFences,vkWaitForFences,vkGetFenceStatusvkCreateBuffer,vkDestroyBuffervkAllocateMemory,vkFreeMemoryvkBindBufferMemoryvkGetBufferMemoryRequirementsvkMapMemory,vkUnmapMemoryvkCmdBindPipelinevkCmdSetViewport,vkCmdSetScissorvkCmdBindVertexBuffersvkCmdDrawvkCmdBeginRendering,vkCmdEndRendering(Vulkan 1.3 dynamic rendering)vkCmdPipelineBarrier2(sync2)vkCmdCopyBuffervkCmdBindIndexBuffer,vkCmdDrawIndexed(for future)vkAcquireNextImageKHRvkQueueSubmit2(sync2)vkQueuePresentKHRvkDeviceWaitIdlevkQueueWaitIdle
Phase 2: Vulkan Context
2.1 VulkanContext.cs
-
CreateInstance:
VkApplicationInfowithapiVersion = VK_API_VERSION_1_3- Instance extensions from SDL3:
SDL_GetVulkanInstanceExtensions() - Add
VK_EXT_debug_utilsin Debug - Layers:
VK_LAYER_KHRONOS_validationin Debug - Chain
VkDebugUtilsMessengerCreateInfoEXTinpNextfor early validation - Debug callback: prints
pMessageto stderr/console
-
PickPhysicalDevice:
- Enumerate all physical devices
- Prefer
VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU - Find queue family with
VK_QUEUE_GRAPHICS_BIT+ surface support (vkGetPhysicalDeviceSurfaceSupportKHR)
-
CreateLogicalDevice:
- Enable
VK_KHR_swapchaindevice extension - Chain
VkPhysicalDeviceDynamicRenderingFeaturesinpNextwithdynamicRendering = VK_TRUE - Single queue from selected family, priority 1.0
- Enable
-
CreateSurface:
- Call SDL3
SDL_Vulkan_CreateSurface(window, instance, ...)via Sdl3Window - Store
VkSurfaceKHR
- Call SDL3
-
Debug Messenger:
vkCreateDebugUtilsMessengerEXTwith callback- Severity: Verbose | Warning | Error
- Type: General | Validation | Performance
Phase 3: Swapchain
3.1 VulkanSwapchain.cs
-
Query surface:
vkGetPhysicalDeviceSurfaceCapabilitiesKHR→ min/max image count, current extentvkGetPhysicalDeviceSurfaceFormatsKHR→ preferB8G8R8A8_UNORM+SrgbNonlinear, fallback first formatvkGetPhysicalDeviceSurfacePresentModesKHR→ preferMAILBOX, fallbackFIFO(guaranteed)
-
Create swapchain:
minImageCount = max(minImageCount + 1, maxImageCount)(clamped)imageUsage = COLOR_ATTACHMENT_BIT | TRANSFER_DST_BIT(for future screenshots)preTransform = currentTransform(no pre-rotation on desktop)compositeAlpha = OPAQUE_BITclipped = VK_TRUEoldSwapchain = VK_NULL_HANDLE(on first create)
-
Get swapchain images:
vkGetSwapchainImagesKHR→ array ofVkImage- Create
VkImageViewfor each (TYPE_2D, same format,COLOR_BITaspect)
-
Recreate:
vkDeviceWaitIdle- Destroy old image views + swapchain
- Create new swapchain with
oldSwapchain= old handle - Create new image views
Phase 4: Pipeline
4.1 VulkanPipeline.cs
-
Shader modules:
- Load
triangle.vert.spvandtriangle.frag.spvfrom embedded resources or filesystem vkCreateShaderModulefor each
- Load
-
Vertex input:
- Binding 0: stride = sizeof(Vertex) = 36 bytes,
VERTEX_INPUT_RATE_VERTEX - Attribute 0:
R32G32B32_SFLOAT@ offset 0 (Position, location 0) - Attribute 1:
R32G32B32_SFLOAT@ offset 12 (Color, location 1) - Attribute 2:
R32G32B32_SFLOAT@ offset 24 (Normal, location 2)
- Binding 0: stride = sizeof(Vertex) = 36 bytes,
-
Pipeline state:
- Input assembly:
TRIANGLE_LIST - Viewport state: viewportCount=1, scissorCount=1 (dynamic values)
- Rasterization:
FILL, cullNONE,COUNTER_CLOCKWISE, lineWidth=1.0 - Multisample:
COUNT_1_BIT, no sample shading - Color blend: 1 attachment, blend disabled, write RGBA
- Dynamic state:
VIEWPORT,SCISSOR - Pipeline layout: no descriptor sets, no push constants (triangle only)
- Input assembly:
-
Dynamic rendering integration:
VkGraphicsPipelineCreateInfo::renderPass = VK_NULL_HANDLE(Vulkan 1.3 dynamic rendering)- Set
pNexttoVkPipelineRenderingCreateInfowithcolorAttachmentCount=1,pColorAttachmentFormats = {swapchainFormat}
Phase 5: Frame Resources
5.1 VulkanFrameResources.cs
-
Constants:
MAX_FRAMES_IN_FLIGHT = 2
-
Per-frame-in-flight resources (indexed 0..MAX_FRAMES_IN_FLIGHT-1):
VkCommandBuffer— primary, from shared command poolVkFence— signaled on submit, waited at frame start (created withSIGNALEDflag)VkSemaphore— acquire semaphore (signaled byvkAcquireNextImageKHR)
-
Per-swapchain-image resources (indexed 0..swapchainImageCount-1):
VkSemaphore— submit/render-finished semaphore (signaled byvkQueueSubmit2, waited byvkQueuePresentKHR)- CRITICAL: These are indexed by swapchain image index, NOT frame-in-flight index. This is the correct pattern from the Vulkan Guide (§swapchain_semaphore_reuse). Waiting on the acquire semaphore/fence for a given image index guarantees the previous present operation using that image has completed, making the submit semaphore safe to reuse.
-
Command pool:
vkCreateCommandPoolwithRESET_COMMAND_BUFFER_BITflag- Allocate
MAX_FRAMES_IN_FLIGHTprimary command buffers
Phase 6: Vertex Buffer
6.1 VulkanVertexBuffer.cs
-
Staging buffer pattern (correct from start):
- Create staging buffer:
usage = TRANSFER_SRC_BIT, memory =HOST_VISIBLE | HOST_COHERENT vkMapMemory→memcpyvertex data →vkUnmapMemory- Create vertex buffer:
usage = TRANSFER_DST_BIT | VERTEX_BUFFER_BIT, memory =DEVICE_LOCAL - Allocate + record one-time command buffer
vkCmdCopyBuffer(staging, vertex, size)- Submit + wait on fence
- Destroy staging buffer + free its memory + free one-time command buffer
- Create staging buffer:
-
Triangle data:
Vertex[3] = { { Position: ( 0.0, -0.5, 0.0), Color: (1, 0, 0), Normal: (0, 0, 1) }, { Position: ( 0.5, 0.5, 0.0), Color: (0, 1, 0), Normal: (0, 0, 1) }, { Position: (-0.5, 0.5, 0.0), Color: (0, 0, 1), Normal: (0, 0, 1) }, } -
Memory type selection:
vkGetPhysicalDeviceMemoryProperties→ iteratememoryTypes[]- Find type where
(memoryTypeBits >> i) & 1andpropertyFlagsmatches desired flags - Helper:
FindMemoryType(memoryTypeBits, desiredFlags)
Phase 7: Renderer
7.1 VulkanRenderer.cs (implements IRenderer)
-
Constructor:
- Create swapchain, pipeline, frame resources, vertex buffer
- Store reference to
VulkanContext(instance, device, queue, surface)
-
Frame loop (
Render()method):1. vkWaitForFences(frameFences[frameIndex]) 2. vkResetFences(frameFences[frameIndex]) 3. vkAcquireNextImageKHR(swapchain, acquireSemaphores[frameIndex], imageIndex) 4. vkResetCommandBuffer(commandBuffers[frameIndex]) 5. vkBeginCommandBuffer(commandBuffers[frameIndex], ONE_TIME_SUBMIT) 6. Image layout transition (sync2 barrier): UNDEFINED → COLOR_ATTACHMENT_OPTIMAL (srcStageMask: NONE, dstStageMask: COLOR_ATTACHMENT_OUTPUT) 7. vkCmdBeginRendering(renderingInfo): - colorAttachment: swapchainImageViews[imageIndex], COLOR_ATTACHMENT_OPTIMAL - loadOp: CLEAR (black), storeOp: STORE - renderArea: full extent 8. vkCmdBindPipeline(GRAPHICS, pipeline) 9. vkCmdSetViewport(0, 1, {0, 0, extent.width, extent.height, 0, 1}) 10. vkCmdSetScissor(0, 1, {{0,0}, extent}) 11. vkCmdBindVertexBuffers(0, 1, {vertexBuffer}, {0}) 12. vkCmdDraw(3, 1, 0, 0) 13. vkCmdEndRendering() 14. Image layout transition (sync2 barrier): COLOR_ATTACHMENT_OPTIMAL → PRESENT_SRC_KHR (srcStageMask: COLOR_ATTACHMENT_OUTPUT, dstStageMask: ALL_GRAPHICS) 15. vkEndCommandBuffer() 16. vkQueueSubmit2(queue, submitInfo2): - wait: acquireSemaphores[frameIndex] @ COLOR_ATTACHMENT_OUTPUT - commandBuffer: commandBuffers[frameIndex] - signal: submitSemaphores[imageIndex] - fence: frameFences[frameIndex] 17. vkQueuePresentKHR(presentInfo): - wait: submitSemaphores[imageIndex] - swapchain, imageIndex 18. frameIndex = (frameIndex + 1) % MAX_FRAMES_IN_FLIGHT -
Resize handling:
- If
vkAcquireNextImageKHRreturnsERROR_OUT_OF_DATE_KHRorSuboptimalKHR:vkDeviceWaitIdle- Recreate swapchain
- Continue frame
- If
-
Dispose:
vkDeviceWaitIdle- Destroy vertex buffer + memory
- Destroy semaphores (acquire + submit), fences
- Destroy command pool
- Destroy pipeline, pipeline layout, shader modules
- Destroy swapchain + image views
- Destroy debug messenger
- Destroy device, surface, instance
7.2 VulkanRenderContext.cs (implements IRenderContext)
- Exposes
Window(from Sdl3Window) CreateRenderer()→ returnsVulkanRendererResize()→ triggers swapchain recreationDispose()→ destroys context
7.3 VulkanBackendRegistrar.cs
- Static constructor registers
"vulkan"inRenderBackendFactory - Factory creates
VulkanRenderContextwithSdl3Window
Phase 8: Shaders
8.1 Shaders/triangle.vert
#version 450
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 2) in vec3 inNormal;
layout(location = 0) out vec3 fragColor;
void main() {
gl_Position = vec4(inPosition, 1.0);
fragColor = inColor;
}
8.2 Shaders/triangle.frag
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(fragColor, 1.0);
}
8.3 Compilation
glslangValidator -V triangle.vert -o triangle.vert.spv
glslangValidator -V triangle.frag -o triangle.frag.spv
- Embed
.spvfiles as embedded resources in csproj, or copy to output directory - Load at runtime via
Assembly.GetManifestResourceStream()orFile.ReadAllBytes()
Phase 9: App Integration
- Fix
CortexEngine.App.csproj:- Remove deleted project references
- Add
Engine.Graphics+Engine.Graphics.Vulkan
- Fix
Program.cs:- Simplify to triangle-only rendering
RenderBackendFactory.Create("vulkan", 1280, 720, validation: true)- Main loop: poll events → render → present
- Keep: Sdl3Window, basic event handling
- Remove: ECS scene, physics, AI, camera tour (add back later)
Phase 10: Fix Tests
- Update
Engine.Tests.csproj— reference restoredEngine.Graphics - Tests referencing Engine.Graphics: ObjLoaderTests, RenderBackendFactoryTests, SceneSerializerTests, MeshMathAndProceduralTests
- All tests should pass after Engine.Graphics is restored
File Layout
src/
├── Engine.Core/ (exists, unchanged)
├── Engine.Graphics/ (exists, restored minimal interfaces)
│ ├── Engine.Graphics.csproj
│ ├── IRenderContext.cs
│ ├── IRenderer.cs
│ ├── IScreenshotProvider.cs
│ ├── RenderBackendFactory.cs
│ ├── MeshMath.cs
│ ├── ProceduralMesh.cs
│ ├── SceneSerializer.cs
│ └── Loaders/
│ └── ObjLoader.cs
├── Engine.Graphics.Vulkan/ (new — pure P/Invoke, Vulkan 1.3)
│ ├── Engine.Graphics.Vulkan.csproj
│ ├── VulkanNative.cs — library loading, vkGetInstanceProcAddr
│ ├── VulkanHandles.cs — opaque pointer types
│ ├── VulkanEnums.cs — all Vulkan enums/flags
│ ├── VulkanStructs.cs — all Vulkan structs (LayoutKind.Sequential)
│ ├── Vk.cs — function delegates + loaded pointers
│ ├── VulkanContext.cs — instance, device, queue, surface, debug
│ ├── VulkanSwapchain.cs — swapchain, image views, recreate
│ ├── VulkanPipeline.cs — shader modules, pipeline layout, graphics pipeline
│ ├── VulkanFrameResources.cs — command buffers, fences, semaphores (correct indexing)
│ ├── VulkanVertexBuffer.cs — staging buffer → device-local vertex buffer
│ ├── VulkanRenderer.cs — IRenderer: frame loop with dynamic rendering
│ ├── VulkanRenderContext.cs — IRenderContext implementation
│ ├── VulkanBackendRegistrar.cs— registration in RenderBackendFactory
│ └── Shaders/
│ ├── triangle.vert
│ ├── triangle.frag
│ ├── triangle.vert.spv
│ └── triangle.frag.spv
├── Engine.Physics/ (exists, unchanged)
├── Engine.AI/ (exists, unchanged)
└── CortexEngine.App/ (fix references, simplify to triangle)
csproj: Engine.Graphics.Vulkan
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
</Project>
No NuGet packages for Vulkan. Pure P/Invoke.
Cross-Platform Notes
- Library name:
vulkan-1.dll(Windows) vslibvulkan.so.1(Linux) — handled inVulkanNative.cs - Surface creation: SDL3 abstracts platform differences (
SDL_Vulkan_CreateSurface) - SPIR-V: Binary format, identical on all platforms
- .NET 9:
NativeLibrary.Load()for dynamic resolution
Key Technical Details
Semaphore Indexing (CRITICAL)
Indexed by frame-in-flight (0..1) Indexed by swapchain image (0..N-1)
───────────────────────────────── ──────────────────────────────────
Acquire semaphore ✓
Command buffer ✓
Frame fence ✓
Submit semaphore ✓
Rationale: vkQueuePresentKHR cannot signal a fence/semaphore. The only way to know
a submit semaphore is safe to reuse is to acquire the same swapchain image index again
(which guarantees the previous present using that image has completed).
Indexing submit semaphores by frame-in-flight is a common bug that violates the spec.
Dynamic Rendering (Vulkan 1.3)
No VkRenderPass or VkFramebuffer objects needed:
// Instead of vkCmdBeginRenderPass:
VkRenderingAttachmentInfo colorAttachment = new() {
sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
imageView = swapchainImageViews[imageIndex],
imageLayout = COLOR_ATTACHMENT_OPTIMAL,
loadOp = CLEAR,
storeOp = STORE,
clearValue = new() { color = { 0, 0, 0, 1 } }
};
VkRenderingInfo renderingInfo = new() {
sType = VK_STRUCTURE_TYPE_RENDERING_INFO,
renderArea = { {0,0}, extent },
layerCount = 1,
colorAttachmentCount = 1,
pColorAttachments = &colorAttachment
};
vkCmdBeginRendering(commandBuffer, &renderingInfo);
// draw commands...
vkCmdEndRendering(commandBuffer);
Pipeline must include VkPipelineRenderingCreateInfo in pNext:
VkPipelineRenderingCreateInfo renderingInfo = new() {
sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO,
colorAttachmentCount = 1,
pColorAttachmentFormats = &swapchainFormat
};
// Chain in VkGraphicsPipelineCreateInfo.pNext
Sync2 Image Layout Transitions
Using vkCmdPipelineBarrier2 with VkImageMemoryBarrier2:
// UNDEFINED → COLOR_ATTACHMENT_OPTIMAL (before rendering)
VkImageMemoryBarrier2 toColor = new() {
sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
srcStageMask = PIPELINE_STAGE_2_NONE,
srcAccessMask = ACCESS_2_NONE,
dstStageMask = PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT,
dstAccessMask = ACCESS_2_COLOR_ATTACHMENT_WRITE,
oldLayout = UNDEFINED,
newLayout = COLOR_ATTACHMENT_OPTIMAL,
image = swapchainImages[imageIndex],
subresourceRange = { COLOR_BIT, 0, 1, 0, 1 }
};
// COLOR_ATTACHMENT_OPTIMAL → PRESENT_SRC_KHR (after rendering)
VkImageMemoryBarrier2 toPresent = new() {
sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
srcStageMask = PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT,
srcAccessMask = ACCESS_2_COLOR_ATTACHMENT_WRITE,
dstStageMask = PIPELINE_STAGE_2_ALL_GRAPHICS,
dstAccessMask = ACCESS_2_NONE,
oldLayout = COLOR_ATTACHMENT_OPTIMAL,
newLayout = PRESENT_SRC_KHR,
image = swapchainImages[imageIndex],
subresourceRange = { COLOR_BIT, 0, 1, 0, 1 }
};
VkDependencyInfo depInfo = new() {
sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
imageMemoryBarrierCount = 1,
pImageMemoryBarriers = &barrier
};
vkCmdPipelineBarrier2(commandBuffer, &depInfo);
Queue Submit (Sync2)
Using vkQueueSubmit2 with VkSubmitInfo2:
VkSemaphoreSubmitInfo waitInfo = new() {
sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO,
semaphore = acquireSemaphores[frameIndex],
stageMask = PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT
};
VkCommandBufferSubmitInfo cmdInfo = new() {
sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO,
commandBuffer = commandBuffers[frameIndex]
};
VkSemaphoreSubmitInfo signalInfo = new() {
sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO,
semaphore = submitSemaphores[imageIndex],
stageMask = PIPELINE_STAGE_2_ALL_GRAPHICS
};
VkSubmitInfo2 submitInfo = new() {
sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2,
waitSemaphoreInfoCount = 1,
pWaitSemaphoreInfos = &waitInfo,
commandBufferInfoCount = 1,
pCommandBufferInfos = &cmdInfo,
signalSemaphoreInfoCount = 1,
pSignalSemaphoreInfos = &signalInfo
};
vkQueueSubmit2(queue, 1, &submitInfo, frameFences[frameIndex]);
Vertex Layout
Vertex struct (9 floats, 36 bytes):
Position: vec3 (offset 0, format R32G32B32_SFLOAT, location 0)
Color: vec3 (offset 12, format R32G32B32_SFLOAT, location 1)
Normal: vec3 (offset 24, format R32G32B32_SFLOAT, location 2)
Validation Layers
string[] layers = enableValidation
? new[] { "VK_LAYER_KHRONOS_validation" }
: Array.Empty<string>();
string[] instanceExtensions = enableValidation
? [.. sdlExtensions, "VK_EXT_debug_utils"]
: sdlExtensions;
Debug callback (C#):
static uint DebugCallback(
nint instance, uint messageSeverity, uint messageTypes,
nint pCallbackData, nint pUserData)
{
var data = Marshal.PtrToStructure<VkDebugUtilsMessengerCallbackDataEXT>(pCallbackData);
Console.Error.WriteLine($"[Vulkan] {data.pMessage}");
return 0; // VK_FALSE — don't abort
}
Future Phases (Not in This Plan)
- Phase 11: ImGui integration (ImGui.NET + Vulkan backend)
- Phase 12: Mesh rendering (OBJ loading, index buffers, descriptor sets, UBO for camera)
- Phase 13: PBR shading (Fresnel, ACES tonemap, gamma correction, directional + point lights)
- Phase 14: Shadow mapping (depth-only render pass from light POV, PCF sampling)
- Phase 15: Screenshot capture (copy swapchain image to staging buffer → PNG)
- Phase 16: VMA (Vulkan Memory Allocator) for sub-allocation
- Phase 17: Multi-threaded command buffer recording