Tutorial 105: Browser-Native Renderers
What you’ll learn
- What
CANVAS,HTML_DOMandSVG_DOMactually put on the page, and how to choose between them. - The one thing
CANVAScan do that the other two cannot, and the one performance shape that inverts the ranking. - The only capability in CNA whose answer is a live browser query — and the only place a renderer degrades silently.
- The three web traps that apply no matter which browser renderer you pick.
Before you start — Tutorial 81: Building for the Web covers the Emscripten toolchain these three renderers require.
Alongside WEBGL1 and WEBGL2, CNA has three browser renderers that do not use WebGL at all. They draw with the browser's own 2D primitives: a Canvas 2D context, CSS-composited DOM elements, or real SVG nodes.
All three are Emscripten-only and refuse to configure for a native target with a FATAL_ERROR, because a CanvasRenderingContext2D, an HTMLDivElement and the SVG namespace only exist inside a browser. All three are 2D-only: every 3D entry point they override throws deterministically rather than silently doing nothing.
What each one puts on the page
| Renderer | How a sprite is drawn |
|---|---|
CANVAS | A drawImage call into a CanvasRenderingContext2D. Each Texture2D owns a private off-screen canvas; uploads are a single synchronous putImageData. |
HTML_DOM | A pooled <div> inside one root element, carrying a CSS transform list for position/rotation/scale/flip, a background-image data URL for the texture, and opacity for tint alpha. Draw order is DOM document order. |
SVG_DOM | A pooled <g> inside one <svg> root: a nested <svg> cropping an <image>, or a <pattern>-filled <rect> for tiled draws. Tint is a native feColorMatrix filter. |
In all three cases SDL3 still owns the real <canvas> element — it stays in the layout, hidden, because SDL keeps sizing it and delivering input through it. The DOM and SVG renderers create their own surface over the top of it.
The decisive difference: backbuffer readback
HTML_DOM and SVG_DOM cannot read the backbuffer. GetBackBufferData() throws on both, and the exception says why: no browser API rasterises a live DOM or SVG subtree to pixels synchronously. CANVAS can — it is a real, synchronous getImageData call.
The workaround is the same on both: render into a RenderTarget2D and read that. Render targets on both renderers are backed by a real off-screen canvas, so their readback is a genuine getImageData. If your game takes screenshots, runs a pixel-comparison test, or samples the frame for any reason, plan for that indirection — or choose CANVAS.
Performance shapes, not a ranking
HTML_DOM exists because CSS transform and opacity are compositor-only properties: a frame in which sprites merely move triggers no layout and no repaint. Sprite n of a frame always lands on pool element n, each element caches its last applied style values, and a whole batch crosses the WebAssembly-to-JavaScript boundary in one call. A frame in which nothing changes performs zero CSS property writes.
The cost sits somewhere else entirely:
| Workload | How HTML_DOM behaves |
|---|---|
| Moving, rotating, scaling or fading sprites | Fast — one transform or opacity write each, composited on the GPU. |
| Large sprite counts that stay stable frame to frame | Fast. |
| Uploading and drawing texture pixels every frame | Slow. Each texture/tint/address-mode variant needs a PNG data URL, encoded lazily on first draw and then cached. Fine once at load time; expensive every frame. |
| Animating a sprite's tint RGB every frame | Slow — a new cached variant per distinct colour, capped by an LRU. |
| Very high sprite counts | Each pooled sprite carries its own compositor layer, so memory grows with the sprite count. This targets normal 2D games, not particle storms. |
The rule of thumb: HTML_DOM rewards static sprite sheets and moving sprites, and punishes dynamic pixel data. If your game calls Texture2D::SetData every frame on a texture it also draws, use CANVAS or a WebGL profile instead.
The one live capability query in CNA
HTML_DOM and SVG_DOM report false for every GraphicsCapability except one. AdditiveBlending is answered by asking the running browser, once, whether it supports the CSS value these renderers map BlendState::Additive onto:
// Answers from a memoised CSS.supports('mix-blend-mode', 'plus-lighter')
if (device.SupportsCapability(CNA::GraphicsCapability::AdditiveBlending))
DrawGlowPass();
else
DrawGlowFallback();
That matters because of what happens otherwise:
This is the one place a CNA renderer degrades silently. On a browser engine without mix-blend-mode: plus-lighter, the CSS value is simply ignored before any CNA code can observe it, and Additive renders as ordinary source-over blending — no exception, just a different picture. Support arrived in Chromium 108, Safari 16.4 and Firefox 122. Query the capability rather than assuming.
Every other blend behaviour on these renderers fails loudly instead: only the four standard presets exist in CSS compositing, so any custom BlendState throws, as does a non-default ColorWriteChannels or MultiSampleMask.
A real divergence worth knowing
For NonPremultiplied and Additive, XNA's own blend equation squares the source alpha's contribution to the result alpha. CSS compositing has no per-channel blend-factor model that can reproduce that. The consequence is precise and bounded:
- Colour channels are always exact on both renderers.
- The alpha channel is exact only when the source is fully opaque, where the squared and unsquared formulas coincide.
You will never see this drawing opaque sprites to the screen. You will see it if you draw translucent content into a RenderTarget2D and read the raw RGBA back. It is a documented architectural limit, measured against real translucent data rather than assumed.
Where SVG_DOM differs
SVG_DOM shares the DOM renderers' shape but draws a narrower boundary in one place: an out-of-bounds source rectangle under TextureAddressMode::Clamp, and mixed per-axis addressing, both throw. Wrap and symmetric Mirror work through a <pattern> fill. HTML_DOM handles the Clamp overflow case with a cached edge-extended texture variant; SVG_DOM has not built that yet and refuses rather than cropping silently.
What SVG_DOM gives you in exchange is a genuinely vector output tree: real, inspectable SVG nodes with real clip paths, which is useful when the page itself is the artefact.
How much of this is actually exercised in a browser
| Renderer | Browser verification |
|---|---|
HTML_DOM | Real, automatic CI. htmldom-ci.yml runs on every push and pull request on ubuntu-24.04, driving several PASS/FAIL pages in headless Chromium via scripts/run-htmldom-test-suite.sh, with real DOM inspection, real RenderTarget2D readbacks and real screenshots. |
SVG_DOM | A real Playwright plus headless-Chromium harness exists (scripts/run-svgdom-browser-test.sh, with smoke, pixel and scissor-order pages) and a native host-contract suite that needs no Emscripten SDK — but it is not wired into a CI workflow. |
CANVAS | No browser run at all in CNA's development environment: the Node-based test runner provides no CanvasRenderingContext2D. Its status is “implemented, reviewed, and covered by structural C++ tests”, with a written manual browser checklist. Verify your own output visually. |
That difference is worth respecting when choosing. HTML_DOM is the browser renderer whose behaviour is continuously re-checked against a real engine.
Three traps that apply to all three
Heap-allocate your Game. emscripten_set_main_loop with an infinite-loop simulation is implemented as a raw JavaScript throw, and CNA compiles with native WebAssembly exception handling, so the cleanup landing pad generated for a stack-local object with a non-trivial destructor genuinely catches it. A stack-allocated Game therefore has its destructor run immediately at the emscripten_set_main_loop call site, deleting the real GraphicsDeviceManager and leaving a dangling pointer that faults frames later. Use new, and deliberately never delete — correct for a page-lifetime application object.
There is no save persistence on the web. SDL_GetPrefPath under Emscripten resolves to volatile MEMFS; CNA mounts no IDBFS and never calls FS.syncfs. Every save is silently discarded on page reload.
There is no video. The video translation units are excluded from web builds, so Video and VideoPlayer compile against their still-present headers and then fail to link.
Building
emcmake cmake -S . -B build-canvas -DCNA_GRAPHICS_RENDERER=CANVAS
emcmake cmake -S . -B build-htmldom -DCNA_GRAPHICS_RENDERER=HTML_DOM
emcmake cmake -S . -B build-svgdom -DCNA_GRAPHICS_RENDERER=SVG_DOM
cmake --build build-htmldom -j3
# Drive the HTML_DOM browser suite in headless Chromium
scripts/run-htmldom-test-suite.sh build-htmldom
Choosing between them
| You need | Pick |
|---|---|
| Backbuffer readback, or per-frame texture uploads | CANVAS |
| Many sprites that move but do not change pixels, and continuously tested behaviour | HTML_DOM |
| A real, inspectable vector output tree | SVG_DOM |
| 3D, custom shaders, or anything past the 2D boundary | WEBGL2 — see Tutorial 102 |