Vulkan support was introduced in v11.24.1. The details on how to enable Vulkan are here. In this post we would like to explain the reasoning for migrating from OpenGL to Vulkan, plus some more migration details.
Reasons for migrating from OpenGL to Vulkan
OpenGL has long been the primary graphics API in our map rendering engine. OpenGL is a time-proven API, but it is being steadily replaced by newer graphic APIs, like DX12, Metal and Vulkan. These modern APIs are designed to be really close to the hardware, reducing the overhead of issuing graphics API calls. OpenGL provides a high abstraction level, which imposes several considerations.
Built in error checking
Built in error checking may sound like a good thing as it keeps the rendering robust. The OpenGL driver is forced to maintain the global state and handle errors in a gentle way. For example, an unbound buffer during copy command will not cause a crash. But the downside is that the error checking consumes CPU cycles and cannot be switched off.
Runtime shader compilation
OpenGL accepts shaders written in GLSL, and the driver compiles them into machine code at runtime. This approach prevents shipping precompiled shaders, and causes stutter when there is a need to compile a shader.
Opaque memory management
With OpenGL, there is very limited control on what is allocated and when. This can cause stutters that are unpredictable and difficult to avoid.
Limited multithreading capabilities
OpenGL operates via contexts. A context is a complex object that encapsulates, accumulates and mutates a state responsible for holding all the data needed to execute a GPU command, i.e. make a draw call. That context is bound to only a single CPU thread at a time, restricting the ability to make API calls on a context from multiple threads in a concurrent manner. This prevents optimizations used in modern rendering engines, like recording draw commands from multiple threads concurrently.
State leaking
OpenGL maintains a state that is tied to a global context. State leaking is a known issue in multi-library scenarios, where each of the libraries makes OpenGL calls.
OpenGL deprecation
There are several reasons for the deprecation of OpenGL:
First, the evolution of the OpenGL ES API has been halted in favour of Vulkan API. The latest version of OpenGL ES 3.2 was released in 2015, since then no major releases were issued. New graphics features will only be exposed through Vulkan.
Next, Android has taken a course to deprecate OpenGL native driver support. According to the Vulkan roadmap for Android, starting 2025 the preferred way to make OpenGL calls is to go through the ANGLE abstraction layer, and then after translation, call to the Vulkan driver. ANGLE is the Almost Native Graphics Layer Engine, which is included in Android 15 and higher. The addition of a layer between render calls and the driver introduces more complexity and less control, and it will hurt performance.
Lastly, tooling is worth mentioning because it is crucial to the developer experience. Rendering tooling, like frame profilers or shader analyzers, is very limited or totally absent for OpenGL especially for mobile platforms. For example, modern gpu profiling tool sokatoa goes with Vulkan support only.
For these reasons, we decided to add support for a Vulkan backend in our renderer.
Introducing Vulkan
When transitioning to Vulkan, we first needed to decide which target platform Vulkan version to support.
Currently there are five notable Vulkan iterations: 1.0, 1.1, 1.2, 1.3 and 1.4. We chose Vulkan 1.1. It has a high device support percentage (87% support). Additionally, compared to 1.0 (93% device support), it has important functionality guaranteed; like dedicated allocations, and a negative viewport in order to flip the image without altering all the code responsible for transformations and SPIR-V 1.3. Detailed information on Vulkan support percentage on Android is in the distribution dashboard (Latest update November 2025).
In order to minimize the boilerplate of initializing Vulkan and setting up swapchain, we opt for widespread industry approved libraries: a combination of volk and vk-boostrap.
For presentation, we use the recommended mobile settings: two frames in flight, minimum of three requested swapchain images, and FIFO presentation mode.
Shaders
At the core of every rendering engine are shaders. Historically, we have a single shader codebase written in GLSL that is shared between all our backends. This allows us to guarantee visual parity across all platforms. GLSL is a first-class citizen for WebGL and OpenGL, whereas Vulkan accepts shaders in SPIR-V format.
This raises three main questions:
- What do we compile?
- When do we compile?
- How do we compile?
What do we compile?
For Vulkan, we want to preserve all the initial shaders. So our input is OpenGL ES 3.0 GLSL shader source code with multiple preprocessor definitions dependent logic. Those preprocessor definitions when replaced produce the result GLSL (shader variant) which is then used for rendering.
When do we compile?
To answer this question, the following options were considered:
Compile at runtime
There are pros and cons when compilation happens on demand. This is the least invasive to the infrastructure and to the rendering code as well. There’s no need to introduce additional artefacts, ship them, or provide them to the end-user. However, there are potential stutters or missing content on screen if compiled on the render thread.
Stuttering should be avoided at all costs. Missing content is also not ideal. So approaches with compiling SPIR-V beforehand were considered.
Compile during build time
There are pros and cons when compilation happens when we prepare our main artefact. It’s a benefit that there is no runtime compilation penalty for precompiled variants. However, it’s not possible to compile all the variants, and there is binary size growth.
Compile on the server
This is when compilation happens beforehand and SPIR-V is stored on the server. Those are then served to the client among other resources: geometry data, textures and SPIR-V. As with all options, there are pros and cons. There’s no runtime penalty, and you can precompile significantly more shader variants. However, the list of cons is lengthier.
- There are still too many variants to precompile
- You need to precompile variants for different client versions
- There is network traffic increase
- It is not suitable for offline
- It requires a lot more modifications across all our infrastructure
Mapbox chose a combination approach
Clearly none of these approaches is a silver bullet. So we opted for a combination: runtime, embedding precompiled SPIR-V, persistent caching, and background compilation.
Our maps styling and rendering is highly customisable by the end user, making it impossible to compile shader variants beforehand. Because of that, runtime compilation is unavoidable. Once a variant is compiled it is placed into a persistent cache, so further occurrences of that particular variant will not trigger compilation (even after application restarts).
It is impossible to precompile all the SPIR-V variants, but we can precompile the most common variants seen in our Standard style. For our Standard style we simulate the most common map usage scenarios and gather around 150 unique shaders, resulting in about 2 Mb of extra memory that we embed into the binaries.
To reduce hiccups, all the runtime compilation happens in the background thread so it does not block the render thread.
How do we compile?
For GLSL to SPIR-V compilation, we use the shaderc + SPIRV-Reflect libraries combo. Shaderc is based upon glslang - the reference GLSL to SPIR-V compiler.
We use the following shaderc::CompileOptions for compilation:
shaderc::CompileOptions compileOptions;
compileOptions.SetVulkanRulesRelaxed(true);
compileOptions.SetAutoBindUniforms(true);
compileOptions.SetAutoMapLocations(true);
compileOptions.SetTargetEnvironment(shaderc_target_env_vulkan, shaderc_env_version_vulkan_1_1);
compileOptions.SetOptimizationLevel(shaderc_optimization_level_size);
compileOptions.SetTargetSpirv(shaderc_spirv_version_1_3);
Those options make shaderc execute the following steps:
- Transform OpenGL ES 3.0 GLSL to intermediate Vulkan acceptable GLSL
- Compile intermediate GLSL to SPIR-V.
Pipelines
Once we have SPIR-V shaders, the next step is to compile those shaders to actual machine code. In Vulkan this is done as a part of pipeline creation.
A pipeline is the central Vulkan object since it is meant to encapsulate all the state information that is needed for rendering, including shaders. Pipeline creation is a time-consuming operation, in part due to SPIR-V compilation.
In order to speed up compilation we use VkPipelineCache as recommended by multiple GPU vendors.
That VkPipelineCache is also serialized to persistent cache so it can be reused for application reruns.
But this method doesn’t properly handle two important cases: when we cold start, and when we first encounter a certain shader variant. To handle this we use fossilize, a library that allows us to serialize data needed for pipeline creation, and replay that data later in order to create the exact same pipeline.
Similar to how we prepare the GLSL to SPIR-V prebuilt cache, we gather pipeline creation data for our Standard style, serialize that data, embed it in our build artifacts, and replay those pipelines on application start filling VkPipelineCache.
How do we handle uniforms?
Contrary to OpenGL and WebGL, Vulkan doesn’t accept free uniforms. Uniforms can be passed only via push constants or by buffer memory. Our aim was to make as few modifications to our high level API as possible.
When compiling from GLSL to SPIR-V, we pack all uniforms in a single uniform buffer object. That UBO is set dynamically for each draw call.
Vulkan accepts shader resources in the form of descriptor sets. In order to minimize descriptor set updates, we maintain a single large UBO which accepts all writes from uniform updates. This single UBO is used in cooperation with a single dynamic UBO descriptor. Essentially, we have a single descriptor responsible for all the rendering commands.
Memory
In Vulkan, it is the developers’ responsibility to make all the allocations in appropriate memory heaps. We opted for VMA, a well-known library that takes most of the allocation burden on its shoulders. VMA allows a certain amount of configuration. We settled on the following configuration:
- Render targets, large buffers, and textures are always allocated dedicated
- Vertex & index buffers, textures, staging buffers, uniform buffers all to separate pools
It’s also worth mentioning that due to frames in flight, all resources that imply per-frame or frequent updates have a number of frames in flight duplicates under the hood.
On platforms with Unified Memory Architecture (UMA) support, where both the CPU and the GPU share a single system RAM which includes most of the mobile platforms, we omit staging memory whenever it is possible.
Here is a a map view and corresponding memory layout
Validation layer
Layers are a core mechanic in the Vulkan API that allow extending behaviour beyond what the spec allows. The most useful layer is the Khronos validation layer. This layer validates Vulkan function calls: checking arguments, maintaining render state, and reporting if any errors are encountered.
We do execute our CI jobs with a validation layer enabled to ensure no validation errors on target devices.
It is worth noting that the Vulkan validation layer is far more powerful than the OpenGL error reporting system, which highlights only critical errors. Checking all graphics API calls via the Vulkan Validation layer highlights far more errors, incompatible formats, and suboptimal usage, even if compared to glDebugMessageCallback.
Results
Here are the gains of Vulkan migration.
Faster frames
We gained about 10% faster frames for CPU heavy styles with multiple draw calls. This is achieved by removing some of the CPU bottlenecks that OpenGL adds behind the scenes, such as validation optional and pipeline caching.
The gains can be seen both in lightweight and heavy scenes:
Standard style. Pixel 7 device. 50, 90, 95, 99 frame time percentile. Vulkan is about 10% faster.
Fewer hiccups and faster cold start times
By embedding most common SPIR-V & pipeline variants we get fewer hiccups and faster cold start times.
Better integration for multi-graphics library Vulkan-ecosystem
For developers who have Vulkan initialization handled on their side and render loop set up, we provide an API to inject global Vulkan objects into our library. Vulkan global objects we accept include: VkInstance, VkDevice, VkPhysicalDevice, VkQueue, and per frame data as well including VkCommandPool, VkCommandBuffer, VkImage for render target color and depth.
Conclusion
Vulkan is gaining more and more share of the mobile market. While OpenGL will not go away (at least via ANGLE), using it comes with sacrifices: a certain runtime penalty, less control on what is actually happening on the GPU, and less access to all the features the hardware exposes.
Since the Vulkan release in 2016, the drivers have matured enough to be production ready. The same is true for developer tooling. There are frame inspectors and shaders analyzers for all the major mobile vendors, clearly making Vulkan a viable option.
As a result, Vulkan support was added to our map rendering engine, without any major infrastructural changes, and with immediate gains like better frame rates and more control on stalls.
In addition to the immediate gains, Vulkan offers the potential for more intrusive engine optimizations. We can spread command buffer construction across multiple threads. We can move parts of the map engine logic to the GPU, like symbol placement. And we can reuse command buffers between frames for static data, and much more. In the coming months we’ll be exploring these optimizations further.












 (profiler built into our engine) gathered frame time histograms comparison for a high zoom, layer intense view point. Yellow - Vulkan. Red - OpenGL. About 10% from OpenGL median between OpenGL and Vulkan medians. <br>](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0uq4jw0uw303vhkqvqo.png)



Top comments (0)