From the early designs of O3DE, the inclusion of Vulkan was a pivotal aspect of its design. Since we were starting from scratch, we were free from the constraints of legacy systems, allowing us for a streamlined and efficient approach to rendering. And by embracing only modern graphics APIs like Vulkan, DX12, or Metal, we were able to take full advantage of modern rendering techniques, shedding the burden of supporting older APIs like OpenGL or DX11. We were lucky, it was almost the ideal scenario when designing a graphics engine.
Central to Vulkan’s philosophy is its explicit nature, requiring developers to meticulously provide context and details for almost every operation. Unlike its predecessors, there’s little room for assumptions or deduced behaviors. The responsibility now lies with the application, which knows more about how the resources are being used. This shift from implicit to explicit resource management inspired the adoption of a key concept on O3DE: a render graph. A render graph serves as a structured representation of graphics workloads, describing dependencies between various tasks and the resources they consume. Each of the nodes in the graph not only contains a list of all resources being used, but also how they are being accessed. The render graph is one of the pivotal pieces that O3DE uses for rendering a frame.
The render graph also serves as a blueprint for resource synchronization, one of the most complex tasks in Vulkan. Since no synchronization is done by the API, we need to explicitly specify how and when the resources are synchronized. O3DE collects the information for building the render graph directly from the users using two approaches. The first one is a data driven one, using static JSON files to describe the contents of a unit of graphic work, or what we call a pass. The second one is using C++ code, where you can have a more dynamic approach using code to build a pass. Synchronization is a long and complex subject, and unfortunately I’m not going to be able to cover it in depth during this post, but if you want to know more about it, I recently gave a presentation on the Vulkanised 2024 conference. You can check the slides and video to know more, Mastering Chaos: Navigating Vulkan Synchronization on O3DE’.
One of the standout features of Vulkan is its innate flexibility. As it needs to run from high-end PCs to embedded systems, it must adjust its capabilities to suit the platform it’s running on. This means that certain features may be enabled or disabled depending on the device, requiring for O3DE to adapt the way it operates. Sometimes this translates into using different paths in order to complete a task. This type of flexibility was another concept that was included into the O3DE’s renderer design, since the goal was always to be able to run on multiple platforms.
Resource binding is a critical component of any graphics engine. It drives the way shaders are created and how resources are grouped, accessed and updated during a frame. Inspired by Vulkan’s descriptor sets, O3DE introduces the concept of Shader Resource Groups (SRGs), as a way of grouping resources and sharing them across different shaders. SRGs do have an additional characteristic: resources are grouped by their update frequency. O3DE uses SRGs per scene, per pass, per draw, per material, etc. SRGs map directly to a descriptor set, so they fit right in with Vulkan. SRGs are defined in a similar way as shaders, using a special syntax that was created by O3DE. In order to handle this new syntax, O3DE uses AZSL, which is a thin extension over HLSL. To complement AZSL, O3DE also introduced AZSLc, a compiler tool that serves the following two purposes:
- Transpile shaders written in AZSL into high-level shader language (HLSL).
- Extracts reflection data from the .azsl files including the shader constants layout, resource binding information, shader variant options, and more. The result outputs into JSON files.
To learn more about AZSLc, you can access the project GitHub.
While AZSLc bridges the gap between AZSL and HLSL, Vulkan requires SPIR-V code when compiling shaders. Enter the DirectX Shader Compiler (DXC), an open source compiler that is able to generate SPIR-V from HLSL among other intermediate representations. DXC not only facilitates the conversion, but also handles all Vulkan specific features like subpass inputs, push constants, specialization constants among others. DXC reinforces the importance of open source software, and is a key component of O3DE’s shader generation pipeline.
Vulkan is a dynamic ecosystem, with new functionalities being constantly added through the use of extensions. Most of these new extensions tend to be vendor specific, but some gain traction within the community and may eventually become part of the standard in upcoming releases. These new functionalities present an opportunity for O3DE to expand its functionalities. Adding new features is always a challenging task, since O3DE must support multiple graphics APIs. This means that special care must be taken when designing an interface that can satisfy all supported graphics APIs. Let’s do a quick example. A couple of months ago, we decided to add support to a new feature called “Variable Rate Shading” or VRS for short. If you don’t know what it is, it’s basically a way to define different rates at which the shading is going to happen on the screen. This allows you to shade at a lower rate in areas of the screen where you consider to be less important. The first task was to investigate how this feature was supported in Vulkan. To our surprise there were 2 different extensions that you can use in order to implement VRS. The first one, allows a per pipeline, a per primitive or a per image attachment way to specify the shading rate. The second one, only allows a per image attachment way of setting the shading rate. On the DX12 side (which was the only other API that supports VRS) it can do a per pipeline, per primitive and per image attachment way of defining the shading rate. Fortunately both APIs work in a similar way, so creating a common interface wasn’t that complicated.
The last obstacle that we encountered was that each implementation uses a different way of specifying the shading rate value, so we created a function to facilitate the translation from O3DE rate values to their implementation-specific counterparts. For the O3DE rate values, we decided to follow DX12, since they could be easily translated to the implementation specific ones.
Vulkan has an active open source community with multiple actors creating tools that help with development. One area where O3DE benefits from these tools is in memory management. Memory management is a non-trivial task that any graphics renderer needs to do. In the initial implementations of O3DE, we developed an internal memory management system that at that time seemed to fill our needs. As bigger projects started using O3DE, we realized that our memory system was not performing properly when a lot of entities were being used at the same time. So, we had a choice: try to make our system better or see what was already out there in the open source community. Luckily for us, AMD had already developed an open source library for memory management called VMA, that was tried and tested by multiple companies in big projects. Thanks to the simplicity of VMA, we were able to quickly integrate it into O3DE without any major changes, and most importantly without any modification to our external interfaces. Another victory for open source!
O3DE not only supports multiple graphics APIs, but also runs on different platforms. Having established stable support for Vulkan on PC, we decided to move to Android. Transitioning to a new platform always brings its own set of challenges, even when dealing with a universally compatible API like Vulkan. One such hurdle we encountered on Android was the limitation on the maximum bound descriptor sets for a pipeline. Unlike PC setups, where the number of bound descriptor sets isn’t usually a concern, mobile platforms typically impose stricter limits, often capping the value at around 4 sets. As we previously mentioned, O3DE uses multiple descriptor sets that are grouped by frequency. Addressing this challenge without compromising the existing codebase was crucial. We didn’t want to overhaul the entire system just to cater to Android devices. Instead, we needed a solution that reduced the number of SRGs without disrupting other platforms. After some consideration, we decided that merging the SRGs was the best approach. The beauty of this solution is that the merging happens automatically by AZSLc during the shader processing phase. This solution allowed us to seamlessly mitigate the descriptor set limitation on Android without requiring users to modify their code. However, this optimization wasn’t entirely without trade-offs. On Android, the merging of SRGs necessitated the creation of a new descriptor set, where values from non-merged sets had to be copied. While this process incurred a slight CPU overhead, it ensured compatibility across platforms without penalizing performance elsewhere.
The Vulkan API prioritizes minimal driver overhead, resulting in limited error checking by default. Because of the explicit nature of Vulkan, developers must specify everything they are doing. This approach can sometimes lead to numerous small mistakes. However, Vulkan offers a solution through validation layers, an elegant system that facilitates error detection during development. These layers can be stacked to provide comprehensive debugging capabilities. By enabling validation layers for debug builds and disabling them for release builds, developers can ensure efficient debugging without compromising performance. Even though Vulkan doesn’t come with validation layers included, once again the open source world comes to the rescue. Thanks to LunarG, a variety of validation layers are available, each of them can check different aspects of Vulkan at runtime. These layers are easily installable and customizable, making them invaluable tools for developers. I cannot stress how important validation layers were during development. They were life savers, and in my opinion, they provide superior error checking than other graphics APIs. Moreover, being open source, validation layers empower developers to create their own custom solutions as needed.
This blog post provides an exploration of O3DE’s journey with Vulkan, highlighting a small portion of the challenges, innovations, and insights gained along the way. Explore more about O3DE’s Vulkan journey and join the vibrant community on GitHub or on our Discord channel #sig-graphics-audio.
Meet Akio Gaule, Senior Graphics Programmer
Akio is a seasoned graphics programmer with over 15 years in the video game industry, with experience in multiple graphics APIs and video game engines. He has been working on O3DE since the early designs, where he contributed to the implementation of O3DE’s RHI (rendering hardware interface). During this tenure, he delved deep into Vulkan and has helped design and implement some the features present today in O3DE.