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.
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
, andwebgpu_glfw
targets so that you can use them in themain.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!
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.
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, yourmain.cpp
file can't access thewebgpu/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
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.