Documentation · Concepts
How a frame happens
You build a tree of objects on the UI thread. Something else draws it, on another thread, through a graphics API you did not choose. Knowing where the handover is explains most of the API — including the parts that look like restrictions and are actually the reason it works in a browser.
The shape of it
Five steps, one direction, one handover. Everything below the dashed line runs on a thread you never touch.
Two threads, one snapshot
Avalonia's compositor renders on its own thread. Your scene graph lives on the UI thread and you expect to mutate it from a button handler. Those two facts cannot both be true of the same object, so they are not the same object: Ava3DView walks the tree once per frame and produces a flat, immutable snapshot — a draw list, with every transform already accumulated to world space.
This is why you can add a hundred nodes in a loop without a lock, without a "begin update" call, and without ever seeing a half-drawn frame. It is also why the renderer never holds a reference to anything you can still change: by the time it draws, what it has is a copy of a moment.
The cost is one tree walk per frame, which is not the expensive part of drawing a scene. The benefit is that no rule about which thread may touch what ever reaches you.
What survives between frames
A snapshot per frame would be pointless if it meant re-uploading geometry per frame, so the parts that are expensive are keyed on identity rather than on content:
-
Meshes are cached against the Mesh instance. The demo's
stress scene puts one sphere on 125 nodes: one vertex buffer, 125 draw calls. Reuse the same instance and
you get that automatically; build a new
Meshevery frame and you will pay for it every frame. That is whyMeshexposes its arrays as init-only: nothing can swap one out from under a buffer, so the length the buffer was sized for is the length it still is. The contents can change — write into them and call InvalidateGeometry(), and the cache refills the buffers it already has rather than making new ones. That is one upload and no allocation, which is the difference between animated geometry and a newMeshevery frame. - Textures decode lazily and upload a couple per frame. A Texture holds encoded bytes until a backend actually needs pixels. A scene with forty maps appears on the first frame and sharpens over the next few, instead of allocating a hundred megabytes of RGBA before anything is drawn. On a 32-bit WebAssembly heap this is the difference between working and not.
Why it composites like a control and not a hole
The obvious way to put 3D in a window is a native child surface. It is also the way that stops the result from being a control: a child surface does not clip to a rounded corner, does not fade with its parent's opacity, does not rotate, and lands on top of your UI whatever the z-order says.
So the backend never draws to the window. It draws into its own framebuffer and returns an image, and
Avalonia composites that image exactly as it would a bitmap. Put the view inside a
Border with a corner radius, animate its opacity, overlay a panel — all of it behaves, because
by the time the compositor sees the 3D it is already just pixels.
There is one rule this creates, and it is worth knowing because the error message is cryptic: while the
platform graphics API is leased, Skia's own canvas must not be touched. Avalonia throws
"the underlying graphics API is currently leased" if it is. The GPU backends therefore hand back an
SKImage and let the caller composite it, rather than drawing directly. The CPU backend, which
never takes a lease, is free to draw straight onto the canvas — which is why the two paths look different
inside and identical from outside.
The first frame, a ready scene, and starting over
Nothing is drawn until the compositor asks, and the moment it first does is the moment a placeholder can
come down: Ava3DView raises FirstFrameRendered on the UI
thread once per attachment, after the renderer's first frame, whatever that frame showed. Until then the
view is painted in ClearColor — the dark blue-grey it has always been unless you set it, and
the property to set to the colour of whatever the view sits on, so that the moment before the first frame
is not a visible rectangle.
A scene's first frame is not its finished one. Textures upload a couple per frame, so a scene appears and
then sharpens; SceneReady is raised once per assignment of Scene, on the first
frame that drew it with nothing left to upload. It is about the assignment, not the contents: a scene that
moves after it was announced is a ready scene that moves.
The renderer notices when its device goes — a Vulkan device lost, an OpenGL context that Avalonia or the
driver reports reset, a Metal command buffer that finished with a device error — and builds itself again
on the next frame, saying why in RenderInfo. What it cannot notice is
a loss only the host is told about, and that is the browser's: WebGL reports webglcontextlost
and webglcontextrestored to the page, and after the restore the same context is usable again
with everything in it gone. ResetRenderer() is the call for that handler, and for any other
moment a host knows more than the control does.
Hidden, detached, and what wakes an idle view
A view that cannot be seen — collapsed, on a hidden tab, under IsVisible="False" anywhere
above it — stops its frame loop. Animators and texture programs pause with it, the renderer keeps the last
frame it drew, and the loop starts again the moment the view can be seen, advancing by one frame rather
than by the whole time it was hidden. A view detached from the visual tree loses its renderer and the
frame with it; attached again, it renders afresh and raises FirstFrameRendered once more.
Under RenderTrigger.OnDemand, three things wake a quiet view, and each ends as a message to
the renderer: an animator whose pose changed this frame, a TextureProgram that evaluated,
and a message sent directly — InvalidateCamera, InvalidateScene, assigning
Scene, or any property that changes how the scene is drawn. A clip that is playing wakes
the view every frame; a clip holding its last pose does not.
A repaint the compositor starts on its own — a control over the view changing, the window's chrome
redrawing — is not a wake. It is answered with the frame already rendered, at whatever
MaxFrameRate allows, and RenderInfo.FramesReused counts it. The retained frame
is dropped when the view is resized, detached or handed to another renderer, and the next repaint
renders; a hole is never the answer.
Capturing a frame
A frame lives in the renderer's own target, on the render thread, and the way to get one is to ask for
it: CaptureAsync queues a CaptureRequest for the
next frame the view renders and completes with a CapturedFrame —
the pixels, straight alpha, rows top to bottom, in the format you asked for, plus the frame's number and
the camera it was drawn from. The defaults describe the screen: the view's size, its camera, and the
picture as shown.
using var frame = await view.CaptureAsync(new CaptureRequest());
await frame.SaveAsync("frame.png"); // encoded and written on the thread pool
using var poster = await view.CaptureAsync(new CaptureRequest
{
Scale = 2, // four times the pixels, rendered, not enlarged
Format = CaptureFormat.Png
});
As shown means after bloom and the vignette. Those are drawn over the frame on the way to the
screen, in a surface of the frame's own size, and a capture copies that surface — so what you get is what
you see, including on the software renderer, where the composite now runs too. Ask for
Composited = false for the renderer's frame before either. Under
Scene.LinearHdr the two are one picture, because that pipeline applies its bloom before
display encoding; there, and only there, CaptureFormat.RgbaF16 gives you the linear frame
as half floats.
A request for another size, another scale or another camera is its own frame: the scene is
rendered once more for it, into the renderer's target, before the frame for the screen — so the view
never changes size or picture for a capture, and RenderInfo.Size stays what it was. Several
requests queued before one frame all come out of it, each with its own task; the queue holds
CaptureQueueCapacity of them, eight by default, and one more throws
CaptureQueueFullException from the call rather than
dropping anything. Leaving the tree, or your token, completes what is pending as cancelled.
The readback is where the platforms differ, and only there. Metal blits the surface into a shared buffer and OpenGL reads it into a pixel buffer behind a fence; neither waits for the GPU, and the pixels are collected on a later frame. Vulkan and the software renderer read synchronously through Skia. On all of them the unpremultiplying, the row order and any PNG encoding happen on the thread pool, and the task completes on the thread that asked — never inside a frame.
A capture is the view and nothing else. The Avalonia content over it — an
Ava3DOverlay, a caption, a timecode — is not in the renderer's
target, and a RenderTargetBitmap of the tree cannot see the renderer's target either, because
that picture belongs to the compositor. ComposeAsync(root) joins the two: it captures the
frame, then renders root through a bitmap while the view draws that frame in place of its
live picture, so overlays and 3D come out together from one frame.
using var still = await view.ComposeAsync(root: viewportWithOverlays);
still.Save("still.png");
The static FrameCapture — a path, a frame number and a process-wide event — is served through
this path now and marked obsolete for one release. It captures the picture as shown, which it did not
use to, is taken by whichever view renders first after it is armed, and raises Captured
from the thread pool once the file is written. Arming it twice before the first frame lands still
replaces the first request silently; that was the reason for the queue.
Rendering without a window
SceneRenderer draws the same pixels with no view and no window at
all. Create makes a device and a render thread of its own, and RenderAsync hands
back a CapturedFrame — a catalogue thumbnail, a picture for a test or a build server, or a
frame from a command line, anywhere there is nothing on screen to capture from. Which device it gets
depends on the platform: Metal's system default device on macOS and iOS, Vulkan through MoltenVK there or
the system loader on Windows and Linux, and the CPU renderer wherever neither can be made, which is always
the case in a browser. Kind says which one actually answered and Description
names the card — never an exception for want of a GPU. On Metal that device is the one every window on a
one-GPU Mac already draws with, and on the CPU it is the one software registry every view in the process
shares, so a texture this renderer uploads for a thumbnail is already resident for the view that shows the
model next, and the other way round. A Vulkan or OpenGL device made this way is its own, sharing nothing
with anything else. Ask for less than the platform's best
RendererPreference allows and you still get an answer: a
device that cannot be made falls through to the next, down to the CPU renderer, which draws with the same
limits OfflineRenderer has always had.
What this means when you use it
- Mutate freely from the UI thread. There is no lock and no update scope.
- Keep your
Meshinstances. Sharing one is how instancing happens here. - Do not expect to reach the GPU. There is no device, context or command buffer in the public API, because on one of the supported platforms there is no such object to hand you.
- Read RenderInfo rather than assuming. It reports what actually happened this frame, including which renderer you ended up with.