Build an app with WebGPU

François Beaufort
François Beaufort

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

While true, that story is incomplete. WebGPU is 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 for developers across ecosystems, other than the web.

To fulfill the primary use case, 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 document, you'll learn how to write a small 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 document.

Get started

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

The main.cpp file should contain 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 following commands 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 fetches all Dawn dependencies.
  • The dawn/ sub folder is included in the target.
  • Your app will depend on dawn::webgpu_dawn, glfw, 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 dawn::webgpu_dawn glfw 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 two helper functions called GetAdapter() and GetDevice() that respectively return a callback function with a wgpu::Adapter and a wgpu::Device.

#include <iostream>
…

void GetAdapter(void (*callback)(wgpu::Adapter)) {
  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);
        reinterpret_cast<void (*)(wgpu::Adapter)>(userdata)(adapter);
  }, reinterpret_cast<void*>(callback));
}

void GetDevice(void (*callback)(wgpu::Device)) {
  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);
  }, reinterpret_cast<void*>(callback));
}

For easier access, declare two variables wgpu::Adapter and wgpu::Device at the top of the main.cpp file. Update the main() function to call GetAdapter() and assign its result callback to adapter then call GetDevice() and assign its result callback to device before calling Start().

wgpu::Adapter adapter;
wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetAdapter([](wgpu::Adapter a) {
    adapter = a;
    GetDevice([](wgpu::Device d) {
      device = d;
      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::Surface 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 configure it by calling the new helper ConfigureSurface() function in InitGraphics(). You also need to call surface.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::Surface surface;
wgpu::TextureFormat format;

void ConfigureSurface() {
  wgpu::SurfaceCapabilities capabilities;
  surface.GetCapabilities(adapter, &capabilities);
  format = capabilities.formats[0];

  wgpu::SurfaceConfiguration config{
      .device = device,
      .format = format,
      .width = kWidth,
      .height = kHeight};
  surface.Configure(&config);
}

void InitGraphics() {
  ConfigureSurface();
}

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

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

  InitGraphics();

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    surface.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 = format};

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

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

void InitGraphics() {
  …
  CreateRenderPipeline();
}

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

void Render() {
  wgpu::SurfaceTexture surfaceTexture;
  surface.GetCurrentTexture(&surfaceTexture);

  wgpu::RenderPassColorAttachment attachment{
      .view = surfaceTexture.texture.CreateView(),
      .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. Again, the app is 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 follows. The highlighted code is the only thing 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 dawn::webgpu_dawn glfw 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};
  surface = instance.CreateSurface(&surfaceDesc);
#else
  surface = wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics();

#if defined(__EMSCRIPTEN__)
  emscripten_set_main_loop(Render, 0, false);
#else
  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    surface.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.