Documentation · Concepts

Deploying to the browser

A browser is the platform where the control has the least to work with and the most to prove: one graphics API and no choice about it, no file system, a runtime that has to be trimmed before it is fast, and a graphics context the page can lose. This is what changes there, effect by effect, and what to do about each of them — measured on a published build rather than read off a specification.

What the browser gives you

Avalonia's browser backend draws through WebGL 2, and the control leases that context the same way it leases OpenGL anywhere else — see How the renderer is chosen. RenderInfo.Renderer reads OpenGL and RenderInfo.Context reads OpenGL ES 3.0, because that is what WebGL 2 is: the same shader the desktop GL backend compiles, on the same code, with the differences below. A browser that offers no WebGL at all — or a page that asked Avalonia for its 2D canvas instead — gets the CPU renderer, and the picture still arrives.

What you do not get is a choice. Ava3DView.PreferredBackend can move between the GPU and the CPU renderer on the next frame, as everywhere; there is no Metal or Vulkan to ask for, and the catalogue in RenderInfo.AvailableBackends says so with a reason rather than a blank row.

What WebGL 2 lacks, and how each effect degrades

The honest list is RenderInfo.Features, which the renderer fills in after its first frame with the same rows on every backend and a sentence for each. On a published build in Chrome the OpenGL renderer ticks 33 of the 34, and the one it does not tick is the light count. The rows a browser changes, and what each one means for a picture:

RowIn the browser
Every light in the scene The shader's light loop has a length fixed when it compiles, sized from the driver's uniform budget. WebGL 2 promises enough for about a hundred; the renderer stops at 64 on purpose, because a uniform array is reserved whether a scene fills it or not. A scene with more loses the ones it added last, and the row says how many this context was built for.
GPU timings RenderInfo.GpuMilliseconds is null. Emscripten routes GL ES query objects through a WebGL 1 extension a WebGL 2 context does not have, so the renderer never asks — asking would end the process. Measure RenderMilliseconds and the frame time instead; both still work. (GL1006 in PlatformSupport.)
The device's name RenderInfo.Device is null. The GPU model is a fingerprint, and browsers refuse the extension that would name it. Nothing degrades but the diagnostics panel. (GL1009.)
Picking and readback A blocking fence wait takes a 64-bit timeout, which trips WebAssembly's call-type check, so readback polls or stalls rather than pipelining. Click-to-pick still answers; it may take a frame longer. (GL1008.)
Block-compressed textures Whatever the browser advertises — WEBGL_compressed_texture_s3tc on most desktops, ETC2 or ASTC on phones — is taken as it is, and the row lists the formats. BC1, BC3, BC4 and BC5 are decoded to RGBA8 where the context lacks them, at the size the card could have avoided; BC7, ETC2 and ASTC have no decoder and are reported rather than drawn.
Anisotropic filtering, depth bias, soft particles, instancing, edge anti-aliasing, shadows All present on WebGL 2, and all measured rather than assumed: anisotropy at the driver's ceiling, depth bias through glPolygonOffset, the depth copy a soft sprite reads, instanced draws, multisampled renderbuffers, and the depth pass. Two of these were refused in the browser for three releases and turned out to be a build fault rather than the browser's — a build that was assembled rather than published lacks the trampolines the entry points need. See Publishing trimmed, which is where that fault lives now.
The maps A fragment shader on WebGL 2 has sixteen sampler units and this renderer needs eleven, so every map has a home. A context offering only the eight WebGL 1 guarantees shares units between maps and says which two cannot be used on one material — the row for each map carries the answer.

RenderInfo.PlatformLimitations is the unsupported rows in one line, for a status bar or a log. The minimal sample below prints it after its first frame, which is how the 33 of 34 above was read.

The software fallback

Where there is no WebGL — a locked-down kiosk, a browser with hardware acceleration switched off, an automated run with no GPU — the control draws through Skia on the CPU, and RenderInfo.Renderer says so. The picture is the base-colour map lit once per vertex: no per-pixel lighting, no normal, metallic-roughness, emissive or occlusion maps, no bloom on the default pipeline, and the frame is filled at up to four megapixels before it is drawn smaller and stretched. The rows in Features say each of these individually, with what to do instead.

Two things make that renderer fit for a browser rather than merely present. Ahead-of-time compilation: the vertex work is C#, and interpreted C# costs ten times what compiled C# does — 4 fps against 44 on the demo's board scene, measured. And threads: Ava3DView.SoftwareThreading shares the vertex pass and the fill across every core the tab has, which is one core unless the application was published for threads and the page is cross-origin isolated. Both are arranged in the project file, and both are explained below.

Assets arrive over HTTP

A browser application has no working directory. A model is a URL, and everything a .gltf names beside itself — its buffer, its images — is another URL relative to it. GltfAsset.LoadAsync takes the address and, for http and https, fetches through a GltfHttpResolver without being told to; hand it one when the client needs configuring, and the model's own address as the base so its resources resolve beside it:

var model = new Uri(page, "models/spinner.gltf");   // page is the tab's own address

using var http = new HttpClient();
var asset = await GltfAsset.LoadAsync(model, new GltfLoadOptions
{
    Resolver = new GltfHttpResolver(http, model)
});

var scene = new Scene();
var instance = asset.Instantiate().AddTo(scene);   // registers its Animator with the scene
instance.Animator.Play("Spin").Loop = true;
view.Scene = scene;

The page's own address is the one thing the application cannot discover for itself; the sample's main.js passes location.href as the argument to runMain. A model shipped inside the application is an AvaloniaResource and an avares:// address instead, read through the resource resolver — the same call, a different scheme. Either way the textures decode and upload over the frames that follow, and Ava3DView.SceneReady says when the first frame with nothing left to upload has been drawn, which is the moment to take a placeholder away.

Threads, and the two headers

WasmEnableThreads in the project file gives the tab more than one core: the runtime is linked for threads, SkiaSharp switches to its multi-threaded native library, Avalonia's browser backend moves its dispatcher to a worker with a real OffscreenCanvas, and Environment.ProcessorCount stops saying 1 — which opens the CPU renderer's SoftwareThreading gate by itself. The demo measured the tax at under four per cent on one core and the refund at three to five times on all of them.

A threaded runtime needs SharedArrayBuffer, and a browser only hands that to a page that is cross-origin isolated: its own response carried Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. A server you control sends them — the repository's serve.py does. A static host that cannot set a header, such as GitHub Pages, gets them from a service worker that sits in front of the page and adds the two headers to every response it hands back, at the cost of one reload the first time; the demo's coi.js is that worker, and its comments are the whole of what it does. A page that is not isolated and was published for threads cannot start at all, so the demo's loader checks crossOriginIsolated before it fetches the runtime and says what went wrong instead of downloading twenty megabytes to find out.

The minimal sample is single-threaded on purpose. It exists to measure what the trimmer keeps, and a second runtime flavour would be a second question.

Losing the context

A tab left in the background, a GPU process that crashed, a laptop switching graphics cards: the browser drops the WebGL context, every texture and buffer in it is gone, and the only party told is the page — as webglcontextlost and webglcontextrestored on the canvas. Neither reaches the code drawing into the context, and after a restore the same context object is usable again with everything in it gone, so nothing in the control can tell. The page has to say so, through Ava3DView.ResetRenderer, which drops the renderer and builds it again on the next frame with every GPU resource made afresh.

// app.js — listened on every canvas under Avalonia's mount point, once the application is up
canvas.addEventListener('webglcontextlost', event => event.preventDefault());   // or no restore ever comes
canvas.addEventListener('webglcontextrestored', () => restored());               // the [JSExport] below
// Program.cs
[JSExport]
internal static void ContextRestored() =>
    Dispatcher.UIThread.Post(() => MainView.Current?.View.ResetRenderer());

preventDefault on the loss is what makes the restore possible at all; a browser does not restore a lost context by default. On every other platform the control notices its own losses — a Vulkan device lost, an OpenGL context the driver reports reset, a Metal command buffer that finished with a device error — and rebuilds without being asked; the browser is the one place the host is the only one told.

What that buys today, measured. The minimal sample forces a loss through WEBGL_lose_context and restores it two seconds later. The control's renderer comes back: the same OpenGL ES 3.0, the same size, the model's draw and its texture, no error, frames flowing. But Avalonia 12.1's browser backend has no handler for the two events and does not rebuild its own Skia context, so what it composites after the restore is nothing — the overlay label, which is pure Avalonia, is gone whether the renderer was reset or not (?reset=0 in the sample shows the difference). ResetRenderer is the control's half of a recovery whose other half does not exist yet. Until it does, a page that gets its context back should call ResetRenderer and then location.reload(): the reload is cached and costs a second, and it is the only thing that brings the rest of the interface back. The probe reports the picture after the restore as "composition lost" for exactly this reason, and does not fail on it.

Publishing trimmed

The browser SDK trims by default — PublishTrimmed and TrimMode=full — and it has to, because trimming is what makes RunAOTCompilation possible and AOT is the ten times above. The package declares IsTrimmable and IsAotCompatible, builds with the trim analyser on and no warning suppressed, and parses glTF through a source-generated serializer so no reflection walks a type graph at run time. A consumer's publish carries no IL2xxx warning from this library.

One setting is not a default and is not optional: WasmBuildNative. The renderer binds GL entry points beyond Avalonia's own set as function pointers, and calling one in WebAssembly needs a trampoline generated for that exact signature. The native relink generates them by scanning the application; without it the first such call aborts the runtime at aot-runtime-wasm.c:188 with an ExitStatus carrying no message, and the page is black while every diagnostic the application prints says nothing is wrong. It costs about a minute and needs the wasm-tools workload.

<PropertyGroup>
  <TargetFramework>net10.0-browser</TargetFramework>
  <PublishTrimmed>true</PublishTrimmed>
  <TrimMode>full</TrimMode>
  <WasmBuildNative>true</WasmBuildNative>          <!-- the trampolines; not optional -->
  <RunAOTCompilation>true</RunAOTCompilation>      <!-- ten times, on the CPU renderer -->
  <WasmEnableThreads>true</WasmEnableThreads>      <!-- with the two headers above -->
</PropertyGroup>

What a consumer still needs to root

Nothing. The package carries an ILLink.Descriptors.xml that the trimmer reads by name, and it holds the whole contract: this renderer's own twenty-one GL delegates, and the trampoline signatures for every GL entry point Avalonia binds. A consumer's project file needs no TrimmerRootAssembly and no NoWarn; the minimal sample has neither, and it is the measurement.

The second half of that contract is worth understanding, because it is the kind of thing that comes back. Avalonia's [GetProcAddress] generator emits, beside each entry point's function pointer, a nested delegate named __wasmDummy<Name> marked [UnmanagedFunctionPointer] — a hook for exactly the scan above, so that the signature gets a trampoline. Nothing references those delegates, so a full trim removes all of them, and the three the renderer needed — glClearColor, glClearDepth, glTexImage2D, the only three of Avalonia's signatures not already covered by some other delegate in the application — went with them. The descriptor keeps every one of GlInterface's dummies rather than those three, at ten kilobytes before compression, so the next entry point taken from Avalonia cannot fail the same silent way. Rooting GlInterface itself would not have done it: a type entry keeps members, not nested types.

DescriptorMinimal sample, published trimmedDraws
Four assemblies preserved whole (previews up to 13) 22.2 MB in 165 files; 6.2 MB brotli yes
Nothing but this renderer's own delegates 19.4 MB in 150 files; 5.3 MB brotli no — aborts on the first frame
The delegates and Avalonia's __wasmDummy types (now) 19.5 MB in 150 files; 5.4 MB brotli yes — 63 trampolines against 60

Checking it

The failure this page is about is silent, so the check is a browser drawing a frame, not a build log. samples/Ava3D.Browser.Minimal is the smallest application that uses the control the way yours will — one view, the model above with its buffer and image beside it, one overlay label, the context handlers — published trimmed with nothing rooted and nothing suppressed. It prints a line for each thing that happened: on screen, which renderer and what it lacks, the model loaded and drawn, the context restored and the renderer back. tools/browser-probe.py publishes it, serves it, drives a real Chrome over the DevTools protocol, waits for each line, forces the loss, photographs the page and decodes the photograph: the cube's orange and the label's white have to be there, because a page that trimmed away the GL path prints nothing and draws nothing, and only the second of those is proof. It runs on every push, on a runner whose Chrome has no GPU and renders WebGL in software.

Run it

The sample, published and probed; then the same folder served for a real browser:

python3 tools/browser-probe.py --publish
python3 serve.py artifacts/browser-minimal/wwwroot 8131     # then open http://127.0.0.1:8131/

In the devtools console, ava3dLoseContext() takes the context away and gives it back two seconds later; the lines the application prints are the ones the probe waits for.