TBF #18 – WebGPU

This is part three of a three-part series about how we ported our custom C++ engine to the web for the prototype build of Make A Pair. See the previous entry right here.

WebGPU

"WebGPU is a modern, high-performance web API that grants applications low-level access to a device's GPU for rendering advanced graphics and executing highly parallel computations. It is the direct successor to WebGL and natively interfaces with low-level modern systems like Vulkan, Metal, and DirectX 12."

This is the first sentence you can read about WebGPU on Wikipedia. The technology rolled version 1.0 into every major browser earlier this year, and adoption is going strong on the platforms still missing it. During the development of WebGPU, the committee wanted to create an abstraction that would work for the web, but also for other applications like a native desktop application. They made a clear definition: an application using WebGPU outside of the web is called "native".

WebGPU is implemented on top of all the other major graphics APIs. It is what is classically called a Rendering Hardware Interface (or RHI), and it supports Vulkan, Metal, DX12 and OpenGL. This allows it to be used on every platform, with the caveat that it is necessarily restricted by the weakest hardware with the fewest API features available.

WebGPU has two main libraries that implement it, and that you can use:

At Studio Cellier, I decided to use Dawn, since it was simply easier for me to work in C/C++. The whole codebase is already written in C++, so what harm could a little bit more do?

The first thing that hit me was how long a fresh build would take. After adding Dawn to our CMake, I saw a massive increase from 1 minute to 13 minutes on Windows. Our incremental builds went from 2 seconds to 11 seconds.

This meant I had to be very careful not to trigger a fresh build whenever I needed to change a cached CMake variable, just to spare myself that long compilation time.

Furthermore, Dawn requires the project it compiles with to use C++20 at minimum. As a reminder, our own RHI, called SGL, was on C++17. I had to bump SGL to C++20 while adding WebGPU as a valid target, otherwise it was impossible to move forward. If you are thinking about replacing your stack with WebGPU only, this is a real consideration to make, especially if you want to debug and build from source.

On top of that, Dawn is not tested with MSVC. As game developers, Windows is the platform we use day to day, and MSVC is our default compiler. During compilation, I found that a subset of the Vulkan implementation would not build under MSVC. It forced me to disable that backend, on Windows only, through CMake options.

Once I got that working, the next issue I ran into was that Dawn prefers FXC over DXC. Both are DirectX shader compilers, used to turn HLSL code into the opcode that will be executed by the GPU. FXC is the legacy compiler from the DX11 days, while DXC is the "new" compiler currently used by most companies shipping DX12. Since we do not ship DX11 or its DLLs, I had to force Dawn to use DXC instead of FXC.

CMake
# On Windows, they don't build against MSVC and the Vulkan path is failing to compile.
# So it's disabled.
if(WIN32)
    set(DAWN_ENABLE_VULKAN OFF)
    set(DAWN_ENABLE_D3D12 ON)

    # Disable FXC as this is the old DirectX compiler. Favors DXC always.
    set(TINT_ENABLE_HLSL_WRITER_FXC OFF)
endif()

Finally, after all this project configuration work, the code was compiling and I could start writing WebGPU-specific code in SGL. In SGL, there is a very simple and clear way to implement a graphics API.

If you have ever written a backend with DX12, Metal or Vulkan, implementing WebGPU will not be very difficult. Since it was mostly boilerplate to type out (around 2000 lines), I will simply go over the main challenges I faced during the implementation. There is a high probability that this blog post is the only one discussing these issues, until some AI bot crawls this page and internalizes the knowledge. But for a hot minute, this will probably be the only place.

Transpiling

If there is one issue plaguing the graphics programming world, it's transpiling. Everybody hates it. Every backend API has its own shader language (GLSL, HLSL, MSL, WGSL) that you must conform to if you want to see anything on screen.

No programmer wants to write the same shader once per backend API. That would be a massive waste of time. What happens instead is that we take a source-of-truth language and transform it into the other languages, depending on a set of settings and the target platform we wish to deploy to. This operation is usually called transpiling. This is why you will see commercial game engines either default to HLSL/GLSL, or write their own shader language or library (like SLANG).

It's horrible, inefficient, and causes massive headaches for everyone. In our case, we use HLSL as the source of truth, which is then transpiled into the target IR we are currently working with.

WebGPU decided that it needed a new language sitting above all the other ones. It is called WGSL, or WebGPU Shading Language. This means that you, the developer, also have to transpile your code into this new shading language. Thankfully, both libraries ship a shader compiler for it: Naga for wgpu, and Tint for Dawn.

Let's look in detail at how the process works for us in practice on a web build.

  1. The HLSL code is compiled to DXIL.
  2. Via DXC, it is transpiled into SPIR-V.
  3. Via Tint, it is transpiled into WGSL, which can be stored on disk.
  4. At load time, the WGSL is loaded and transpiled back into DXIL if you are running on a DX12 platform.
  5. Finally, that DXIL is used to run your shader.

During any of these operations, it's possible that one of the tools you use has a different convention than the previous one. For example, In HLSL cbuffers, a float3 won't cross a 16-byte boundary, so the next scalar packs into the trailing 4 bytes. In WGSL this is false: the scalar won't pack into those bytes, giving you a different stride. In such case, you have to change your structure to something else: a float4 or 3 individual float.

As you can see, you have to be extra careful at every step. One of the issues I faced was the SPIR-V intermediate representation step between HLSL and WGSL.

In HLSL, matrices are column major but sent row major. When DXC generates SPIR-V from HLSL, it has to convert how the matrices are interpreted to match how SPIR-V reads and loads them (column major by default). To do so, it generates SPIR-V with an attribute stating that the reading must be row major, since the loading is row major. This also implies that some computations (such as vector × matrix) must be transformed into (matrix × vector) to match the new behaviour.

From there, Tint reads the generated SPIR-V and produces valid WGSL which, in this case, will be incorrect: WGSL is column major, but it received row major matrices. The data is laid out and set up correctly, but the operations are now wrong (or the data is wrong and the operations are correct, depending on how you read it).

This single issue, born from an amalgamation of tools exchanging data so that everything can run where it needs to, shows the complexity of what we are working with in 2026. The end goal is simple — draw to the screen — but getting anything to draw is a true challenge.

As a result, I had to transpose our matrices before sending them to the GPU, so that the operations that got reversed come out correct again, but only when we are using WebGPU as our backend. To find this, I had to manually read the opcode at every step. I opened a bug in Chromium, which was initially classified as low priority, but has been resolved since then.

C++
// NOTE: fix a bug in WGSL gen by Tint under HLSL from DXC.
if(sgl::get_selected_backend_type() == sgl::BackendType::WEBGPU)
{
    camera_view          = glm::transpose(camera_view);
    camera_perspective   = glm::transpose(camera_perspective);
    camera_orthographic  = glm::transpose(camera_orthographic);
    camera_ui_projection = glm::transpose(camera_ui_projection);
}

As you get into more complicated workflows, you will find that being able to read DXIL or SPIR-V is critical to seeing where the bugs are. Some tools can help, but since WebGPU is relatively new, AI tools have a hard time finding and resolving the real issue, and will often propose a patch around the true bug (assuming they even find it).

No Bindless for you!

After so much work, I finally had a textured triangle. As most graphics programmers would tell you, at that point the job is almost done. Around 3000 lines of code later, we could render with WebGPU and play the game… that is, until it crashed.

The game would now crash once it went past 16 textures in memory. What was going on? The issue was simple: there is no bindless support in WebGPU as of today.

One of the biggest pain points for a graphics programmer in the past was the need to bind every texture that was about to be used during a draw call. If you wanted to draw a textured cube, you had to say: "I'm using this normal map, this albedo, this colour…". It was painful, because you needed to find and specify what would be used and when.

Eventually, programmers got tired and found a way to avoid it. The idea is simple: put all the textures into a single gigantic array, and index into that array to obtain the resource you are looking for. Since we are no longer binding textures to slots, the name "bindless" stuck.

It is now the de-facto way of working in the graphics programming world. Sadly, WebGPU does not support this approach. You are limited to a maximum of 16 textures bound during a draw call.

This means that you have to change the way you make your draw calls. Where before you could ignore such restrictions, you now have to design around them.

In our case, it required two big changes.

  • First, we have to declare the textures we will use.
  • Second, we have to bind those textures right before the draw call.

Thankfully, in our case, we use fewer than 5 textures per draw call: we bind the number of textures required by a material, plus one texture that can come from the input vertex data. If you were to use more than 16 textures during a draw call, you would be forced to set up some sort of multi-draw pass to obtain the same result.

Most of our APIs went from this:

C++
// Simply obtain the index of the texture and use it
return texture->get_index();

To this:

C++
// Ignore in most backends except WebGPU
if(sgl::get_selected_backend_type() != sgl::BackendType::WEBGPU)
{
    return texture->get_index();
}
else
{
    // There is some extra logic that is not displayed. The idea is to give an index that will be remapped
    // right before we send the draw operation.

    // CODE

    // textures_to_indexes is the mapping dictionary, specific to each draw call.
    return textures_to_indexes[texture->id];
}

Note that this is simply ignored on other backends, like DX12 or Vulkan. It is also wasteful, since I would create one binding group per draw call, but as I don't create that many during a frame (only 80), it was fine by me… at the time. More on that later.

Finally, the game could be played on native desktop with WebGPU as a backend. I then chose to test it on the web, since I had already done the porting through Emscripten.

WebGPU on the Web

The promise of WebGPU, since its early days, was simple: one API that works everywhere. It works on the web, it works on Windows, on macOS, or anywhere else that supports it.

Since my game was working on native desktop, I thought the web would be a simple matter of a few fixes here and there. I was wrong. The promise does not hold up.

From there on, you have to realize something really important about the web and WebGPU:

You must consider every combination of platform x browser as a new target.

It sounds crazy put that way, but it is true. You are not porting to Google Chrome. You are porting your game to Google Chrome on Windows. You are porting your game to Google Chrome on macOS. You are porting your game to Firefox on Windows. Each specific combination is a new target you have to consider, with its own rules and constraints. You must test every one of them to make sure it works as expected.

For the following section, I was working specifically on Google Chrome on Windows. Note that for the most part, the W3C specification was my only saving grace, as the documentation is scarce and examples are extremely rare.

Let's look at a first example. On native, you must call a function (wgpuSurfacePresent in Dawn) to present your content to the screen. On the web, that assumption is false, because presentation is handled by the browser.

C++
// On the web, we do nothing. After submission, the browser will handle the present.
#ifndef PLATFORM_WEB
    WGPUStatus status = wgpuSurfacePresent(webgpu_global->surface);
    sgl_assert(status == WGPUStatus_Success);
#endif // PLATFORM_WEB

A second example is the presentation mode. On native, you have a choice of presentation mode (fifo, mailbox, etc.). On the web, you must fall back to fifo, as anything else would simply fail silently.

C++
// Make sure we use the correct presentation mode on the web
#ifdef PLATFORM_WEB
    surface_configuration.presentMode = WGPUPresentMode_Fifo;
#else
    surface_configuration.presentMode = WGPUPresentMode_Mailbox;
#endif

Finally, a third example: on native, you can wait for a function to finish in most cases. One such moment is when you try to acquire an adapter or a device. On the web, you must relinquish control back to the browser instead.

These issues pile up, and you can "quickly" find and understand them by reading the specification, and sometimes by digging through other people's repositories.

Hidden Assumptions

Now, let's look at a really complicated issue that you might stumble upon.

After 3 frames on the web, I would get a black screen with a single validation error message: the surface texture was destroyed. Let's be clear about what that means. In plain English, the message says: the texture you received from the browser, the one that will be presented to the screen, got destroyed before it had the chance to be presented.

Note that this texture is provided by the browser, and that I never destroy it myself. The same code also works perfectly on native desktop. The application does not display a black screen there. After multiple rounds of checking, I could confirm that absolutely nothing was destroying this texture on my side. The issue would appear even if I did nothing with that texture.

Let's dive into how rendering works on the web. If you want to render an element to the screen, the browser calls a function called requestAnimationFrame(). Once you implement it, that function comes with a couple of rules you must follow.

The browser assumes that once it regains control, the texture has been drawn to and is ready to be presented. In theory, that is true. In practice, it is not.

Please re-read that sentence. Once the browser regains control. I did not write once the function returns. This means that any operation that can possibly yield inside requestAnimationFrame is forbidden. If you do yield during that function, by the time you come back, the texture has been destroyed.

Why would you yield? It turns out that in some cases, you do need to yield in order to obtain a GPU resource, such as a buffer for uploading data. Mapping and unmapping a buffer requires you to wait on it, because WebGPU might need to do some work to make it valid. I was doing exactly that at the beginning of the frame, which simply broke the assumption.

SVN diff removing the buffer upload call from the start of the frame. The fix: move the buffer upload out of the start of the frame, so that nothing can yield before we acquire the surface texture.

For someone working on the web, this might be obvious. For an outsider, it is a huge time sink to map your C++ loop back to this function and understand how it behaves. We are talking about something thousands of lines away from your own code.

Memory leaks?

You might have noticed that all of this was tested on Google Chrome on Windows. There is a very specific reason for that. On Firefox, I had a crash caused by running out of memory. Chrome was totally fine, but Firefox would crash after around 10 seconds.

This is also the moment where I discovered that there is not much tooling on the web to diagnose GPU issues. There is a single tool, called WebGPU Inspector, which gives you a rough view of what's happening, without being anywhere near as powerful as RenderDoc or similar tools.

After multiple captures, it turned out that my initial solution, creating one bind group per draw call, was not ideal. Since browsers use garbage collectors, resources are left alive for a long time. I'm talking multiple seconds in some cases before any collection runs. In Firefox, this meant I was generating ~80 bind groups, 60 frames per second, for 5 to 7 seconds before a single pass. Easily thousands of bind groups were left alive, filling up the RAM until the browser would simply… kill the application. The only reason it worked on Google Chrome was that Chrome is more aggressive with garbage collection while being more flexible on memory limits.

The solution was a hash-and-cache approach. For every combination of binding group we create, I hash it and cache it. Over time, this stabilizes and prevents the creation of new binding groups. In the end, the game shipped with a total of 300 to 400 binding groups, with the most stress happening during the initial load of the game.

C++
// Custom allocator for bind group
void WebGPUBindGroupAllocator::bind(const std::vector<u64>& new_textures)
{
    // FNV-1 inspired hash
    std::size_t hashing = hash(new_textures);

    auto it = hash_to_bind_group.find(hashing);
    if(it != hash_to_bind_group.end())
    {
        // If found, simply use that
        bindless_texture_group = it->second;
    }
    else
    {
        // Otherwise, create a new binding group
        u32 bindless_texture_size = get_bindless_texture_size();

        WGPUBindGroupDescriptor bind_group_descriptor = WGPU_BIND_GROUP_DESCRIPTOR_INIT;
        bind_group_descriptor.nextInChain = nullptr;
        bind_group_descriptor.label = {"WebGPUBindGroupAllocator Bindless Group", WGPU_STRLEN};
        bind_group_descriptor.entryCount = bindless_texture_size;
        bind_group_descriptor.layout = bindless_texture_group_layout;

        // We have an empty texture available to fill in the 16 texture array. It also
        // prevents issues in case texture loading is too slow or incomplete during a draw.
        std::vector<WGPUBindGroupEntry> bind_group_entries;
        bind_group_entries.reserve(bindless_texture_size);
        for(i32 i = 0; i < bindless_texture_size; i++)
        {
            WebGPUTexture* texture = nullptr;
            if(i < new_textures.size())
            {
                texture = get_from_vector(webgpu_renderer->textures, new_textures[i]);
                if(texture == nullptr)
                {
                    texture = empty_texture;
                }
            }
            else
            {
                texture = empty_texture;
            }

            WGPUBindGroupEntry bind_group_entry = WGPU_BIND_GROUP_ENTRY_INIT;
            bind_group_entry.binding = WebGPUShader::base_shader_texture_binding_shift + i;
            bind_group_entry.nextInChain = nullptr;
            bind_group_entry.textureView = texture->texture_view;

            bind_group_entries.push_back(bind_group_entry);
        }

        bind_group_descriptor.entries = bind_group_entries.data();

        // Add it to the list of hashed combinations
        WGPUBindGroup bind = wgpuDeviceCreateBindGroup(webgpu_global->device, &bind_group_descriptor);
        hash_to_bind_group[hashing] = bind;
        bindless_texture_group = bind;
    }
}
WebGPU on mobile & elsewhere

So far, I have talked exclusively about one platform (Windows), with two browsers. Let's explore other platforms and browsers. Remember what I said earlier: You must consider every combination of platform x browser as a new target.

As I was porting my game to the web, I also wanted it available on my phone. I have an iPhone 17. Since the game was now working on the web, it should also work in my phone's browser, right? Wrong. It immediately failed and displayed artefacts during rendering.

I assumed that was because I was running on Safari. I downloaded Google Chrome on my phone and tried again. It failed in exactly the same way. That is because Google Chrome on iOS is simply a wrapper around WebKit. Any issue that shows up on Safari will show up in every other browser you install on that phone.

The rendering issue was happening because of what I believe is a hazard tracking problem on WebKit's side. I was able to work around it by reducing the number of render passes in a specific section of the game, and by disabling any draw operation after a fullscreen blur pass.

Then I tried to get the game working on a Samsung S22. Immediate black screen. The reason? WebGPU only shipped on Android in Chrome 121+, and that phone was running a previous version. This is what I meant by treating each combination as its own target. You never know what will hit you.

As a fun note, on Linux, WebGPU needs to be set to compatibility mode to work at all, because core mode is not supported. This meant that some players were simply unable to try the game, as I did not support compatibility mode.

Why did I not support compatibility mode? Because it requires your transpiled shaders to be in compatibility mode as well, and Tint does not care: it has no option for it. Tint exports a version of WGSL that assumes you are running in core mode. If you want that working, you have to adapt the generated WGSL by hand.

There is nothing easy about WebGPU in 2026. And as of today, there is a group of players who can't play Make A Pair. I will try to do better for the next demo.

All of this takes a massive amount of effort for a single developer to deliver a smooth experience to players.

Conclusion

The lesson here is simple: on the web, each platform is independent and unique. There is no single solution, no matter what WebGPU wants you to believe. It's an ideal that the W3C is trying to reach, but it is absolutely not what happens today.

Throughout this series of blog posts, you saw how complicated it was to go from a C++ application to a WASM application that could run on the web. I consider it a little miracle that I was able to do it in three weeks of work. It was intense and painful, while being very stressful due to our deadline: finish in time, or have nothing to present.

I wrote all of this in the hope that it helps you. If you are in the process of migrating an application from C++ to WASM, you can always write me an email at [email protected]. It will be my pleasure to try and help you cross the finish line.

You can also join the Discord to reach out.

Next week, we will be back with some regular content about Make A Pair!

RSS Feed
Previous Post Blog List