{"article":{"slug":"from-opengl-to-vulkan","title":"From OpenGL to Vulkan","subtitle":null,"summary":"Alexandre Lamure walks through first-principles differences when porting a small OpenGL engine to Vulkan—bindless resources, explicit synchronization, and modern Vulkan 1.3 patterns—with pointers to nebula and further reading.","content_type":"tutorial","language":"en","canonical_url":"https://alexandrelamure.github.io/graphics-posts/from-opengl-to-vulkan.html","author":{"name":"Alexandre Lamure","url":"https://alexandrelamure.github.io","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Alexandre Lamure","url":"https://alexandrelamure.github.io","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Hardware","slug":"hardware","url":"https://listedarticles.com/topics/hardware"},{"name":"Tutorials","slug":"tutorials","url":"https://listedarticles.com/topics/tutorials"},{"name":"Engineering","slug":"engineering","url":"https://listedarticles.com/topics/engineering"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2040,"reading_minutes":9,"published_at":"2026-09-13T00:00:00.000Z","added_at":"2026-10-04T08:12:35.586Z","updated_at":"2026-10-04T08:12:35.586Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/from-opengl-to-vulkan","markdown_url":"https://listedarticles.com/articles/from-opengl-to-vulkan.md","example":false,"citation":"Alexandre Lamure, Alexandre Lamure. \"From OpenGL to Vulkan.\" 13 Sept 2026. https://alexandrelamure.github.io/graphics-posts/from-opengl-to-vulkan.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://alexandrelamure.github.io/graphics-posts/from-opengl-to-vulkan.html"},"body_markdown":"# From OpenGL to Vulkan\n        September 13, 2026\n\n\n\nI recently ported a basic OpenGL engine to Vulkan (see [nebula](https://github.com/AlexandreLamure/nebula)).\n\n        There are already plenty of great resources on transitioning from OpenGL to Vulkan:\n\n\n\n- [Transitioning from OpenGL to Vulkan by NVIDIA](https://developer.nvidia.com/transitioning-opengl-vulkan)\n\n- [Vulkanised 2023: Use \"bindless\" to quickly port \"bindful\" OpenGL apps to Vulkan](https://www.youtube.com/watch?v=yHNEecWwFOc)\n\n- [The journey of porting AnKi to Vulkan](https://anki3d.org/porting-anki-to-vulkan/)\n\n- [NAPTECH: OpenGL to Vulkan](https://blog.nap-framework.tech/d0/dfd/md_articles_001_nap_opengl_to_vulkan)\n\n\n\nBut I wanted to modestly contribute. The goal here is not to provide a detailed guide, but rather a few-minute read covering the core principles.\n        Many people still learn graphics programming with OpenGL, and feel overwhelmed by the Vulkan API. I hope this post can help them get started.\n\n\n\n\n## What doesn't change\n\n\nThe core ideas of OpenGL are still valid in Vulkan.\n\n\n- We prepare resources like buffers and textures on the CPU, and upload them to the GPU.\n\n- We can draw things through a rasterization pipeline.\n\n- This pipeline is configured by pipeline states (blending mode, depth test, backface culling, etc.).\n\n- We use shaders (vertex, fragment, compute...) to program what happens on the GPU.\n\n\n\n\n## What does change\n\n\nOpenGL is a black box: many concepts are hidden and simplified. For the sake of performance and flexibility, Vulkan makes them explicit.\n\n\n\n### Device choice\n\n\n            OpenGL: We don't choose which GPU to use, the driver decides for us. It is certainly possible to influence this choice, but the API doesn't make GPU selection a central part of the programming model.\n\n\n\n            Vulkan: We enumerate the available physical devices (GPUs), inspect their capabilities, and then select one from which to create a logical device.\n            This explicit choice is important for applications that require high performance or predictability.\n\n\n\n            Click to expand pseudo-code snippet\n\n\n```\n// Enumerate physical devices\nuint32_t deviceCount = 0;\nvkEnumeratePhysicalDevices(&deviceCount, nullptr);\nstd::vector<VkPhysicalDevice> physicalDevices(deviceCount);\nvkEnumeratePhysicalDevices(&deviceCount, physicalDevices.data());\n\n// Select the best GPU\nVkPhysicalDevice physicalDevice = VK_NULL_HANDLE;\nfor (auto candidate : physicalDevices) {\n    VkPhysicalDeviceProperties properties;\n    vkGetPhysicalDeviceProperties(candidate, &properties);\n    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {\n        physicalDevice = candidate;\n        break;\n    }\n}\n\n// Create a logical device from the selected physical device\nVkDevice logicalDevice;\nvkCreateDevice(physicalDevice, &logicalDevice);\n\n```\n\n\n\nNB: This snippet is simplified and won't compile. If you want to actually implement this,\n            you should use the [Vulkan documentation](https://docs.vulkan.org/spec/latest/index.html) and the links mentioned at the end of this blog post.\n\n\n\n\n\n### Scheduling\n\n\n            OpenGL: GPU scheduling is largely managed by the driver. Beginners tend to think that commands like `glBind*` or `glDraw*` are executed immediately,\n            because the API is designed to give that impression. In reality, they are queued and executed later. They might even get reordered, as OpenGL automatically infers dependencies and optimizes the workflow.\n\n\n\n            Vulkan: This mechanism isn't hidden anymore. We record a list of commands into a command buffer, then submit that command buffer to a queue. Nothing runs until we submit it.\n\n            We're also responsible for expressing dependencies ourselves using barriers, semaphores and fences. It's more bookkeeping, but the payoff is that the driver has much less guesswork to do at runtime, and we control how work is organized and submitted.\n\n            To go further, Vulkan allows us to fill command buffers with multiple threads, which is useful when CPU work gets heavy. We can also use multiple command queues to run different types of work in parallel on the GPU (e.g. compute, graphics, memory transfer).\n\n\n            Click to expand pseudo-code snippet\n\n\n```\n// Record commands into a command buffer, nothing is drawn yet\nvkBeginCommandBuffer(commandBuffer);\nvkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);\nvkCmdDrawIndexed(commandBuffer, indexCount);\nvkEndCommandBuffer(commandBuffer);\n\n// Submit the command buffer to a queue, rendering will start now\nvkQueueSubmit(queue, fence);\n\n```\n\n\n\n\n\n### Resource lifetime\n\n\n            OpenGL: We can delete a buffer after issuing a draw call using it, and OpenGL will make sure that the deletion doesn't happen while the GPU is still using the buffer.\n            The driver takes care of keeping the underlying objects alive for as long as necessary.\n\n\n\n            Vulkan: Resource lifetime is our responsibility. Destroying a buffer while the GPU still references it means trouble.\n            The CPU and GPU work asynchronously, so we use fences to signal when the GPU has finished using a resource.\n\n            In practice, most engines don't check this per-resource. They keep a few frames in flight: while the GPU is still rendering frame N,\n            the CPU is already recording frame N+1. Each in-flight frame has its own command buffers and a fence.\n            We only destroy or reuse a resource once that frame's fence signals, so we know the GPU is done with it.\n\n\n            Click to expand diagram\n\n\nThis diagram illustrates the lifecycle of an engine with 2 frames in flight.\n\n                The fence #0 synchronizes frames #0, #2, #4, etc. After the CPU submits the commands for frame #0, the GPU begins processing them. Once the GPU has finished, it signals fence #0, allowing the CPU to reuse the resources associated with frame #0 for frame #2.\n\n                Meanwhile, the fence #1 (not shown here) ensures the proper scheduling of frames #1, #3, #5, etc.\n\n\n\n\n            Click to expand pseudo-code snippet\n\n\n```\nconstexpr uint32_t FRAMES_IN_FLIGHT = 2; // Number of frames in flight\nVkCommandBuffer commandBuffers[FRAMES_IN_FLIGHT];\nVkFence fences[FRAMES_IN_FLIGHT]; // One fence per in-flight frame.\nVkBuffer uniformBuffers[FRAMES_IN_FLIGHT]; // Resources are duplicated per in-flight frame\nuint32_t frame = 0;\n\n// Main loop\nwhile(true) {\n    // Wait until the GPU has finished the last submit that used this slot\n    vkWaitForFences(&fences[frame]);\n    vkResetFences(&fences[frame]);\n\n    // Fence signaled: this frame's resources are no longer in use, we can rewrite it\n    memcpy(uniformMapped[frame], &camera, sizeof(camera));\n\n    // Submit a command buffer with the frame's fence.\n    // The fence will be signaled when the GPU is done with the command buffer.\n    vkBeginCommandBuffer(commandBuffers[frame]);\n    vkCmdDrawIndexed(commandBuffers[frame], indexCount);\n    vkEndCommandBuffer(commandBuffers[frame]);\n    vkQueueSubmit(queue, fences[frame]);\n\n    // Increment the frame index.\n    frame = (frame + 1) % FRAMES_IN_FLIGHT;\n}\n\n```\n\n\n\n\n\n### Descriptor sets\n\n\n            OpenGL: Binding resources is a sequence of individual calls. `glBindTexture`, `glBindBufferBase`, `glUniform*`... each one updates a piece\n            of OpenGL's global state machine: a texture unit, a buffer slot, a uniform value.\n\n            The currently bound shader then reads from that state.\n\n\n\n            Vulkan: Resources are grouped into descriptor sets. There is no global binding state.\n            The driver no longer has to reconstruct shader inputs from scattered slots, and we can reuse a set across many draws instead of rebinding everything from scratch.\n\n            The usual trick is to split sets by update frequency: one set for data that changes once per frame (camera, lights), another for data that changes per draw or per pass (material textures).\n\n            Tiny per-draw values like a model matrix usually go into push constants, a small fast path that doesn't need a descriptor set at all.\n\n\n            Click to expand pseudo-code snippet\n\n\n```\n// Write camera data into the frame descriptor set, once per frame\nVkDescriptorSet frameSet;\nmemcpy(cameraMapped, &camera, sizeof(camera));\nVkDescriptorBufferInfo cameraInfo{ .buffer = cameraUBO, .range = VK_WHOLE_SIZE };\nVkWriteDescriptorSet frameWrite{\n    .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n    .dstSet = frameSet,\n    .dstBinding = 0,\n    .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,\n    .pBufferInfo = &cameraInfo\n};\nvkUpdateDescriptorSets(device, &frameWrite);\nvkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 0, &frameSet);\n\nfor (auto& pass : passes) {\n    // Write material textures to the pass descriptor set, once per pass\n    VkDescriptorImageInfo albedoInfo{ .imageView = pass.albedoView };\n    VkDescriptorImageInfo normalInfo{ .imageView = pass.normalView };\n    VkDescriptorSet passSet = pass.set;\n    VkWriteDescriptorSet passWrites[] = {\n        {\n            .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n            .dstSet = passSet,\n            .dstBinding = 0,\n            .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,\n            .pImageInfo = &albedoInfo\n        },\n        {\n            .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n            .dstSet = passSet,\n            .dstBinding = 1,\n            .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,\n            .pImageInfo = &normalInfo\n        }\n    };\n    vkUpdateDescriptorSets(device, std::size(passWrites), passWrites);\n    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 1, &passSet);\n\n    for (auto& object : pass.objects) {\n        // Write model matrix to push constants, once per object\n        vkCmdPushConstants(cmd, VK_SHADER_STAGE_VERTEX_BIT, sizeof(object.model), &object.model);\n        // Once all bindings are bound to the command buffer, we can issue the draw call.\n        vkCmdDrawIndexed(cmd, object.indexCount);\n    }\n}\n\n```\n\n\n\n\n\n### Image layout\n\n\n            OpenGL: A texture is a texture. We don't tell the driver how we intend to use it at a given moment; it rearranges GPU memory behind the scenes.\n\n\n\n            Vulkan: Images have an explicit layout that must match their current use.\n            `TRANSFER_DST_OPTIMAL` for uploads, `COLOR_ATTACHMENT_OPTIMAL` when rendering into them, `SHADER_READ_ONLY_OPTIMAL` when sampling, `PRESENT_SRC_KHR` when presenting to the swapchain.\n            We transition from one layout to another with a barrier. Using an image in the wrong layout is a classic source of validation errors (or silent corruption).\n\n\n            Click to expand pseudo-code snippet\n\n\n```\n// After rendering into the image, transition it so a later pass can sample it\nVkImageMemoryBarrier barrier{\n    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,\n    .oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,\n    .newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,\n    .image = image\n};\nvkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,\n                     VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, &barrier);\n\n```\n\n\n\n\n\n### Shader compilation\n\n\n            OpenGL: We ship GLSL source and compile it at runtime with `glCompileShader` / `glLinkProgram`. The driver turns it into GPU machine code, which means the generated code heavily depends on the user's hardware.\n\n\n\n            Vulkan: The API only accepts SPIR-V, an intermediate bytecode. We can still write our shaders in GLSL (or HLSL), but they are compiled to SPIR-V as part of our build pipeline rather than by the Vulkan driver at runtime.\n            This makes shader compilation more explicit and predictable.\n\n\n\n\n## Basic Vulkan setup\n\n\nTo get started, you'll need to install the Vulkan SDK. It will provide you with some tools like validation layers (API error checking), a SPIR-V compiler, etc.\n            Then I recommend these libraries:\n\n\n\n- [volk](https://github.com/zeux/volk) for function loading. It replaces glad or glew for OpenGL.\n\n- [VMA](https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator) to simplify low-level memory management. That part was completely hidden in OpenGL.\n\n- [vk-bootstrap](https://github.com/charles-lunarg/vk-bootstrap) can be a good addition if you want to reduce Vulkan's initialization boilerplate.\n\n- [GLFW](https://github.com/glfw/glfw),\n                [GLM](https://github.com/g-truc/glm),\n                [stb](https://github.com/nothings/stb),\n                [tinygltf](https://github.com/syoyo/tinygltf) and\n                [ImGui](https://github.com/ocornut/imgui), which you may have used in OpenGL, still work great with Vulkan.\n\n\n\n\n## Architecture\n\n\nVulkan forces us to build a proper architecture, even for small engines. Skip it, and you'll quickly end up with a slow, messy, bug-prone codebase — the API is less forgiving than OpenGL.\n\n            Scene code and materials can stay close to your OpenGL engine, but the backend needs to handle work queues, resource lifetimes, and dependencies.\n\n\n\nThe base layer is a device, a swapchain, and two frames in flight so the CPU can keep preparing frame N+1 while the GPU is still working on frame N.\n            Each of those two frames gets its own command buffer and fence, plus a deletion queue: instead of destroying a resource immediately, we push its handle into that frame's queue,\n            and it's only actually released once that frame's fence signals (i.e. once we know the GPU is done using it).\n\n\n\nTo bind resources, a basic setup would be to use a descriptor set for the frame data (camera and lights) and another one for the pass data (material textures, model matrices, etc.).\n            As a starter I would advise managing the pass descriptor set through `vkCmdPushDescriptorSet`. This Vulkan 1.4 feature allows you to update your resources on-the-fly, in a simple inlined manner.\n\n        If you wish to go further, you should learn about bindless rendering. The idea is to bind a single descriptor set containing a giant array of all the resources we need to draw the entire frame.\n        This allows us to avoid binding a new descriptor set for each object — something that hurts performance at scale. Once every texture and buffer is available on the GPU, drawing an object simply means fetching the giant array at the right index.\n        This lightweight index can be passed as a push constant.\n\n\n\nFor image layout transitions, the cleanest approach is to build a render graph (a.k.a. \"frame graph\"). Rather than manually inserting barriers between every pass, we declare each pass along with the images it reads from and writes to;\n            the graph figures out the dependency order and inserts the right layout transitions and barriers for us. Then we can simply declare passes without worrying about synchronization.\n\n            This abstraction layer can even be used to optimize the rendering pipeline, for example by minimizing the number of barriers and reusing a texture from a previous pass instead of allocating a new one.\n\n\n\n\n## Further reading\n\n\nIf you want to actually implement a Vulkan engine, I recommend the following resources:\n\n\n- [vulkan-tutorial.com](https://vulkan-tutorial.com/Introduction), a complete walkthrough, though somewhat outdated\n\n- [vkguide.dev](https://vkguide.dev/), a more modern guide\n\n- [howtovulkan.com](https://howtovulkan.com/), with an emphasis on modern Vulkan 1.3 features\n\n\n\n\n[Back to graphics](../graphics.html)","body_html":"<h1 id=\"from-opengl-to-vulkan\">From OpenGL to Vulkan</h1>\n<pre><code>    September 13, 2026</code></pre>\n<p>I recently ported a basic OpenGL engine to Vulkan (see <a href=\"https://github.com/AlexandreLamure/nebula\" rel=\"nofollow ugc noopener\">nebula</a>).</p>\n<pre><code>    There are already plenty of great resources on transitioning from OpenGL to Vulkan:</code></pre>\n<ul><li><a href=\"https://developer.nvidia.com/transitioning-opengl-vulkan\" rel=\"nofollow ugc noopener\">Transitioning from OpenGL to Vulkan by NVIDIA</a></li><li><a href=\"https://www.youtube.com/watch?v=yHNEecWwFOc\" rel=\"nofollow ugc noopener\">Vulkanised 2023: Use &quot;bindless&quot; to quickly port &quot;bindful&quot; OpenGL apps to Vulkan</a></li><li><a href=\"https://anki3d.org/porting-anki-to-vulkan/\" rel=\"nofollow ugc noopener\">The journey of porting AnKi to Vulkan</a></li><li><a href=\"https://blog.nap-framework.tech/d0/dfd/md_articles_001_nap_opengl_to_vulkan\" rel=\"nofollow ugc noopener\">NAPTECH: OpenGL to Vulkan</a></li></ul>\n<p>But I wanted to modestly contribute. The goal here is not to provide a detailed guide, but rather a few-minute read covering the core principles.\n        Many people still learn graphics programming with OpenGL, and feel overwhelmed by the Vulkan API. I hope this post can help them get started.</p>\n<h2 id=\"what-doesn-t-change\">What doesn&#39;t change</h2>\n<p>The core ideas of OpenGL are still valid in Vulkan.</p>\n<ul><li>We prepare resources like buffers and textures on the CPU, and upload them to the GPU.</li><li>We can draw things through a rasterization pipeline.</li><li>This pipeline is configured by pipeline states (blending mode, depth test, backface culling, etc.).</li><li>We use shaders (vertex, fragment, compute...) to program what happens on the GPU.</li></ul>\n<h2 id=\"what-does-change\">What does change</h2>\n<p>OpenGL is a black box: many concepts are hidden and simplified. For the sake of performance and flexibility, Vulkan makes them explicit.</p>\n<h3 id=\"device-choice\">Device choice</h3>\n<pre><code>        OpenGL: We don&#39;t choose which GPU to use, the driver decides for us. It is certainly possible to influence this choice, but the API doesn&#39;t make GPU selection a central part of the programming model.\n\n\n\n        Vulkan: We enumerate the available physical devices (GPUs), inspect their capabilities, and then select one from which to create a logical device.\n        This explicit choice is important for applications that require high performance or predictability.\n\n\n\n        Click to expand pseudo-code snippet</code></pre>\n<pre><code>// Enumerate physical devices\nuint32_t deviceCount = 0;\nvkEnumeratePhysicalDevices(&amp;deviceCount, nullptr);\nstd::vector&lt;VkPhysicalDevice&gt; physicalDevices(deviceCount);\nvkEnumeratePhysicalDevices(&amp;deviceCount, physicalDevices.data());\n\n// Select the best GPU\nVkPhysicalDevice physicalDevice = VK_NULL_HANDLE;\nfor (auto candidate : physicalDevices) {\n    VkPhysicalDeviceProperties properties;\n    vkGetPhysicalDeviceProperties(candidate, &amp;properties);\n    if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {\n        physicalDevice = candidate;\n        break;\n    }\n}\n\n// Create a logical device from the selected physical device\nVkDevice logicalDevice;\nvkCreateDevice(physicalDevice, &amp;logicalDevice);\n</code></pre>\n<p>NB: This snippet is simplified and won&#39;t compile. If you want to actually implement this,\n            you should use the <a href=\"https://docs.vulkan.org/spec/latest/index.html\" rel=\"nofollow ugc noopener\">Vulkan documentation</a> and the links mentioned at the end of this blog post.</p>\n<h3 id=\"scheduling\">Scheduling</h3>\n<pre><code>        OpenGL: GPU scheduling is largely managed by the driver. Beginners tend to think that commands like `glBind*` or `glDraw*` are executed immediately,\n        because the API is designed to give that impression. In reality, they are queued and executed later. They might even get reordered, as OpenGL automatically infers dependencies and optimizes the workflow.\n\n\n\n        Vulkan: This mechanism isn&#39;t hidden anymore. We record a list of commands into a command buffer, then submit that command buffer to a queue. Nothing runs until we submit it.\n\n        We&#39;re also responsible for expressing dependencies ourselves using barriers, semaphores and fences. It&#39;s more bookkeeping, but the payoff is that the driver has much less guesswork to do at runtime, and we control how work is organized and submitted.\n\n        To go further, Vulkan allows us to fill command buffers with multiple threads, which is useful when CPU work gets heavy. We can also use multiple command queues to run different types of work in parallel on the GPU (e.g. compute, graphics, memory transfer).\n\n\n        Click to expand pseudo-code snippet</code></pre>\n<pre><code>// Record commands into a command buffer, nothing is drawn yet\nvkBeginCommandBuffer(commandBuffer);\nvkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);\nvkCmdDrawIndexed(commandBuffer, indexCount);\nvkEndCommandBuffer(commandBuffer);\n\n// Submit the command buffer to a queue, rendering will start now\nvkQueueSubmit(queue, fence);\n</code></pre>\n<h3 id=\"resource-lifetime\">Resource lifetime</h3>\n<pre><code>        OpenGL: We can delete a buffer after issuing a draw call using it, and OpenGL will make sure that the deletion doesn&#39;t happen while the GPU is still using the buffer.\n        The driver takes care of keeping the underlying objects alive for as long as necessary.\n\n\n\n        Vulkan: Resource lifetime is our responsibility. Destroying a buffer while the GPU still references it means trouble.\n        The CPU and GPU work asynchronously, so we use fences to signal when the GPU has finished using a resource.\n\n        In practice, most engines don&#39;t check this per-resource. They keep a few frames in flight: while the GPU is still rendering frame N,\n        the CPU is already recording frame N+1. Each in-flight frame has its own command buffers and a fence.\n        We only destroy or reuse a resource once that frame&#39;s fence signals, so we know the GPU is done with it.\n\n\n        Click to expand diagram</code></pre>\n<p>This diagram illustrates the lifecycle of an engine with 2 frames in flight.</p>\n<pre><code>            The fence #0 synchronizes frames #0, #2, #4, etc. After the CPU submits the commands for frame #0, the GPU begins processing them. Once the GPU has finished, it signals fence #0, allowing the CPU to reuse the resources associated with frame #0 for frame #2.\n\n            Meanwhile, the fence #1 (not shown here) ensures the proper scheduling of frames #1, #3, #5, etc.\n\n\n\n\n        Click to expand pseudo-code snippet</code></pre>\n<pre><code>constexpr uint32_t FRAMES_IN_FLIGHT = 2; // Number of frames in flight\nVkCommandBuffer commandBuffers[FRAMES_IN_FLIGHT];\nVkFence fences[FRAMES_IN_FLIGHT]; // One fence per in-flight frame.\nVkBuffer uniformBuffers[FRAMES_IN_FLIGHT]; // Resources are duplicated per in-flight frame\nuint32_t frame = 0;\n\n// Main loop\nwhile(true) {\n    // Wait until the GPU has finished the last submit that used this slot\n    vkWaitForFences(&amp;fences[frame]);\n    vkResetFences(&amp;fences[frame]);\n\n    // Fence signaled: this frame&#39;s resources are no longer in use, we can rewrite it\n    memcpy(uniformMapped[frame], &amp;camera, sizeof(camera));\n\n    // Submit a command buffer with the frame&#39;s fence.\n    // The fence will be signaled when the GPU is done with the command buffer.\n    vkBeginCommandBuffer(commandBuffers[frame]);\n    vkCmdDrawIndexed(commandBuffers[frame], indexCount);\n    vkEndCommandBuffer(commandBuffers[frame]);\n    vkQueueSubmit(queue, fences[frame]);\n\n    // Increment the frame index.\n    frame = (frame + 1) % FRAMES_IN_FLIGHT;\n}\n</code></pre>\n<h3 id=\"descriptor-sets\">Descriptor sets</h3>\n<pre><code>        OpenGL: Binding resources is a sequence of individual calls. `glBindTexture`, `glBindBufferBase`, `glUniform*`... each one updates a piece\n        of OpenGL&#39;s global state machine: a texture unit, a buffer slot, a uniform value.\n\n        The currently bound shader then reads from that state.\n\n\n\n        Vulkan: Resources are grouped into descriptor sets. There is no global binding state.\n        The driver no longer has to reconstruct shader inputs from scattered slots, and we can reuse a set across many draws instead of rebinding everything from scratch.\n\n        The usual trick is to split sets by update frequency: one set for data that changes once per frame (camera, lights), another for data that changes per draw or per pass (material textures).\n\n        Tiny per-draw values like a model matrix usually go into push constants, a small fast path that doesn&#39;t need a descriptor set at all.\n\n\n        Click to expand pseudo-code snippet</code></pre>\n<pre><code>// Write camera data into the frame descriptor set, once per frame\nVkDescriptorSet frameSet;\nmemcpy(cameraMapped, &amp;camera, sizeof(camera));\nVkDescriptorBufferInfo cameraInfo{ .buffer = cameraUBO, .range = VK_WHOLE_SIZE };\nVkWriteDescriptorSet frameWrite{\n    .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n    .dstSet = frameSet,\n    .dstBinding = 0,\n    .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,\n    .pBufferInfo = &amp;cameraInfo\n};\nvkUpdateDescriptorSets(device, &amp;frameWrite);\nvkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 0, &amp;frameSet);\n\nfor (auto&amp; pass : passes) {\n    // Write material textures to the pass descriptor set, once per pass\n    VkDescriptorImageInfo albedoInfo{ .imageView = pass.albedoView };\n    VkDescriptorImageInfo normalInfo{ .imageView = pass.normalView };\n    VkDescriptorSet passSet = pass.set;\n    VkWriteDescriptorSet passWrites[] = {\n        {\n            .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n            .dstSet = passSet,\n            .dstBinding = 0,\n            .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,\n            .pImageInfo = &amp;albedoInfo\n        },\n        {\n            .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,\n            .dstSet = passSet,\n            .dstBinding = 1,\n            .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,\n            .pImageInfo = &amp;normalInfo\n        }\n    };\n    vkUpdateDescriptorSets(device, std::size(passWrites), passWrites);\n    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 1, &amp;passSet);\n\n    for (auto&amp; object : pass.objects) {\n        // Write model matrix to push constants, once per object\n        vkCmdPushConstants(cmd, VK_SHADER_STAGE_VERTEX_BIT, sizeof(object.model), &amp;object.model);\n        // Once all bindings are bound to the command buffer, we can issue the draw call.\n        vkCmdDrawIndexed(cmd, object.indexCount);\n    }\n}\n</code></pre>\n<h3 id=\"image-layout\">Image layout</h3>\n<pre><code>        OpenGL: A texture is a texture. We don&#39;t tell the driver how we intend to use it at a given moment; it rearranges GPU memory behind the scenes.\n\n\n\n        Vulkan: Images have an explicit layout that must match their current use.\n        `TRANSFER_DST_OPTIMAL` for uploads, `COLOR_ATTACHMENT_OPTIMAL` when rendering into them, `SHADER_READ_ONLY_OPTIMAL` when sampling, `PRESENT_SRC_KHR` when presenting to the swapchain.\n        We transition from one layout to another with a barrier. Using an image in the wrong layout is a classic source of validation errors (or silent corruption).\n\n\n        Click to expand pseudo-code snippet</code></pre>\n<pre><code>// After rendering into the image, transition it so a later pass can sample it\nVkImageMemoryBarrier barrier{\n    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,\n    .oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,\n    .newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,\n    .image = image\n};\nvkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,\n                     VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, &amp;barrier);\n</code></pre>\n<h3 id=\"shader-compilation\">Shader compilation</h3>\n<pre><code>        OpenGL: We ship GLSL source and compile it at runtime with `glCompileShader` / `glLinkProgram`. The driver turns it into GPU machine code, which means the generated code heavily depends on the user&#39;s hardware.\n\n\n\n        Vulkan: The API only accepts SPIR-V, an intermediate bytecode. We can still write our shaders in GLSL (or HLSL), but they are compiled to SPIR-V as part of our build pipeline rather than by the Vulkan driver at runtime.\n        This makes shader compilation more explicit and predictable.</code></pre>\n<h2 id=\"basic-vulkan-setup\">Basic Vulkan setup</h2>\n<p>To get started, you&#39;ll need to install the Vulkan SDK. It will provide you with some tools like validation layers (API error checking), a SPIR-V compiler, etc.\n            Then I recommend these libraries:</p>\n<ul><li><a href=\"https://github.com/zeux/volk\" rel=\"nofollow ugc noopener\">volk</a> for function loading. It replaces glad or glew for OpenGL.</li><li><a href=\"https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator\" rel=\"nofollow ugc noopener\">VMA</a> to simplify low-level memory management. That part was completely hidden in OpenGL.</li><li><a href=\"https://github.com/charles-lunarg/vk-bootstrap\" rel=\"nofollow ugc noopener\">vk-bootstrap</a> can be a good addition if you want to reduce Vulkan&#39;s initialization boilerplate.</li><li><p><a href=\"https://github.com/glfw/glfw\" rel=\"nofollow ugc noopener\">GLFW</a>,</p><pre><code>          [GLM](https://github.com/g-truc/glm),\n          [stb](https://github.com/nothings/stb),\n          [tinygltf](https://github.com/syoyo/tinygltf) and\n          [ImGui](https://github.com/ocornut/imgui), which you may have used in OpenGL, still work great with Vulkan.</code></pre></li></ul>\n<h2 id=\"architecture\">Architecture</h2>\n<p>Vulkan forces us to build a proper architecture, even for small engines. Skip it, and you&#39;ll quickly end up with a slow, messy, bug-prone codebase — the API is less forgiving than OpenGL.</p>\n<pre><code>        Scene code and materials can stay close to your OpenGL engine, but the backend needs to handle work queues, resource lifetimes, and dependencies.</code></pre>\n<p>The base layer is a device, a swapchain, and two frames in flight so the CPU can keep preparing frame N+1 while the GPU is still working on frame N.\n            Each of those two frames gets its own command buffer and fence, plus a deletion queue: instead of destroying a resource immediately, we push its handle into that frame&#39;s queue,\n            and it&#39;s only actually released once that frame&#39;s fence signals (i.e. once we know the GPU is done using it).</p>\n<p>To bind resources, a basic setup would be to use a descriptor set for the frame data (camera and lights) and another one for the pass data (material textures, model matrices, etc.).\n            As a starter I would advise managing the pass descriptor set through <code>vkCmdPushDescriptorSet</code>. This Vulkan 1.4 feature allows you to update your resources on-the-fly, in a simple inlined manner.</p>\n<pre><code>    If you wish to go further, you should learn about bindless rendering. The idea is to bind a single descriptor set containing a giant array of all the resources we need to draw the entire frame.\n    This allows us to avoid binding a new descriptor set for each object — something that hurts performance at scale. Once every texture and buffer is available on the GPU, drawing an object simply means fetching the giant array at the right index.\n    This lightweight index can be passed as a push constant.</code></pre>\n<p>For image layout transitions, the cleanest approach is to build a render graph (a.k.a. &quot;frame graph&quot;). Rather than manually inserting barriers between every pass, we declare each pass along with the images it reads from and writes to;\n            the graph figures out the dependency order and inserts the right layout transitions and barriers for us. Then we can simply declare passes without worrying about synchronization.</p>\n<pre><code>        This abstraction layer can even be used to optimize the rendering pipeline, for example by minimizing the number of barriers and reusing a texture from a previous pass instead of allocating a new one.</code></pre>\n<h2 id=\"further-reading\">Further reading</h2>\n<p>If you want to actually implement a Vulkan engine, I recommend the following resources:</p>\n<ul><li><a href=\"https://vulkan-tutorial.com/Introduction\" rel=\"nofollow ugc noopener\">vulkan-tutorial.com</a>, a complete walkthrough, though somewhat outdated</li><li><a href=\"https://vkguide.dev/\" rel=\"nofollow ugc noopener\">vkguide.dev</a>, a more modern guide</li><li><a href=\"https://howtovulkan.com/\" rel=\"nofollow ugc noopener\">howtovulkan.com</a>, with an emphasis on modern Vulkan 1.3 features</li></ul>\n<p>Back to graphics</p>","headings":[{"level":1,"text":"From OpenGL to Vulkan","id":"from-opengl-to-vulkan"},{"level":2,"text":"What doesn't change","id":"what-doesn-t-change"},{"level":2,"text":"What does change","id":"what-does-change"},{"level":3,"text":"Device choice","id":"device-choice"},{"level":3,"text":"Scheduling","id":"scheduling"},{"level":3,"text":"Resource lifetime","id":"resource-lifetime"},{"level":3,"text":"Descriptor sets","id":"descriptor-sets"},{"level":3,"text":"Image layout","id":"image-layout"},{"level":3,"text":"Shader compilation","id":"shader-compilation"},{"level":2,"text":"Basic Vulkan setup","id":"basic-vulkan-setup"},{"level":2,"text":"Architecture","id":"architecture"},{"level":2,"text":"Further reading","id":"further-reading"}]}}