WebGPU renderer semantics: surfaces, targets, mips and pipeline state
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. No WebGPU test was executed for this page. CNA's own notes place native verification on Linux x86_64 and browser runs in headless Chrome by script; no workflow selects WEBGPU and no scene is compared with real XNA output.
The WEBGPU renderer serves native wgpu-native and browser WebGPU from one implementation, and CNA declares it Experimental. This page explains the semantics that are specific to it at this snapshot: how it chooses a surface format without gamma-encoding Color, why SpriteBatch coordinates follow the bound target, how a RenderTargetCube is laid out and sampled, how mipmaps are generated when WebGPU has no blit, which state is dynamic and which is baked into pipelines, how clears are ordered, and how block-compressed textures went from an open gap to a native path. Build, selection and capability answers are in Tutorial 132: The WebGPU Renderer; this page is for readers who need the exact behaviour behind those answers.
Surface format and back-buffer depth
WebGPURenderer::ConfigureSurface() in WebGPURenderer.cpp prefers, in order, BGRA8Unorm, RGBA8Unorm, BGRA8UnormSrgb and RGBA8UnormSrgb, and falls back to the first format the surface reports. The reason is the one the Vulkan renderer gives for its swapchain (see Vulkan: UNORM on purpose): SurfaceFormat::Color is a plain UNORM byte format, and an sRGB format would make the hardware encode every stored value. The renderer copies its surface format into render targets and pipeline colour targets, so an sRGB choice here also reached off-screen resources whose bytes a game reads back.
When a surface can only be configured as sRGB, the renderer keeps rendering in the matching non-sRGB format: NonSrgbColorFormat() drops only the transfer function (channel order is preserved, so the BGRA-aware readback swizzle stays correct), and that format is requested through the surface configuration's view formats, so every view over a surface texture reinterprets the same bytes without an encode. Color therefore stays byte-exact even on such a surface, at no cost in extra passes, textures or per-pixel conversion.
The policy comes from CNA's tracking item REMED-GFX-131. Before it, ConfigureSurface() preferred the sRGB formats, so a public Color request travelled with a native sRGB swapchain, the opposite policy from Vulkan, and the shared render-target-cube fixture even carried a compile definition that sRGB-encoded its expectations for WebGPU alone. That definition is gone: it encoded a defect into the oracle rather than describing a platform difference.
The back buffer's depth texture is always Depth24PlusStencil8, recreated for every non-minimised surface size with the colour attachment's sample count, including when the public depth format is DepthFormat::None. Neither requested presentation format participates. Unlike the Vulkan renderer, which writes the depth format it really created back into PresentationParameters, this renderer does not override the applied-format hooks, so the parameters keep the requested value. Render targets are different: MapDepthFormatEXT (WEBGPU-39) maps each target's requested format exactly, Depth16 to Depth16Unorm, Depth24 to Depth24Plus, Depth24Stencil8 to Depth24PlusStencil8, and None means no depth attachment at all. A fullscreen transition is performed by the platform window; the next frame detects the new physical size and reconfigures the surface.
SpriteBatch coordinates follow the bound target
This renderer converts sprite positions to normalised device coordinates on the CPU, when QueueSprite() enqueues the sprite. A SpriteBatch destination rectangle is expressed in the pixel space of the currently bound target, never the back buffer's (XNA and FNA semantics), so QueueSprite() selects one of three explicit cases before dividing:
- a bound
RenderTarget2D: that target's width and height, with an identity viewport; - a bound
RenderTargetCubeface: the face size; - the back buffer: the logical viewport from
ComputeLogicalViewport()over the physical surface size. Letterbox and the other presentation modes are back-buffer-only concepts.
Before REMED-GFX-019 the conversion always used the back buffer's logical dimensions, so a draw intended for a small off-screen target was placed as though the target were the window. Nothing threw; the sprite simply had the wrong scale and position:
// e.g. a 256x256 render target while the window is 1920x1080
device.SetRenderTarget(&smallOffscreenTarget);
spriteBatch.Begin();
spriteBatch.Draw(sourceTexture, Vector2::Zero, Color::White); // "fill the target"
spriteBatch.End();
The regression, WebGPU_SpriteBatch_RenderTarget, makes the distinction discriminating rather than visual. It pairs a 96 × 72 back buffer with asymmetric 48 × 32 and 64 × 40 targets, requires the same destination rectangle to cover the same target-relative pixels in both, and also covers target, back buffer, target isolation, a non-identity SpriteBatch transform, texture orientation and a 48 × 48 cube face. Probe pixels inside the old, wrongly scaled rectangle but outside the correct one must stay black. The earlier practical advice to avoid differently sized targets on this renderer is no longer needed.
RenderTargetCube: one six-layer texture, per-face views, shared depth
A RenderTargetCube (WEBGPU-114) reuses the design of the plain 2D target:
- one
WGPUTexturewith six array layers, the layoutWebGPUTextureCubeRendereralso uses for an ordinary sampled and readableTextureCube; - a
WGPUTextureViewDimension_2Dview per face for the render-pass colour attachment, and oneWGPUTextureViewDimension_Cubeview over all six layers for sampling the result; - one
size×sizedepth-stencil attachment shared by all six faces, which is safe because only one face renders at a time and matches the choice the Vulkan renderer makes and the FNA reference contract; its sample count must match the colour attachment's.
The whole-cube sampling view is where a silent defect lived. EnvironmentMapEffect's environment map on this renderer was once stored as the concrete type const WebGPUTextureCubeRenderer*. Binding an ordinary TextureCube worked; binding a RenderTargetCube, a different class implementing the same ITextureCubeRenderer interface, made the cast fail quietly and the effect fell back to its 1 × 1 white cube, with no exception and no obviously wrong image. The repair is a small interface in WebGPURenderer.hpp, implemented by both concrete classes and resolved with one dynamic_cast that yields nullptr for a null or incompatible input:
// WebGPURenderer.hpp at 009d40f5 (comments abridged)
class IWebGPUCubeSamplable
{
public:
virtual ~IWebGPUCubeSamplable() = default;
[[nodiscard]] virtual WGPUTextureView CubeView() const = 0;
// REMED-GFX-167: the view plus the reference that keeps it alive past this object
[[nodiscard]] virtual WebGPUSampledTextureEXT SampledCube() const = 0;
};
Deferred draws now store the SampledCube() value, never the renderer object, so a cube destroyed after a draw was queued still has a live view when the draw replays. The regression webgpu_rendertargetcube_test.cpp proves the fix rather than exercising the feature. Its Check C clears all six faces of a real RenderTargetCube to blue, binds it as EnvironmentMapEffect.EnvironmentMap, draws a full-screen reflective quad and requires a blue centre, which the old cast could never produce. Check D asks whether switching into a face and back isolates the passes: clear the back buffer blue, bind a face and clear it red, unbind, and require the back buffer still blue and the face red, the same "eager flush on target switch" check the 2D target already had, now extended to a third kind of target.
Two scope cuts once documented for cube targets are closed at this snapshot. mipMap = true now allocates a full chain, floor(log2(size)) + 1 levels to match the XNA layer's LevelCount, and each face's chain is regenerated from its resolved level 0 after that face's pass. A per-cube MultiSampleCount is honoured through the same adapter probe the renderer uses elsewhere (WEBGPU-165), with each face resolving into its own layer; the test's Check E and Check F now assert the real chain and the applied 4x count. The test file's own header comment still describes both as refused; the code and its checks are the current evidence.
The cube interface has an older 2D twin. IWebGPUSamplable (View() plus Sampled()) is implemented by both WebGPUTextureRenderer and WebGPURenderTargetRenderer. Before RenderTarget2D existed, every texture a draw could name was a WebGPUTextureRenderer, so each Queue*Draw() stored it through an unchecked static_cast; a render target is an unrelated class (both derive from ITextureRenderer, so one cannot inherit the other without a diamond), which turned that cast into a real hazard. ResolveSamplable() replaces it with a dynamic_cast that treats a null or incompatible resource as unbound, the same shared-capability-by-small-interface pattern the Vulkan renderer uses.
Mip generation without a blit
The pinned wgpu-native v29 has no filtered-downsample command comparable to vkCmdBlitImage; wgpuCommandEncoderCopyTextureToTexture is a same-size copy. The renderer therefore generates each level with a render pass that draws a full-screen triangle sampling the previous level through a linear sampler into the next level's attachment view (GenerateMipsForLayer, pipelines cached per colour format).
When that happens differs by resource, and one case is a deliberate divergence from XNA, FNA and every other CNA renderer:
| Resource | When upper levels are generated |
|---|---|
RenderTarget2D with mipMap = true | From level 0 when the target is unbound (WEBGPU-164), FNA3D's resolve timing; an MRT set regenerates every attached target |
RenderTargetCube with mipMap = true | Per face, after that face's pass (WEBGPU-114) |
Plain Texture2D or TextureCube with mipMap = true | After every write to level 0 (WEBGPU-52), including a partial SetData at level 0, because the XNA layer re-uploads the whole level |
For plain textures, XNA and FNA keep upper levels explicitly authored and never regenerate them. WebGPU's behaviour guarantees that levels above 0 are never undefined after a level-0 upload, but it is a timing difference, not an unqualified improvement: a later level-0 write overwrites upper levels that the game authored earlier with its own SetData(level > 0, …) calls. Explicit writes to upper levels are never regenerated themselves. The source documents the divergence as deliberate. Code that authors its own mip content and then rewrites level 0 behaves differently on this renderer than on the others.
Texture2D GetData on the GPU path
WebGPUTextureRenderer::GetData() (WEBGPU-51) reads an arbitrary Texture2D level back from the GPU: it copies the requested mip level into a temporary MapRead buffer with WebGPU's required 256-byte row alignment, waits through the asynchronous map callback and extracts the requested x, y, w, h rectangle. Before it existed the call silently fell through to the interface's no-op default. WebGPU_Texture2D_GetData checks an exact full-gradient round trip, a non-origin 2 × 2 sub-rectangle and a distinct level-1 round trip, and it drives IGraphicsRenderer directly on purpose: the public Texture2D::GetData normally serves a plain texture from its shared CPU pixel shadow, so a public round trip alone could pass while this GPU path remained a no-op. Render targets, which keep no CPU shadow, go through their own renderer's readback.
Viewport and scissor are per-command dynamic state
Every queued SpriteBatch or 3D command captures its own viewport and scissor at the public draw call (REMED-GFX-116 and REMED-GFX-146); nothing at replay reads the live members. Replay opens each pass with a full-target viewport and scissor and then applies each command's snapshot through wgpuRenderPassEncoderSetViewport and wgpuRenderPassEncoderSetScissorRect on the back-buffer, 2D-target and cube-face paths. Consecutive commands with identical state skip the redundant native call, so several state changes inside one deferred pass no longer collapse to the last value, and same-state draws do not cost one setter each.
Two rules differ from the Vulkan renderer. The viewport is clamped to the current target at application time: wgpu-native treats an oversized viewport as a validation error, and GraphicsDevice.Viewport can legitimately be stale relative to the physical surface during a live resize, since its default is refreshed by UpdateViewportFromWindow() rather than every frame. CNA's records note that wiring the viewport exposed exactly this logical-versus-physical hazard and that clamping is what restored the existing 2D and 3D regressions. WebGPU has no separate scissor-test switch, so a disabled scissor becomes a rectangle covering the whole target. An enabled rectangle is clipped to the target in signed 64-bit arithmetic, never rejected, so a rectangle hanging off an edge keeps its on-target part; an enabled rectangle of zero width or height stays zero-area and clips the draw entirely. That last rule differs from VULKAN and SDL_GPU, which expand an enabled zero-area rectangle to the full target. WebGPU_Viewport_Cardinality, WebGPU_Scissor_Cardinality and WebGPU_DrawOrder_Cardinality prove that per-command state needed no pipeline variant, pass split, extra submit or per-draw setter; the shared deferred pixel fixtures prove the per-command result.
State baked into pipelines
- Cull mode is part of the 3D pipeline key and maps to
WGPUCullMode. Every 3D pipeline declares a counter-clockwise front face, and WebGPU judges facing in framebuffer space, whose Y points down; XNA's front face is clockwise as displayed, and each XNA enumerator names the face it removes. SoCullClockwiseFacemaps toWGPUCullMode_BackandCullCounterClockwiseFacetoWGPUCullMode_Front(REMED-GFX-160). The pairing used to be the other way round, and a pixel test had "verified" it: the probe quad's own winding had been mis-derived, so the test demanded that a back face stay visible under XNA's default cull mode. The SpriteBatch pipeline uses no culling and was immune, which is why the defect showed only in the stock 3D path. The mapping is now measured against the FNA-derived contract by the sharedfrontface_winding_test.cpp. - Stencil (WEBGPU-83): the complete XNA stencil state is baked into each pipeline's
stencilFrontandstencilBack, with the counter-clockwise operations on the back face in two-sided mode and wrapping increment and decrement mapped toIncrementWrap/DecrementWrap; each draw's own reference value is dynamic.WebGPU_StencilFamilyandWebGPU_StencilTwoSidedcover it. - Blend: every source and destination factor and operation, the dynamic
BlendFactor,MultiSampleMaskand per-slotColorWriteChannels(all four slots since WEBGPU-143) reach keyed SpriteBatch and 3D pipelines. - Wireframe (WEBGPU-153): WebGPU has no polygon-mode switch, so
FillMode::WireFrameexpands each triangle's edges into a 32-bit line-list index buffer at queue time, on every 3D route, natively and in the browser. - Multiple render targets (WEBGPU-85/86/87): two to four
RenderTarget2Dtargets form one pass (all must share width, height and sample count, and a cube face cannot be part of the set); a custom WGSL effect writing@location(0..N-1)fans out to every slot, while stock and SpriteBatch pipelines write attachment 0 only and give slots 1 onward a zero write mask, the same stock behaviour every other renderer has. - Occlusion queries (WEBGPU-84):
CreateOcclusionQuery()returns a query that records a real begin and end pair around its draws and resolves the samples that pass depth and stencil. Only a query's first contiguous run of draws in a flush is recorded (a query slot is written once per resolve, the policy the Vulkan renderer also applies), the renderer owns a fixed pool of 32 query slots so a 33rd live query never records or completes, andWebGPU_OcclusionQuerychecks zero for a fully occluded quad and a positive, near-full-target count for a visible one, not an exact number. - Vertex input: instancing uses a real per-instance vertex stream that needs no bind-group change, and several vertex streams of one input rate become one native vertex-buffer layout each (WEBGPU-172). One device slot is reserved, so the reported maximum is the smaller of the device limit minus one and CNA's own stream table.
Each of these was once a documented gap on this renderer: stored-but-unbaked stencil, a refused WireFrame, no MRT, no custom SpriteBatch effects, no custom WGSL effects. All are implemented at this snapshot. The deferral of the stencil mapping had an evidence-based reason worth keeping: after the cull mapping needed a pixel test to correct it, copying a plausible stencil front/back mapping without an equivalent differential test would have made an invisible error look implemented. The cull history above shows that even a differential test needs an oracle derived from the reference contract.
Ordered clears across eleven deferred families
This renderer queues its work in eleven deferred families, and it replays all of them in one ordered pass over their public positions (REMED-GFX-159). The previous fixed order, every 3D family first and sprites last, was justified as "typical XNA draw order"; a typical order is not a contract, and a game that drew its HUD before its world got the two swapped. Each public Clear enters the same stream, carrying independent colour, depth and stencil flags and values, so replay partitions one bind cycle into native pass segments. Leading and consecutive clears fold into the next segment's load actions; a clear after a draw closes the segment, so the following pass observes the clear at its exact public position.
Usage uses the shared FNA predicate: only DiscardContents discards, and PreserveContents and PlatformContents both preserve. The renderer receives that Boolean for 2D and cube targets, and the first native segment selects clear or load for colour, depth and stencil together; later segments load prior contents unless an explicit ordered clear selects an aspect. A cube face owns its colour view while all faces share the target's depth-stencil attachment. The back buffer has no RenderTargetUsage at all in this policy, so its first segment is never treated as discarded. WebGPU_GraphicsDevice_OrderedClear, WebGPU_RenderTargetCube_Usage and WebGPU_RenderTarget_DepthStencilUsage distinguish the three usages and the six faces rather than inferring behaviour from one combined clear.
Block-compressed textures: from open gap to native path
Block-compressed upload was once an open gap on every CNA renderer, and it was investigated on this one first. A diagnostic against the pinned wgpu-native on the development GPU showed the hardware side was not the obstacle: the adapter reported TextureCompressionBC, and requesting it succeeded. The blocker was one layer up. Texture2D content loading CPU-decompressed DXT data to RGBA8 before any renderer saw it, and the shared CNA::Internal::Graphics::ImageData structure every texture-creation path consumed had no field for a surface format. A WebGPU-only shortcut, passing raw BC1 bytes through that RGBA8-typed field, was considered and rejected: it would compile and might even render on one renderer, but no real game's loading path could produce it, and it would misrepresent the structure's contract. The gap was left open until the cross-renderer design could change.
At this snapshot it has. ImageData.hpp carries surfaceFormat and mipLevels, and the renderer answers the content loaders' queries: LoadsCompressedContentNativelyEXT() is true, and IsCompressedTransferFormatEXT() is true for the DXT and BC7 formats only when the device actually enabled the BC feature (WEBGPU-144), in which case they are uploaded to a WGPUTextureFormat_BC* texture. Cube maps follow the same rule since WEBGPU-206 allocated cubes in their requested format. On a device without the feature, the framework's rule applies and content is decoded to Color. The user-facing format table is in Tutorial 132: formats.
Historical defects worth remembering
These are fixed; they are kept because each one teaches how a renderer can look right and be wrong.
- Translucent sprites rendered opaque. The SpriteBatch pipeline's blend factors did not match the non-premultiplied output its shader produced, so a sprite with an alpha strictly between 0 and 1 came out opaque. A manual screenshot review of rotation, flips and filtering missed it; a pixel-value assertion caught it. The repair matched the Vulkan renderer's blend-factor pairing.
- Depth-stencil state was a no-op. Building the first 3D draw path, verified with a real near/far depth-ordering test rather than draw order, revealed that the renderer's depth-stencil apply entry point had never been implemented, so
GraphicsDevice.DepthStencilStatehad silently done nothing on this renderer until then. - A failing test can be the defect. The first MSAA implementation appeared to fail its dedicated test; investigation showed the implementation correct and the test missing
RasterizerState::CullNone.
Evidence and its limits
Every test named here is registered in the WebGPU examples CMakeLists.txt; none was executed for this page. They need a display with a real WebGPU adapter; CNA's own notes say a software-only X server has no adapter and that the native route was verified on Linux x86_64, while the browser route was driven in headless Chrome by CNA's scripts. Native and browser runs are separate evidence environments: Naga (native) and Tint (browser) can disagree about the same WGSL. No workflow selects WEBGPU, and no scene is compared with real XNA output; the renderer's pixel evidence is its own assertions and the 32-fixture cross-renderer parity corpus. The comparison with the other native renderers is in Evidence tiers of the native modern GPU renderers.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Maintainer workflow
- Fix a renderer bug
- Tests and validation
- Test architecture: GPU tests
- Reference
- CMake option index