CANVAS: the browser 2D canvas renderer, its refusals and evidence

CNA snapshot b0e97bb1  ·  Deep Dives › Renderers  ·  source links pinned to b0e97bb1

✓

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 b0e97bb1; unless a sentence says otherwise, nothing here was built or executed. Read from the CANVAS family's sources, its example and host-test CMake, the Emscripten multi-renderer workflow and CNA's runtime-selection document at b0e97bb1; no browser, Emscripten build or test was run for this page.

CANVAS puts XNA sprites into a browser page without WebGL: it paints pixels into the Canvas 2D context of the <canvas> element that SDL3's Emscripten driver creates. It is Emscripten-only and deliberately 2D-only, it needs no GPU, and its back buffer is ordinary pixels that a test can read synchronously. Its object model decides where it fails, what it refuses and how much of its behaviour a test run actually observes. This page is the source-level companion to Tutorial 105, which covers choosing a browser renderer; it is for anyone debugging CANVAS, extending it, or interpreting a browser test result.

The object model

RendererSelection.cmake refuses CANVAS outside Emscripten, and its descriptor asks for the ordinary plain window that SDL3's Emscripten driver backs with a <canvas> element. Every XNA object the renderer serves maps onto a Canvas 2D object:

XNA object or questionWhat CANVAS uses
A sprite on the back bufferdrawImage into the SDL canvas's 2D context
A texturea private OffscreenCanvas (or a detached canvas) filled by one putImageData
A RenderTarget2Danother off-screen Canvas 2D surface, bound as the current context
Back-buffer readbacksynchronous getImageData of the bound context
AdditiveBlendingtrue ('lighter')
3D refusalHandleUnsupported3DCall: WarnAndStub honoured
Declared maturitySupported

There is no shader stage, depth buffer, MRT or occlusion query, and a custom SpriteBatch effect throws. The capability switch is one line that answers only AdditiveBlending, so a caller can discover the 2D ceiling before constructing anything that would be refused. CANVAS and SDL_RENDERER are CNA's two deliberately 2D-only identities; the other browser renderers, WEBGL2, WEBGL1 and WEBGPU, are 3D-capable.

Immediate raster operations

CanvasRenderer.cpp obtains the SDL canvas's getContext('2d') and maps every operation onto Canvas 2D calls against whichever context is bound, the main canvas or a target's. Sprites are not sent one call at a time: CanvasSpriteBatchRenderer packs each deferred batch into fixed-size command records that the shared layer has already sorted, and replays the whole batch from one WebAssembly-to-JavaScript call at End() (CNA_Canvas2D_DrawSprites); only SpriteSortMode::Immediate sends a command per call. Clear with a positive alpha is a fillRect under globalCompositeOperation = 'copy', because XNA's clear overwrites rather than blends; a zero alpha uses clearRect; both run inside save()/restore() so a clear between Begin and End does not disturb the batch's transform or composite state.

Blend states map to four composite operations and nothing else: Opaque to 'copy' (clipped to the sprite's own rectangle, because Porter-Duff copy would otherwise clear the whole canvas outside it), AlphaBlend and NonPremultiplied to 'source-over' (premultiplied sources are un-premultiplied first, since Canvas 2D treats its input as straight alpha; the straight-alpha copy of a texture is cached after its first AlphaBlend use), and Additive to 'lighter'. Any other BlendState throws, because globalCompositeOperation has no blend-factor model. Tint is an exact per-pixel pass, and TextureFilter reduces to its magnification component, which drives imageSmoothingEnabled. Clamp is implemented by clamping the source rectangle into the texture; Wrap and Mirror matter only when the source rectangle leaves the texture, and then fill a createPattern pattern (a pre-tiled 2×2 mirrored canvas for Mirror) under the same transform stack as drawImage.

The 3D surface uses the shared policy: clears and toggles naming depth or stencil, the buffer factories, the coloured draws and the cube, volume and query factories call HandleUnsupported3DCall, and the resource factories return a stub object under WarnAndStub. Readback is a real, synchronous getImageData of the bound context, so a screenshot or pixel test can read the CANVAS back buffer directly; the cross-family comparison is in back-buffer readback by family.

Presentation: logical size, letterboxing and input

A game's back buffer size is logical; the canvas has a physical drawable size and a device-pixel ratio. CANVAS keeps the two apart. Its private getPresentedRect computes the physical rectangle the logical image occupies: under Letterbox and Overscan with a virtual resolution, the virtual size scaled by the smaller (letterbox) or larger (overscan) of the two axis ratios and centred in the drawable; otherwise the whole drawable. GetDefaultViewportRect reports that rectangle to GraphicsDevice, which maps every public, logical Viewport onto it before calling the renderer's SetViewport.

CANVAS overrides SetViewport and retains that physical rectangle in the state it shares with its sprite batches. Each batch is clipped to it, and the batch's logical projection is scaled and offset into it (CanvasSpriteBatchRenderer.cpp, GetDrawTransform), so letterbox bars stay untouched and a viewport narrower than the screen confines the sprites drawn through it. Binding a render target sets the viewport to the target's full size; unbinding restores the presented rectangle. The scissor hook is not overridden, so ScissorRectangle changes stored state only on this renderer (viewport and scissor by family).

Pointer input runs the inverse mapping. TransformWindowToLogical scales the window coordinate by the display scale, subtracts the presented rectangle's origin and divides by the per-axis ratio, so a pointer over a letterbox bar maps to a coordinate outside the logical screen, and stretch and high-DPI presentation use independent horizontal and vertical scales. Two host tests in CanvasRendererTests.cpp pin the arithmetic (CanvasPresentation.LetterboxUsesPhysicalBoundsAndInputOffset and CanvasPresentation.StretchAndHiDpiInputUseIndependentAxisScales); they belong to the opt-in host suite described under Evidence.

What CANVAS refuses

By default every refusal is an exception at the call that cannot be represented, not a silently different picture:

  • 3D work goes through HandleUnsupported3DCall: it throws by default and becomes a warn-once no-op with a stub resource under Unsupported3DGraphicsCallBehavior::WarnAndStub (the unsupported-3D policy).
  • Blend states other than the four standard presets throw from BlendStateToCompositeOp.
  • A custom SpriteBatch effect (any non-null Effect) throws: there is no programmable shader stage.
  • Several render targets at once throw, because a CanvasRenderingContext2D is single-target, and so does binding a RenderTargetCube face.
  • Mip levels: a SetData to any level other than 0 throws; Canvas 2D has no mip chain or per-level sampling.
  • Addressing edge cases: with a source rectangle that leaves the texture, mixed per-axis address modes, a tinted Wrap/Mirror draw and an AlphaBlend Wrap/Mirror draw that needs un-premultiplying all throw before any pattern is filled.

Where CANVAS fails

CANVAS failures cluster around pixel upload, context state carried between calls, clipping, immediate composite operations and the mapping between logical and physical coordinates. They can produce a sprite scene that looks right at one canvas size and wrong at another, which is the main reason to test it at more than one drawable size and device-pixel ratio.

A useful scene therefore includes a cropped source rectangle, rotation about an off-centre origin, overlapping alpha and additive sprites, an Opaque sprite drawn over existing content, a tinted sprite, a target bind and unbind with readback of the target, and a letterboxed presentation with a pointer position checked on and off the logical screen. Inspect pixels: the composited colour is the observable, and a canvas read through getImageData reports it directly.

Evidence: built, registered and run are different things

RouteWhat existsRuns automatically
Browser test programsSmoke, capability, sprite-presentation, texture and render-target contract and unsupported-3D programs, built when CANVAS is selected and deliberately not registered with CTest (canvas/examples/CMakeLists.txt)No
Native host contract suiteCanvasHostContracts, behind CNA_BUILD_CANVAS_HOST_TESTS (off by default), declared in modules/renderers/CMakeLists.txtNo; no workflow enables the option
Multi-renderer bundleemscripten-multi-renderer-ci.yml builds one wasm bundle holding WEBGL2, WEBGL1 and CANVAS and asserts that the renderer archives, the three registry entries and the JavaScript selection surface are presentOn pushes and pull requests to next, develop and main; it builds and inspects the bundle and does not run it
Recorded browser runCNA's runtime-selection document records that the renderer benchmark ran to completion in headless Chrome under each of the three identities of that bundle, each preferred through Module.cnaPreferredRenderer (28 September 2026)No; recorded by CNA, not automated in CI

The browser programs are not registered because an ordinary host test run cannot execute them: under Node alone SDL_Init(SDL_INIT_VIDEO) already fails with no browser DOM, before any Canvas code runs. A green host run therefore says nothing about Canvas pixels; the meaningful route is an Emscripten build loaded in a real browser that opens the page, drives frames and reads pixels or a screenshot. The native host suite compiles the renderer-independent contract (blend mapping, presentation arithmetic) on the host with the EM_JS bodies excluded; it is a real test, but nothing runs it unless a developer turns the option on (the options are listed in CMake options). The web build contract itself is on The web target.

Deployment checklist

  1. Record the CNA identity, the Emscripten version, the browser and its version, the device-pixel ratio, the canvas dimensions, the presentation mode, and whether the run was visible or headless.
  2. Confirm that the page reaches several animation frames, not only the first; cached texture variants and context state carried between frames show up only over time.
  3. Check the game at more than one canvas size: letterbox bars, viewport clipping and pointer mapping depend on the drawable size.
  4. Keep blend states to the four presets and avoid custom SpriteBatch effects, or select a WebGL renderer for the content that needs them.
  5. If the game reads pixels (screenshots, pixel tests, picking), read the back buffer or a RenderTarget2D; both are synchronous on CANVAS.
  6. Treat browser compilation as the first gate only; the renderer's behaviour lives in the page that runs after it.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture
Reference
CMake options