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:
| Row | In 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.
| Descriptor | Minimal sample, published trimmed | Draws |
|---|---|---|
| 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.