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

How one frame travels from your scene graph to the screen On the UI thread, your Scene of Nodes is collected by Ava3DView into one immutable SceneSnapshot. That snapshot is posted across a thread boundary to the compositor, where SceneVisualHandler leases the platform graphics API, a backend — Metal, OpenGL, WebGL 2 or Skia on the CPU — draws it, and Avalonia composites the resulting image like any other control. UI thread — yours Scene · Node · MeshNode · Material Mutable. Add, move and edit whenever you like. every change asks for a new frame Ava3DView Walks the tree once. Produces one immutable snapshot. handed over — nothing is shared after this line Render thread — the compositor's SceneVisualHandler Holds the snapshot. Leases the platform graphics API. Metal · OpenGL / WebGL 2 · Skia on the CPU Whichever one the lease turned out to offer. an SKImage, not a hole in the window Avalonia composites it Clipping, opacity, z-order and transforms all apply.

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:

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