WebGPU: the cross-platform graphics API of tomorrow

François Beaufort
François Beaufort

For web developers, WebGPU is a web graphics API that provides a unified and fast access to GPUs by exposing modern hardware capabilities and allowing rendering and computation operations on a GPU, similar to Direct3D 12, Metal, and Vulkan.

While it’s true, that story is not complete. WebGPU is first and foremost the result of a collaborative effort including major companies such as Apple, Google, Intel, Mozilla, and Microsoft. Among them, some realized that WebGPU could be more than a Javascript API but a cross-platform graphics API that could be used by developers across ecosystems other than the web. To fulfill the primary goal, a JavaScript API was introduced in Chrome 113. However, another significant project has been developed alongside it: the webgpu.h C API. This C header file lists all the available procedures and data structures of WebGPU. It serves as a platform-agnostic hardware abstraction layer, allowing you to build platform-specific applications by providing a consistent interface across different platforms.

In this article, you will learn how to write a simple C++ app using WebGPU that runs both on the web and specific platforms. Spoiler alert, you’ll get the same red triangle that appears in a browser window and a desktop window with minimal adjustments to your codebase.

Screenshot of a red triangle powered by WebGPU in a browser window and a desktop window on macOS.
The same triangle powered by WebGPU in a browser window and a desktop window.

How does it work?

To see the completed application check out the WebGPU cross-platform app repository.

The app is a minimalistic C++ example that shows how to use WebGPU to build desktop and web apps from a single codebase. Under the hood, it uses WebGPU's webgpu.h as a platform-agnostic hardware abstraction layer through a C++ wrapper called webgpu_cpp.h.

On the web, the app is built against Emscripten, which has bindings implementing webgpu.h on top of the JavaScript API. On specific platforms such as macOS or Windows, this project can be built against Dawn, Chromium's cross-platform WebGPU implementation. It’s worth mentioning wgpu-native, a Rust implementation of webgpu.h, also exists but is not used in this article.

Getting started

All you need to start is a C++ compiler and CMake to handle cross-platform builds in a standard way. Then, in a dedicated folder, create a main.cpp source file and a CMakeLists.txt build file.

The main.cpp file contains an empty main() function for now.

int main() {}

The CMakeLists.txt file contains basic information about the project. The last line specifies the executable name is "app" and its source code is main.cpp.

cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

Run cmake -B build to create build files in a "build/" sub folder and cmake --build build to actually build the app and generate the executable file.

# Build the app with CMake.
$ cmake -B build && cmake --build build

# Run the app.
$ ./build/app

The app runs but there’s no output yet, as you need a way to draw things on the screen.

Get Dawn

To draw your triangle, you can take advantage of Dawn, Chromium's cross-platform WebGPU implementation. This includes GLFW C++ library for drawing to the screen. One way to download Dawn is to add it as a git submodule to your repository. The commands below will fetch it in a "dawn/" sub folder.

$ git init
$ git submodule add https://dawn.googlesource.com/dawn

Then, append to the CMakeLists.txt file as follows: - The CMake DAWN_FETCH_DEPENDENCIES option will fetch all Dawn dependencies. - The dawn/ sub folder will be included in the target. - Your app will depend on webgpu_cpp, webgpu_dawn, and webgpu_glfw targets so that you can use them in the main.cpp file later.

…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

Open a window

Now that Dawn is available, use GLFW to draw things on the screen. This library included in webgpu_glfw for convenience, allows you to write code that is platform-agnostic for window management.

To open a window named "WebGPU window" with a resolution of 512x512, update the main.cpp file as below. Note that glfwWindowHint() is used here to request no particular graphics API initialization.

#include <GLFW/glfw3.h>

const uint32_t kWidth = 512;
const uint32_t kHeight = 512;

void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    // TODO: Render a triangle using WebGPU.
  }
}

int main() {
  Start();
}

Rebuilding the app and running it as before now results in an empty window. You’re making progress!

Screenshot of a empty macOS window.
An empty window.

Get GPU device

In JavaScript, navigator.gpu is your entrypoint for accessing the GPU. In C++, you need to manually create a wgpu::Instance variable that’s used for the same purpose. For convenience, declare instance at the top of the main.cpp file and call wgpu::CreateInstance() inside main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

int main() {
  instance = wgpu::CreateInstance();
  Start();
}

Accessing the GPU is asynchronous due to the shape of the JavaScript API. In C++, create a helper GetDevice() function that takes a callback function argument and calls it with the resulting wgpu::Device.

#include <iostream>
…

void GetDevice(void (*callback)(wgpu::Device)) {
  instance.RequestAdapter(
      nullptr,
      [](WGPURequestAdapterStatus status, WGPUAdapter cAdapter,
         const char* message, void* userdata) {
        if (status != WGPURequestAdapterStatus_Success) {
          exit(0);
        }
        wgpu::Adapter adapter = wgpu::Adapter::Acquire(cAdapter);
        adapter.RequestDevice(
            nullptr,
            [](WGPURequestDeviceStatus status, WGPUDevice cDevice,
               const char* message, void* userdata) {
              wgpu::Device device = wgpu::Device::Acquire(cDevice);
              device.SetUncapturedErrorCallback(
                  [](WGPUErrorType type, const char* message, void* userdata) {
                    std::cout << "Error: " << type << " - message: " << message;
                  },
                  nullptr);
              reinterpret_cast<void (*)(wgpu::Device)>(userdata)(device);
            },
            userdata);
      },
      reinterpret_cast<void*>(callback));
}

For easier access, declare a wgpu::Device variable at the top of the main.cpp file and update the main() function to call GetDevice() and assign its result callback to device before calling Start().

wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetDevice([](wgpu::Device dev) {
    device = dev;
    Start();
  });
}

Draw a triangle

The swap chain is not exposed in the JavaScript API as the browser takes care of it. In C++, you need to create it manually. Once again, for convenience, declare a wgpu::SwapChain variable at the top of the main.cpp file. Just after creating the GLFW window in Start(), call the handy wgpu::glfw::CreateSurfaceForWindow() function to create a wgpu::Surface (similar to an HTML canvas) and use it to setup the swap chain by calling the new helper SetupSwapChain() function in InitGraphics(). You also need to call swapChain.Present() to present the next texture in the while loop. This has no visible effect as there is no rendering happening yet.

#include <webgpu/webgpu_glfw.h>
…

wgpu::SwapChain swapChain;

void SetupSwapChain(wgpu::Surface surface) {
  wgpu::SwapChainDescriptor scDesc{
      .usage = wgpu::TextureUsage::RenderAttachment,
      .format = wgpu::TextureFormat::BGRA8Unorm,
      .width = kWidth,
      .height = kHeight,
      .presentMode = wgpu::PresentMode::Fifo};
  swapChain = device.CreateSwapChain(surface, &scDesc);
}

void InitGraphics(wgpu::Surface surface) {
  SetupSwapChain(surface);
}

void Render() {
  // TODO: Render a triangle using WebGPU.
}

void Start() {
  …
  wgpu::Surface surface =
      wgpu::glfw::CreateSurfaceForWindow(instance, window);

  InitGraphics(surface);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.Present();
    instance.ProcessEvents();
  }
}

Now is a good time to create the render pipeline with the code below. For easier access, declare a wgpu::RenderPipeline variable at the top of the main.cpp file and call the helper function CreateRenderPipeline() in InitGraphics().

wgpu::RenderPipeline pipeline;
…

const char shaderCode[] = R"(
    @vertex fn vertexMain(@builtin(vertex_index) i : u32) ->
      @builtin(position) vec4f {
        const pos = array(vec2f(0, 1), vec2f(-1, -1), vec2f(1, -1));
        return vec4f(pos[i], 0, 1);
    }
    @fragment fn fragmentMain() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
)";

void CreateRenderPipeline() {
  wgpu::ShaderModuleWGSLDescriptor wgslDesc{};
  wgslDesc.code = shaderCode;

  wgpu::ShaderModuleDescriptor shaderModuleDescriptor{
      .nextInChain = &wgslDesc};
  wgpu::ShaderModule shaderModule =
      device.CreateShaderModule(&shaderModuleDescriptor);

  wgpu::ColorTargetState colorTargetState{
      .format = wgpu::TextureFormat::BGRA8Unorm};

  wgpu::FragmentState fragmentState{.module = shaderModule,
                                    .targetCount = 1,
                                    .targets = &colorTargetState};

  wgpu::RenderPipelineDescriptor descriptor{
      .vertex = {.module = shaderModule},
      .fragment = &fragmentState};
  pipeline = device.CreateRenderPipeline(&descriptor);
}

void InitGraphics(wgpu::Surface surface) {
  …
  CreateRenderPipeline();
}

Finally, send rendering commands to the GPU in the Render() function called each frame.

void Render() {
  wgpu::RenderPassColorAttachment attachment{
      .view = swapChain.GetCurrentTextureView(),
      .loadOp = wgpu::LoadOp::Clear,
      .storeOp = wgpu::StoreOp::Store};

  wgpu::RenderPassDescriptor renderpass{.colorAttachmentCount = 1,
                                        .colorAttachments = &attachment};

  wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
  wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderpass);
  pass.SetPipeline(pipeline);
  pass.Draw(3);
  pass.End();
  wgpu::CommandBuffer commands = encoder.Finish();
  device.GetQueue().Submit(1, &commands);
}

Rebuilding the app with CMake and running it now results in the long-awaited red triangle in a window! Take a break—you deserve it.

Screenshot of a red triangle in a macOS window.
A red triangle in a desktop window.

Compile to WebAssembly

Let’s have a look now at the minimal changes required to adjust your existing codebase to draw this red triangle in a browser window. As said earlier, the app will be built against Emscripten, a tool for compiling C/C++ programs to WebAssembly, which has bindings implementing webgpu.h on top of the JavaScript API.

Update CMake settings

Once Emscripten is installed, update the CMakeLists.txt build file as below. The highlighted parts are the only things you need to change: - set_target_properties is used to automatically add the "html" file extension to the target file. In other words, you’ll generate an "app.html" file. - The USE_WEBGPU app link option is required to enable WebGPU support in Emscripten. Without it, your main.cpp file can’t access the webgpu/webgpu_cpp.h file. - The USE_GLFW app link option is also required here so that you can reuse your GLFW code.

cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

if(EMSCRIPTEN)
  set_target_properties(app PROPERTIES SUFFIX ".html")
  target_link_options(app PRIVATE "-sUSE_WEBGPU=1" "-sUSE_GLFW=3")
else()
  set(DAWN_FETCH_DEPENDENCIES ON)
  add_subdirectory("dawn" EXCLUDE_FROM_ALL)
  target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)
endif()

Update the code

In Emscripten, creating a wgpu::surface requires a HTML canvas element. For this, call instance.CreateSurface() and specify the #canvas selector to match the appropriate HTML canvas element in the HTML page generated by Emscripten.

Instead of using a while loop, call emscripten_set_main_loop(Render) to make sure the Render() function is called at a proper smooth rate that lines up properly with the browser and monitor.

#include <GLFW/glfw3.h>
#include <webgpu/webgpu_cpp.h>
#include <iostream>
#if defined(__EMSCRIPTEN__)
#include <emscripten/emscripten.h>
#else
#include <webgpu/webgpu_glfw.h>
#endif
void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

#if defined(__EMSCRIPTEN__)
  wgpu::SurfaceDescriptorFromCanvasHTMLSelector canvasDesc{};
  canvasDesc.selector = "#canvas";

  wgpu::SurfaceDescriptor surfaceDesc{.nextInChain = &canvasDesc};
  wgpu::Surface surface = instance.CreateSurface(&surfaceDesc);
#else
  wgpu::Surface surface =
      wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics(surface);

#if defined(__EMSCRIPTEN__)
  emscripten_set_main_loop(Render, 0, false);
#else
  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.Present();
    instance.ProcessEvents();
  }
#endif
}

Build the app with Emscripten

The only change needed to build the app with Emscripten is to prepend the cmake commands with the magical emcmake shell script. This time, generate the app in a build-web sub folder and start a HTTP server. Finally, open your browser and visit build-web/app.html.

# Build the app with Emscripten.
$ emcmake cmake -B build-web && cmake --build build-web

# Start a HTTP server.
$ npx http-server
Screenshot of a red triangle in a browser window.
A red triangle in a browser window.

What’s next

Here’s what you can expect in the future:

  • Improvements in the stabilization of webgpu.h and webgpu_cpp.h APIs.
  • Dawn initial support for Android and iOS.

In the meantime, please file WebGPU issues for Emscripten and Dawn issues with suggestions and questions.

Resources

Feel free to explore the source code of this app.

If you want to dive more in creating native 3D applications in C++ from scratch with WebGPU, check out Learn WebGPU for C++ documentation and Dawn Native WebGPU Examples.

If you're interested in Rust, you can also explore the wgpu graphics library based on WebGPU. Take a look at their hello-triangle demo.

Acknowledgments

This article was reviewed by Corentin Wallez, Kai Ninomiya, and Rachel Andrew.

Photo by Marc-Olivier Jodoin on Unsplash.