Tutorial 132: The WebGPU Renderer: Native and Browser
What you’ll learn: how to build and verify the WEBGPU renderer natively (pinned wgpu-native, CNA_WEBGPU_ROOT) and in the browser (emdawnwebgpu), how to choose it at run time, and what it reports for WGSL shaders, compiled effects, float targets and occlusion queries.
Before you start — Tutorial 72: Choosing a Renderer for the group comparison, Tutorial 81: Building for the Web if you want the browser route, and Tutorial 131: The SDL_GPU Renderer, whose probe program this tutorial reuses. This tutorial is about one renderer, WEBGPU, in CNA snapshot 3b43bd86.
Supported, and verified on Linux x86_64 and headless Chrome only. CNA declares WEBGPU Supported since 17 October 2026 (a declaration, not a measurement). The native route is verified on Linux x86_64; the pinned Windows, macOS and Linux aarch64 wgpu-native packages are hash-pinned but CNA lists their verification as open. CNA’s early-October Apple campaign, before this snapshot and not repeated, recorded the full test tree passing through wgpu-native’s Metal driver on a physical Mac mini M4 (10,968 tests with named skips only; MultiSampleMask ignored there and occlusion a 0-or-1 flag). The browser route has been driven in headless Chrome by CNA’s own scripts. In CI, the Linux platform-contract and glTF stride jobs build and run WEBGPU, and the Emscripten workflow builds a WEBGL2;WEBGPU bundle without running it. Everything below is checked against CNA branch next at commit 3b43bd8687ed46523d873f9bec4b32bd8481dcb5; the product version string is still 0.1.0, and a plain git clone gives you main, the 0.1.0 release.
One identity, two routes
WEBGPU is a single renderer identity with two backends behind it. The same WebGPURenderer code and the same WGSL shaders serve both; only a handful of seams differ.
| Native route | Browser route (new since alpha.1) | |
|---|---|---|
| Implementation | Pinned wgpu-native v29.0.1.1, a prebuilt shared library CMake downloads and verifies | Emscripten's emdawnwebgpu port (--use-port=emdawnwebgpu); the browser owns the WebGPU implementation (navigator.gpu) |
| Hosts | Linux, macOS, Windows; x86_64 and aarch64 (asset table below) | Emscripten builds only; no wgpu-native library to download or ship |
| Surface | Built from the platform's native window handle: Win32 HWND, a Metal layer on Apple, an Android window, or Wayland / X11 on Linux | The <canvas> selected by #canvas — SDL3's default Emscripten canvas |
| Waiting for the GPU | Polls wgpuInstanceProcessEvents() with a 10-second timeout for adapter and device requests | Callbacks fire from the browser event loop; the renderer's synchronous waits yield with emscripten_sleep() under Asyncify |
| Presenting | Explicit wgpuSurfacePresent() | No explicit present: the frame is shown when control returns to requestAnimationFrame |
Rgba64 (RGBA16 UNORM) | Needs wgpu-native's own TextureFormat16bitNorm feature | Not available |
| Compiled-effect shader form | SPIR-V by default | WGSL only |
| Recorded evidence | Native smoke and test suite on Linux x86_64 per CNA's notes | 2D and 3D demos and every stock effect page in headless Chrome, by script |
WEBGPU does not pick the native GPU API itself: a Vulkan, Metal or Direct3D 12 device is chosen by wgpu-native or by the browser, not by CNA, and CNA’s own renderer registry classes the identity as a translation layer. It draws in 2D and 3D, the PBR effects included.
Native route: build and run
Prerequisites
cd /path/to/workspace
git clone -b next https://github.com/libcna/cna.git
git clone -b next https://github.com/libcna/sharp-runtime.git # must be its `next` branch too
cd cna
git submodule update --init # non-recursive is correct
You need the usual SDL-based build dependencies (see Building), an internet connection for the first configure (or an extracted wgpu-native package, below), and at run time a real GPU adapter. There is no easy-gl or other sibling requirement beyond sharp-runtime. On Linux the surface comes from the window's Wayland or X11 handle; any other native window kind stops with CNA WebGPU: unsupported Linux native window.
Configure and build
cmake -S . -B build -DCNA_GRAPHICS_RENDERER=WEBGPU \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache
cmake --build build --target cna_demo_renderer_selection cna_demo_2d cna_house3d_demo --parallel
The configure step prints CNA WebGPU: downloading https://github.com/gfx-rs/wgpu-native/releases/download/v29.0.1.1/… the first time, then CNA WebGPU: using wgpu-native v29.0.1.1 from …. The download is checked against a SHA-256 pinned in cmake/WebGPUChecksum.cmake before it is extracted; a mismatch removes the archive and stops the configure (fail closed), and an asset with no pinned hash is refused rather than trusted.
| Cache variable | Default | Meaning |
|---|---|---|
CNA_WEBGPU_VERSION | v29.0.1.1 | The pinned wgpu-native release. Changing it means supplying matching hashes. |
CNA_WEBGPU_ROOT | empty | Root of an extracted official wgpu-native release. When set, nothing is downloaded and the tree is not re-hashed. |
CNA_WEBGPU_AUTO_DOWNLOAD | ON | Download the pinned release when CNA_WEBGPU_ROOT is empty. With OFF and no root, configure stops. |
CNA_WEBGPU_COMPILED_EFFECTS | OFF | Build support for compiled XNA effect bytecode; see Shaders. |
The automatic download knows these packages (all from the gfx-rs/wgpu-native GitHub release, each with a pinned SHA-256):
| Host | Architectures with a pinned asset |
|---|---|
| Linux | x86_64, aarch64 |
| macOS | x86_64, aarch64 |
| Windows, MSVC | x86_64, aarch64 |
| Windows, MinGW | x86_64 only (the GNU package) |
Any other host or processor needs CNA_WEBGPU_ROOT. For an offline or reproducible build extract the official package yourself, which must provide include/webgpu/webgpu.h (or include/webgpu.h) and a libwgpu_native library under lib/:
cmake -S . -B build -DCNA_GRAPHICS_RENDERER=WEBGPU \
-DCNA_WEBGPU_ROOT=/absolute/path/to/extracted/wgpu-native \
-DCNA_WEBGPU_AUTO_DOWNLOAD=OFF
Verify that WEBGPU really started
./build/cna_demo_renderer_selection # lists WEBGPU, then "Active renderer: WEBGPU"
./build/cna_demo_2d --smoke 120 # 2D sprite demo, exits cleanly after 120 frames
./build/cna_demo_2d --webgpu-2d-validation --smoke 120 # deterministic SpriteBatch coverage scene
./build/cna_house3d_demo # the 3D BasicEffect demo (built for WEBGPU)
CNA copies the wgpu-native library next to cna_demo_2d and gives the executable an $ORIGIN runtime path on Linux, so it runs straight from the build directory. The --webgpu-2d-validation scene uploads a texture and exercises source rectangles, tint and alpha, rotation, flips, linear and point filtering, and the three address modes, and resizes the window mid-run; it is a WebGPU-specific scene in the shared 2D demo.
For tests, a build with CNA_BUILD_TESTS=ON registers WebGPU_Native2D_Smoke, which runs cna_demo_2d --smoke 120 and reports a clear skip rather than a failure when neither WAYLAND_DISPLAY nor DISPLAY exists or the adapter cannot be obtained:
ctest --test-dir build -R '^WebGPU_Native2D_Smoke$' --output-on-failure
tools/platform/run_gpu_tests_private.sh build -L WebGPU --output-on-failure -j1 # the full WebGPU set
The wider set needs an X display with a real GPU adapter behind it; CNA's own notes say a plain software Xvfb has no WebGPU adapter and the readback tests fail for that reason alone. WebGPU_ChecksumVerification and WebGPU_PresentModeMapping are pure CMake or unit tests and need neither display nor GPU.
Ask the device what it can do
Reuse the probe program from Tutorial 131 unchanged — it compiles against every renderer and prints all 17 GraphicsCapability answers plus CNA's generated capability report. The only build difference for WEBGPU is that your own executable needs the wgpu-native library beside it; CNA provides a helper for that:
set(CNA_GRAPHICS_RENDERER "WEBGPU" CACHE STRING "CNA graphics renderer")
set(CNA_BUILD_TESTS OFF)
set(CNA_BUILD_EXAMPLES OFF)
add_subdirectory(../cna ${CMAKE_BINARY_DIR}/cna)
add_executable(webgpu_probe main.cpp)
target_link_libraries(webgpu_probe PRIVATE CNA)
cna_link_sharp_runtime(webgpu_probe PRIVATE)
cna_copy_renderer_runtime(webgpu_probe) # copies libwgpu_native next to your executable
Ship that library with your game: it is a separate shared library, not linked into the executable.
Browser route: build, serve, run
The source-tree prerequisites are the same as for the native route (sharp-runtime on next, git submodule update --init), plus an Emscripten SDK that provides the emdawnwebgpu port. CNA's build uses --use-port=emdawnwebgpu; CNA's notes say the older -sUSE_WEBGPU=1 built-in was removed in Emscripten 4.0.10 and is not used. (CNA's Emscripten CI pins emsdk 6.0.3 for its own bundles, which do not include WEBGPU.) The page must run in a browser that exposes navigator.gpu.
emcmake cmake -S . -B build-web -DCNA_GRAPHICS_RENDERER=WEBGPU -DCNA_BUILD_EXAMPLES=ON
cmake --build build-web --target cna_demo_2d --parallel
The configure prints CNA WebGPU: using Emscripten emdawnwebgpu port (browser navigator.gpu). Nothing is downloaded. Build the 3D BasicEffect demo with --target cna_house3d_demo the same way; both are gated to include WEBGPU.
WebAssembly does not load from file://. Serve the build directory over HTTP and open the generated page:
python3 -m http.server 8080 --directory build-web
# then open http://127.0.0.1:8080/cna_demo_2d.html in a WebGPU-capable browser
CNA's own automation of this is scripts/run-webgpu-browser-test.sh: it serves the build directory, drives headless Google Chrome with the demo's --smoke N argument, and treats a clean wasm exit with no WebGPU error as the pass. Its default Chrome flags enable Chrome’s unsafe-WebGPU switch and run Chrome’s own GPU process and ANGLE on the Vulkan API, a choice made for CNA's Linux development machine (override them with CNA_WEBGPU_CHROME_FLAGS; the script states the exact default):
scripts/run-webgpu-browser-test.sh build-web 120 # cna_demo_2d, 120 frames
CNA_WEBGPU_DEMO=cna_house3d_demo scripts/run-webgpu-browser-test.sh build-web 120 # the 3D demo
CNA's notes record these driven pages green in headless Chrome on that machine: the 2D demo, the 3D demo, the PBR, environment-map, skinned, dual-texture and alpha-test effect pages, and a coverage page for wireframe, semantic vertex layouts, multi-stream input, a custom WGSL ShaderEffect and device loss. That is a development-machine result, not a CI gate.
What is different in the browser
- The canvas is
#canvas. The WebGPU surface targets that CSS selector, so your page must use SDL3's default canvas id. - Blocking waits need Asyncify. Adapter, device and readback waits call
emscripten_sleep(). CNA-owned Emscripten executables opt into-sASYNCIFY=1automatically; an external final executable that uses the blockingGame::Run()linksCNA::EmscriptenAsyncify(the compatibility targetCNA::EmscriptenAbicontains both it andCNA::EmscriptenExceptionAbi). A localGameobject is fine: CNA's own notes say the alpha.1-era rule to heap-allocate the game in a browser build no longer applies. - Resize follows the canvas. The browser sizes the surface from the canvas backing store, and the renderer re-syncs its depth and MSAA attachments to the texture it actually acquires.
- Reading pixels back yields to the browser. A same-frame readback marks the acquired texture stale and re-acquires before the next draw; this is handled inside the renderer, but it is why heavy per-frame readback is slower here.
- The browser's shader compiler is Tint, not wgpu-native's Naga. A shader that one accepts can be rejected by the other, so test your WGSL in both routes.
Choosing WEBGPU at run time in a browser bundle
The default renderer under Emscripten is WEBGL2. If you build a multi-renderer bundle, the page can pick before the program starts — the same precedence as a native environment variable, below an explicit SetPreferred() call in your program:
emcmake cmake -S . -B build-web -DCNA_GRAPHICS_RENDERER=WEBGL2 \
-DCNA_GRAPHICS_RENDERERS="WEBGL2;WEBGPU" -DCNA_BUILD_EXAMPLES=ON
<canvas id="canvas" width="800" height="480"></canvas>
<script>
var Module = {
canvas: document.getElementById('canvas'),
cnaPreferredRenderer: 'WEBGPU', // a public renderer name; case-insensitive
print: function (t) { console.log(t); },
printErr: function (t) { console.warn(t); }
};
</script>
<script src="cna_demo_2d.js"></script>
The property is read once. A value that is not a CNA renderer name throws InvalidOperationException (does not name any CNA graphics renderer), and so does a real name that is not compiled into the bundle, with the available renderers listed — unless a fallback chain is enabled, in which case the rejection is recorded instead. An exported Module._cna_set_preferred_renderer is an alternative that behaves like SetPreferred() and returns 0 instead of throwing; it takes a C string, so it needs string marshalling on the JavaScript side. Inside the program, this is how to prefer WEBGPU and opt in to a second chance (the braced form SetFallbackChain({…}) does not compile as C++23, because a std::span cannot be built from an initializer list; pass a named array):
#include <array>
#include <iostream>
#include "CNA/GraphicsRendererFallbackRecord.hpp"
#include "CNA/GraphicsRendererSelection.hpp"
#include "CNA/GraphicsRendererType.hpp"
#include "MyGame.hpp" // your Game subclass
int main()
{
using CNA::GraphicsRendererSelection;
using CNA::GraphicsRendererType;
// Only ask for what this bundle actually contains.
if (GraphicsRendererSelection::IsAvailable(GraphicsRendererType::WebGPU))
{
GraphicsRendererSelection::SetPreferred(GraphicsRendererType::WebGPU);
// Opt in to a second chance; without it a failed WEBGPU start is an error.
static constexpr std::array chain{GraphicsRendererType::WebGL2};
GraphicsRendererSelection::SetFallbackChain(chain);
}
MyGame game; // your Game subclass; a local object is fine in a browser build too
game.Run();
std::cout << "active: "
<< CNA::getGraphicsRendererName(GraphicsRendererSelection::GetActive()) << '\n';
for (const auto& record : GraphicsRendererSelection::GetFallbackHistory())
{
std::cout << "skipped " << CNA::getGraphicsRendererName(record.type) << " ("
<< CNA::getGraphicsRendererFallbackReasonName(record.reason) << "): "
<< record.message << '\n';
}
return 0;
}
Untested combination. CMake allows WEBGPU beside WEBGL2 (it belongs to no platform partition), and the Emscripten multi-renderer bundle CNA’s CI builds is exactly WEBGL2;WEBGPU, but that job builds and checks symbols rather than running the renderers. Check the configure line CNA: renderer set -- … (default: WEBGL2) and confirm with GraphicsRendererSelection::GetAvailable() and GetActive() rather than assuming. Fallback is off unless you enable it, and selection latches on the first successful device creation.
Shaders: WGSL, and compiled effects
Custom ShaderEffect shaders are WGSL. Unlike SDL_GPU (SPIR-V) and the GL family (GLSL), this renderer takes WGSL source in a ShaderEffect, and only that: the renderer's shader dialect is Wgsl, accepted at the vertex and fragment stages, and nothing else. A custom effect whose fragment stage writes @location(0..N-1) fans out to every attachment of a multiple-render-target set. See Tutorial 52 for the ShaderEffect API.
Compiled XNA effects (.fxb, Direct3D 9 Effect Framework bytecode) are opt-in:
cmake -S . -B build -DCNA_GRAPHICS_RENDERER=WEBGPU -DCNA_WEBGPU_COMPILED_EFFECTS=ON
# offline / shared checkout of the pinned FNA3D (supplies MojoShader):
# -DFETCHCONTENT_SOURCE_DIR_FNA3D=/path/to/FNA3D
The option pulls the pinned FNA3D checkout for MojoShader, which this renderer does not otherwise need, and is buildable on every target. Natively the route emits SPIR-V by default. In a browser the shader module must be WGSL, so CNA translates the SPIR-V into WGSL (SpirvToWgsl); asking a browser build for anything other than WGSL throws NotSupportedException (browser WebGPU ingests WGSL and nothing else). With the option on, CompiledEffects reports yes; in a default configure it reports no. CNA's notes record a 13-check compiled-effect page passing in headless Chrome, which is what settled that the browser's Tint accepts the generated WGSL.
What it reports
Answers from GraphicsDevice::SupportsCapability() for a default configuration under the HiDef profile. "Probed" means the renderer asks the live device, so the answer can differ between machines and between native and browser.
| Capability | WEBGPU | Notes |
|---|---|---|
ThreeD, DepthStencilBuffer, StencilBuffer | yes | Every stock effect, including skinned and PBR, is implemented. |
MultiSampleAntiAliasing | probed | The renderer creates a scratch multisampled texture inside a validation error scope and reads whether it was rejected; the result is cached. |
MultipleRenderTargets | yes under HiDef | 2 to 4 RenderTarget2D targets that share width, height and sample count. Stock and SpriteBatch draws write attachment 0 only; a custom WGSL effect fans out. Under the default Reach profile the public answer is no. |
AnisotropicFiltering, Texture3D, Instancing, AdditiveBlending | yes | |
WireFrame | yes | Emulated by expanding each triangle's edges into a line list; the same route works natively and in the browser, where WebGPU has no polygon mode. |
OcclusionQuery | yes | Real begin/end queries counting the samples that pass depth and stencil (the test distinguishes zero from a near-full count; no exact count is asserted). Only a query's first contiguous run of draws per flush is recorded, and at most 32 query objects can be alive at once. |
MultiStreamVertexInput | yes | One native vertex-buffer layout per stream. One device slot is reserved, so the reported maximum is the smaller of the device limit minus one and CNA's own stream table. |
CustomEffects | yes | WGSL source. |
CompiledEffects | only with CNA_WEBGPU_COMPILED_EFFECTS=ON | See above. |
FloatRenderTargets, HalfFloatRenderTargets | probed | Derived from creating a 1x1 render-attachment texture of the exact format inside an error scope. See the float caveats below. |
HalfFloatTextureLinearFiltering | yes | rgba16float, the storage of HalfVector4 and HdrBlendable, is filterable in core WebGPU (XNA itself still refuses a classic draw that filters HalfVector4, which the device enforces). |
Two optional adapter features matter for HDR. Float32Filterable is needed to sample a 32-bit float render target with a filtering sampler, and Float32Blendable to blend into one. When the adapter lacks them the renderer refuses that draw by name (NotSupportedException) instead of quietly overwriting: TextureFilter::Point samples such a target without the feature, and an opaque blend state avoids the blending restriction. Compare with GetRendererCapabilityReportEXT(), and see Tutorial 133 for the detailed profile.
Texture and render-target formats
- Block-compressed (
Dxt1/Dxt3/Dxt5, BC7 and their sRGB forms): stored and uploaded as native BC blocks only when the adapter offers the BC compression feature, which the renderer requests when available. On a device without it, the framework's own rule applies and content is decoded toColor. Cube maps follow the same rule. NormalizedByte2/NormalizedByte4: core WebGPU texture formats, stored natively — for textures only; neither is renderable, so aRenderTarget2Dasking for one is refused.- Float textures:
HalfVector4andHdrBlendableare stored natively asrgba16floatinTexture2D, and the float formats are accepted inTexture3D; in a browser, optional texture formats are gated on what the browser offers. - Render targets:
Coloris the swap-chain format and always supported; the float and half-float formats are probed against the device as above.Rgba64has no render-target mapping, andRgba64textures exist only in native builds with wgpu-native's 16-bit-norm feature. - Everything else follows the framework's
Color-only rule and throws.
Modern features
Beyond the XNA surface the renderer implements:
- Shadow reception in the four stock lit families, and image-based lighting in the PBR families — both claimed by the renderer and fed with a shadow map and environment textures that you supply. CNA has no compute-shader, storage-buffer, indirect-draw or GPU-timer API on any renderer.
- A WGSL variant in CNA’s shader packages (today the package behind the CNAEXT retro effects), with a descriptor binding contract that mirrors the SPIR-V variant’s.
- Debug labels and per-instance vertex streams for a custom effect.
- A boundary to plan for: a cube face inside a multiple-render-target set is refused.
SpriteSortMode::Immediatedraws each sprite with the texture as it is at itsDrawcall: aTexture2D,TextureCubeorTexture3Dwritten while a queued draw samples it moves to a fresh GPU texture, and the queued draw keeps the old one.
The renderer also takes part in the 32-fixture cross-renderer parity corpus, as WebGPU_Parity_<fixture> tests (with EasyGL and SDL_GPU). Each fixture carries its own expected result; the oracle is the fixture's assertions, not real XNA output.
When it does not start
| Symptom | Cause and fix |
|---|---|
Configure: CNA_WEBGPU_ROOT is empty and CNA_WEBGPU_AUTO_DOWNLOAD=OFF | Extract an official wgpu-native v29.0.1.1 release and set CNA_WEBGPU_ROOT, or turn auto-download back on. |
Configure: unsupported processor or automatic wgpu-native download is unsupported on … | No pinned asset for this host. Set CNA_WEBGPU_ROOT to a package you vouch for. |
Configure: failed to download wgpu-native | Network problem. Download the asset manually and set CNA_WEBGPU_ROOT. |
Configure: SHA-256 mismatch | The archive was corrupt or tampered with; it was removed. Re-run to download again, or use CNA_WEBGPU_ROOT. |
Configure: webgpu.h was not found / libwgpu_native was not found | The root you gave is not an extracted official package (or an unexpected layout). |
Run time: CNA WebGPU: adapter request failed or timed out waiting for adapter request | No usable GPU adapter for the surface, for example a software-only X server, or a browser without WebGPU. CNA never substitutes another renderer; enable a fallback chain if you want that. |
Run time: unsupported Linux native window | The platform's native handle is neither Wayland nor X11. |
Emscripten rejects --use-port=emdawnwebgpu | The SDK does not provide the emdawnwebgpu port; use a newer SDK. |
NotSupportedException mentioning Float32Filterable or Float32Blendable | The adapter lacks the optional feature. Use point filtering, or avoid blending, on 32-bit float targets. |
NotSupportedException: browser WebGPU ingests WGSL and nothing else | Something asked a browser build to use a non-WGSL compiled-effect route. Leave the shader language at its default. |
A ShaderEffect fails to compile | The source must be WGSL, and Tint (browser) and Naga (native) can disagree. Check the compile error the effect reports. |
What backs these answers
- Declared maturity: Supported (since 17 October 2026). That is CNA's own label.
- Native: a native smoke test, a WebGPU-labelled CTest set, a pure-CMake checksum test, and the shared parity fixtures. CNA's notes say the native route was verified on Linux x86_64; the other pinned packages are still to be verified. CI builds and runs it in the Linux platform-contract and glTF stride jobs.
- Browser: scripted headless-Chrome runs of the demos and effect pages, as above.
- No XNA pixel oracle. Only
DIRECTX9is gated on the whole real-XNA reference corpus (at tolerance 0). EasyGL has a CTest on two line scenes that fails on any pixel difference, SOFTWARE registers the same two but its script fails only on a render failure, and FNA3D gates only that every scene renders (see the XNA oracle). WEBGPU is in none of them.
Where to go next
- Tutorial 133: Read the Renderer Capability Profile
- Tutorial 131: The SDL_GPU Renderer: Build, Verify and Query
- Tutorial 105: Browser renderers and Tutorial 81: Building for the Web
- Tutorial 126: Build and Select Several Renderers
- Runtime Renderer Selection and the Renderers reference
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- WebGPU renderer semantics: surfaces, targets, mips and pipeline state — Exact behaviour of CNA's WEBGPU renderer: non-sRGB surface policy, target-relative SpriteBatch coordinates, cube render targets, blit-free mip generation, dynamic and baked state, ordered clears and BC textures.