CANVAS, HTML_DOM and SVG_DOM: browser object models, refusals and evidence
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. Read from the three families' sources, their example and host-test CMake and the HTML_DOM workflow at 009d40f5; no browser, Emscripten build or test was run for this page.
Three CNA identities put XNA sprites into a browser page without WebGL: CANVAS paints pixels into a Canvas 2D context, HTML_DOM turns each sprite into a pooled, CSS-transformed element, and SVG_DOM emits real SVG image nodes with filter-based tint. All three are Emscripten-only and 2D-only, but their object models decide where they fail, what they can read back, how they refuse 3D and how much of their behaviour a test run actually observes. This page is the source-level companion to Tutorial 105, which covers choosing between them and their performance shapes; it is for anyone debugging one of them, extending one, or interpreting a browser test result.
Three object models
RendererSelection.cmake refuses all three outside Emscripten, and their descriptors ask for the ordinary plain window that SDL3's Emscripten driver backs with a <canvas> element. What each renderer does with that page differs from the first draw:
| Question | CANVAS | HTML_DOM | SVG_DOM |
|---|---|---|---|
| A sprite on the back buffer is | drawImage into the SDL canvas's 2D context | a pooled <div> with a CSS transform, a data-URL background and an opacity | a pooled <g> holding a nested <svg> that crops an <image>, or a pattern-filled <rect> for tiling |
| A texture is | a private OffscreenCanvas (or a detached canvas) filled by one putImageData | pixel data encoded to a cached PNG data URL on first use | PNG pixel data that the <image href> nodes reference through an object URL |
A RenderTarget2D is | another Canvas 2D surface | an off-screen Canvas 2D surface; the same draw commands are replayed into it | an off-screen Canvas 2D surface |
| Back-buffer readback | synchronous getImageData | refused (render into a target and read that) | refused (same) |
AdditiveBlending | true ('lighter') | the browser's CSS.supports('mix-blend-mode', 'plus-lighter') | the same browser query |
| 3D refusal | HandleUnsupported3DCall: WarnAndStub honoured | always throws | always throws |
| Declared maturity | Supported | Supported | Experimental |
None has a shader stage, depth buffer, MRT or occlusion query, and a custom SpriteBatch effect throws on all three. Each has a one-line capability switch that answers only AdditiveBlending, so a caller can discover the 2D ceiling before constructing anything that would be refused.
CANVAS: 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), 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.
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. CANVAS overrides neither the viewport nor the scissor hook, so both properties change stored state only (viewport and scissor by family). Readback is a real, synchronous getImageData of the bound context, which makes CANVAS the one browser 2D renderer whose back buffer a screenshot or pixel test can read directly.
HTML_DOM: sprites as pooled CSS objects
The surface and the pool
On construction HtmlDomRenderer.cpp inserts a viewport wrapper and a #cna-dom-root element next to SDL's canvas and refuses to start if the page already has elements with those ids. The canvas is not hidden: it is made transparent (opacity: 0) so that it stays the hit-test surface for the mouse and touch handlers SDL registered on it, and its original opacity is restored at teardown.
HtmlDomSpriteBatchRenderer.cpp resolves all geometry in C++ and sends a whole SpriteBatch batch across the WebAssembly boundary in one call, as a flat array of fixed-size commands. Sprite n of a batch always lands on pool element n, and each element caches the CSS values it last received, so a frame in which sprites merely move writes only transform and opacity, which browsers composite without layout or repaint, and nothing in the path reads layout back. A frame in which nothing changes still runs the flush and walks every command; only the style writes drop to zero. Each distinct scissor rectangle gets its own container ("region") with its own pool, so a later batch's clip cannot reach back and re-clip earlier sprites, and painter's order across regions is expressed with a per-flush z-index rather than document position.
Targets and readback
A <div> cannot render into an off-screen surface, so a bound RenderTarget2D is an off-screen Canvas 2D context and the same command array is replayed into it with the equivalent transform stack; the scissor rectangle (as a canvas clip, honouring ScissorTestEnable) and the viewport origin apply on both paths. Reading a target back is a genuine getImageData. Reading the back buffer throws: the back buffer is a live DOM subtree that the browser composites, and no browser API rasterises one to pixels synchronously. The source records one accepted gap: a readback region larger than a smaller bound target returns transparent padding instead of throwing, because getImageData does not reject it and the shared layer checks the region only against the presentation size.
The two draw paths do not agree at atlas edges under linear filtering. Draw an in-bounds source rectangle from a larger, unpadded atlas at a large fractional scale: the Canvas 2D path, which is the one active while a RenderTarget2D is bound, blends the adjacent, undrawn atlas texel into a sample that lies inside the drawn rectangle, which is what real hardware sampling an unpadded atlas also does; the CSS background-image path, whose element box is a hard clip, shows no bleed at the same point. CNA's own limitations list records this as a measured divergence between the renderer's two paths and leaves it unfixed, and the browser pixel check for the DOM path is kept tight because of it. A game that draws the same sprite both to the back buffer and to a render target and needs identical pixels should pad its atlas or avoid linear filtering on the render-target draws. Read from docs/html-dom-renderer.md (known limitation 14) and htmldom_pixel_verification_test.cpp at 009d40f5; not executed.
State refusals
Beyond the factories and draws, the renderer refuses state it cannot represent at the moment the state is applied: a DepthStencilState with depth test, depth write or stencil enabled, a non-zero reference stencil, CullClockwiseFace, FillMode.WireFrame and any depth bias. Every refusal goes through a local [[noreturn]] ThrowNo3D helper that raises std::runtime_error ("HTML_DOM renderer: … not yet implemented") and bypasses HandleUnsupported3DCall, so WarnAndStub cannot turn it into a warning. Sampler state is expressible only as CSS: Clamp overflow is handled with a cached edge-extended texture variant, and custom blend states, non-default ColorWriteChannels and MultiSampleMask throw.
The additive capability is the renderer's one live browser query: the answer of CSS.supports('mix-blend-mode', 'plus-lighter') is computed once and memoised, since it cannot change during a session. On a browser without plus-lighter, BlendState::Additive degrades silently to ordinary composition, which is why the capability must be queried rather than assumed; the browser versions and the alpha-channel divergence for translucent targets are in Tutorial 105.
SVG_DOM: retained vector structure
SvgDomSpriteBatchRenderer.cpp builds a retained SVG tree: each pooled sprite is a <g> placed with SVG transforms, holding a nested <svg> viewport that crops an <image>; a tiled (Wrap or symmetric Mirror) draw uses a <pattern>-filled <rect>. Tint is a real feColorMatrix filter cached in <defs> and referenced by URL rather than a rewrite of texture pixels, and additive sprites use mix-blend-mode: plus-lighter behind the same memoised CSS.supports answer as HTML_DOM.
The boundary is narrower than HTML_DOM's in two places. TextureFilter must be Linear or Point (one image-rendering toggle cannot express anisotropic or split min/mag/mip filters, so the others throw), and an out-of-bounds source rectangle under Clamp or mixed per-axis addressing throws instead of being cropped silently. It shares the rest: an off-screen Canvas 2D target with getImageData readback, a refused back-buffer readback, the same state refusals through its own ThrowNo3D, and no WarnAndStub. The tree is inspectable and matches vector composition naturally, but it inherits SVG's filter, sampling, blending and layout semantics.
Where each model fails
The three can show an identical sprite scene while failing in different places, which is the main reason to test them separately:
CANVASfailures cluster around pixel upload, context state carried between calls, clipping and immediate composite operations.HTML_DOMfailures cluster around pooling and reuse, CSS transform order, stacking across scissor regions and browser support forplus-lighter.SVG_DOMfailures cluster around retained-node lifetime, SVG transforms, filter caching and sampling.
A useful cross-renderer scene therefore includes a cropped source rectangle, rotation about an off-centre origin, overlapping alpha and additive sprites, a scissor rectangle with the test both enabled and disabled, a target bind and unbind with readback of the target, and node-count stability over many frames. Inspect both structure and pixels: the DOM can look mechanically correct while the browser composites a different colour, and a correct screenshot can hide unbounded node growth.
Evidence: built, registered and run are different things
| Renderer | Browser test programs | Automatic browser run | Native host contract suite |
|---|---|---|---|
CANVAS | Built when CANVAS is selected and deliberately not registered with CTest (canvas/examples/CMakeLists.txt) | None | CanvasHostContracts, behind CNA_BUILD_CANVAS_HOST_TESTS (off by default) |
HTML_DOM | Smoke, pixel, stress, dispose, host-integration and memory pages | htmldom-ci.yml on pushes and pull requests to next, develop and main (Markdown- and docs-only changes ignored): Playwright drives headless Chromium under Xvfb through scripts/run-htmldom-test-suite.sh | HtmlDomHostContracts, behind CNA_BUILD_HTML_DOM_HOST_TESTS (off by default) |
SVG_DOM | Smoke, pixel-verification and scissor-order pages | None; run-svgdom-browser-test.sh is a manual route | SvgDomHostContracts, behind CNA_BUILD_SVG_DOM_HOST_TESTS (off by default) |
The CANVAS 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 three native host suites, declared in modules/renderers/CMakeLists.txt, compile each family's renderer-independent contract on the host with the EM_JS bodies excluded; they are real tests, but no workflow enables them (the options are listed in CMake options).
HTML_DOM has the strongest evidence of the three, because its workflow executes the browser object model rather than only compiling Emscripten output. Even there, what a page asserts matters: node counts and style strings prove a different layer from final composited pixels. The SVG_DOM smoke page checks the raw CSS.supports answer, the public capability and the emitted sprite style together, and separate pages cover pixels and scissor order, but a runnable script is a test route, not evidence that the route gates changes. The Emscripten multi-renderer workflow builds one bundle containing WEBGL2, CANVAS, HTML_DOM and SVG_DOM and checks that all four and the JavaScript selection surface are present; it does not run the three 2D renderers. The web build contract itself is on The web target.
Deployment checklist
- Record the CNA identity, the Emscripten version, the browser and its version, the device-pixel ratio, the canvas or SVG dimensions, and whether the run was visible or headless.
- Confirm that the page reaches several animation frames, not only the first; pooling, region reuse and node growth show up only over time.
- Query
AdditiveBlendingat start-up onHTML_DOMandSVG_DOM, and decide what the game does when the browser answers false. - If the game reads pixels (screenshots, pixel tests, picking), use
CANVASor render into aRenderTarget2Dand read that. - Check that the observable fits the feature under test: a DOM or SVG structure check for pooling and ordering, a target readback for colour.
- Treat browser compilation as the first gate only; these renderers live in the DOM that runs after it.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Tutorial 105: browser-native renderers · Graphics renderers: browser renderers · Tutorial 81: building for the web
- Architecture
- Graphics architecture
- Internals
- Graphics backends
- Maintainer workflow
- Fix a renderer bug
- Tests and validation
- Test architecture
- Reference
- CMake options