Documentation · Concepts

Effects that are not in the scene

A caption, a reticle, a nameplate over a ship, scanlines on the glass. None of them is a triangle, all of them belong to the picture, and there are three different right answers depending on whether the thing is over the picture, attached to something in it, or actually in it. Choosing wrongly is where the awkward work comes from.

Three places a thing can be drawn

The three layers a two-dimensional thing can live in Two bands. The upper band is Avalonia's: a sibling control placed by layout, and an Ava3DOverlay whose children are placed by a world position. Both draw over the picture, keep text at the device's own resolution, and keep layout, bindings and input. Below a dashed line is the control's own surface, where a LabelNode and ordinary geometry are drawn by the renderer — occluded by what is in front of them, shrinking with distance, and the only two that appear in a frame grab. Over the picture — Avalonia draws it A sibling control Placed by Avalonia's layout. Ava3DOverlay Placed by a world position. Text at the device's own resolution. Layout, bindings, input, styling. Composites on top because the picture is a child visual, not a hole. the control's own surface begins here In the picture — the renderer draws it LabelNode A string on a camera-facing quad. Geometry Meshes and lines, held near the lens. Occluded by what is in front. Shrinks with distance. Lit. Drawn on every backend by the same code. a frame grab sees only this half

The dashed line is the only thing that really matters. Above it, Avalonia is drawing and the thing is over the picture. Below it, the renderer is drawing and the thing is part of it.

Over the picture

Put an ordinary control in the same cell as the view, after it. That is the whole technique, and it works because Ava3DView never takes a native surface: the picture arrives as an image the compositor draws like any other, so a later sibling composites over it exactly as it would over a Border.

<Grid>
    <a3d:Ava3DView x:Name="View" Scene="{Binding Scene}" />

    <Border Classes="hud" VerticalAlignment="Top" HorizontalAlignment="Left">
        <TextBlock Text="{Binding Range}" />
    </Border>
</Grid>

This is the right answer for anything the scene does not own: a toolbar, a readout, a settings panel, a caption band, a HUD. It keeps text as text at the device's real resolution, and it keeps layout, hit-testing, input routing, bindings, styling and accessibility — all of which a renderer would have to reimplement badly to take the work off you.

Do not try to draw it from inside the control. Subclassing and overriding Render looks like the direct route and paints underneath the picture rather than over it: the view's own draw list holds one transparent rectangle, and the picture is a composition visual attached as a child of it. Anything you draw there is behind an opaque surface, forever.

Two habits are worth copying from the demo. Give the panel a colour of its own — it sits over a scene that is mostly black, and a control that inherits a theme background will be legible in the designer and invisible in the dark. And drive anything that animates from the same clock as the scene, not from a timer of its own. A caption that fades on a DispatcherTimer while the picture moves on the compositor's clock drifts against it, and a viewer notices without being able to say why.

Anchored to something in the scene

A nameplate over a ship, a callout on a part, a marker on a waypoint. The 2D still belongs to Avalonia — you still want real text and real bindings — but where it goes is decided by the scene. Ava3DOverlay is a panel that places ordinary children at world positions:

<Grid>
    <a3d:Ava3DView x:Name="View" Scene="{Binding Scene}" />

    <a3d:Ava3DOverlay View="{Binding #View}">
        <Border Classes="contact"
                a3d:Ava3DOverlay.WorldPosition="{Binding Target.Position}"
                a3d:Ava3DOverlay.Anchor="Bottom"
                a3d:Ava3DOverlay.PixelOffset="0,-12"
                a3d:Ava3DOverlay.OffScreen="ClampToEdge"
                a3d:Ava3DOverlay.Occlusion="FadeWhenHidden">
            <TextBlock Text="{Binding Target.Name}" />
        </Border>
    </a3d:Ava3DOverlay>
</Grid>

Anchor names the part of the child that sits on the point, so Bottom means the child stands on it and Top means it hangs from it. OffScreen decides what happens when the thing drifts out of shot — ClampToEdge is the waypoint marker that slides to the border still pointing the right way. Occlusion hides or dims a child when the scene is in front of it, using the same triangle-accurate test a click uses; it is off by default because it costs a ray per child, and Precision="BoundingBoxes" is the cheaper half of it.

Underneath is one method you can use on its own: Project turns a world position into a point on the control.

Vector3 p = View.Project(worldPosition);

// p.X, p.Y — the control's own coordinates, the same ones Pick takes
// p.Z     — world units from the near plane

if (p.Z > 0)
    Canvas.SetLeft(marker, p.X);

Test Z before using X and Y. A position behind the camera does not project to a wild number that you would notice — it projects mirrored, landing inside the picture on the opposite side and looking entirely plausible. That is the one bug every implementation of this ships first, and it is why Z is a signed distance rather than the method returning null: a marker being clamped to an edge still needs the direction.

The overlay re-places its children when the camera moves, when the control resizes, and when one of the attached properties changes — not on a timer and not every frame. Since InvalidateCamera is already what a hand-moved camera must call for the picture to follow it, the markers and the picture stay in step by construction, and a view that is idle under RenderTrigger.OnDemand costs its overlay nothing.

Leave the overlay without a Background, and know where a press goes. A panel with no background is not hit-testable at all, which is what lets a press anywhere except on one of its children fall straight through to the view and orbit, pan and pick as though the overlay were not there. Give it a background — even Transparent — and the viewport stops responding to the mouse, with nothing on screen to say why.

A press that lands on a child goes to the child and does not also reach the view. That is not about anything being marked handled: the view is the overlay's sibling, and an unhandled pointer event travels up a tree rather than across it. It is the right behaviour for a button on a HUD and the wrong one for a decorative plate, and the plate says so the framework's way, with IsHitTestVisible="False".

In the scene

Some labels are not over the picture; they are furniture in the room. A sign on a bulkhead, a plaque on a plinth, a designation painted on a hull. Those should be hidden by the wall in front of them, should shrink as you walk away, and should be there when the picture is photographed — LabelNode is a camera-facing quad whose texture is a string:

room.Children.Add(new LabelNode
{
    Text = "DECK 3 — MEDICAL",
    Options = new TextOptions { Size = 48, Outline = 3, Typeface = face },
    Height = 0.12f,                      // world units — it recedes like everything else
    Position = new Vector3(0f, 2.1f, -4f)
});

Height is the size in the world; TextOptions.Size is how much detail is in the image. The width follows from what the text actually measured, so a longer string is a wider plate rather than squashed letters — which is the reason the measuring is in the library rather than left to you. Texture.FromText is the same call on its own, for putting words on a material's map.

One caveat, and it is the only one of its kind in this library. With TextOptions.Typeface left null the glyphs come from whatever face the platform offers, which on a control that runs the same code on desktop, in a browser, on Android and on iOS is four different answers. Hand it the face's own bytes and the picture is the same everywhere. It also means a capture comparison across machines must not include platform-font text — it would be comparing font stacks rather than renderers.

For anything more than a line of text, geometry is still the answer: a title card is a quad and a set of line segments held a finger's width in front of the lens, turned to face it. That costs no font and no atlas, it fades on the same clock as the picture because it is the picture, and — like LabelNode and unlike everything above the dashed line — a frame grab can see it.

Choosing between the three

A sibling control Anything the scene does not own: panels, readouts, toolbars, captions, a whole HUD. Sharpest text, full framework. Cannot be occluded, does not move with the scene, invisible to a capture of the view alone.
Ava3DOverlay 2D that belongs to something in the scene but should stay readable at any distance: nameplates, callouts, target reticles, waypoint markers. Same text quality and same framework as above, plus occlusion and edge-clamping. Still invisible to a capture of the view alone.
LabelNode and geometry Things that are part of the place: signage, plaques, hull markings, title cards. Occluded, lit and perspective-correct, identical on all four renderers, and the only option a capture of the view alone records. Text is resampled by perspective, so it is never as crisp as the two above.

The last column is worth restating, because it surprises people building a demo reel or a regression test. CaptureAsync — and the demo's AVA3D_CAPTURE switch, and the obsolete FrameCapture behind it — photographs the control's own picture, bloom and vignette included and nothing composited over it. For the two rows above as well, ask for ComposeAsync(root) instead: it captures the frame and then renders the tree over it, so a caption or a nameplate is in the picture exactly as it was on screen — see capturing a frame. A recording that wants the sharpest possible caption can still burn one in afterwards from a caption track.

Effects over the whole picture

Scanlines, a sweep, film grain, a vignette, chromatic aberration, a tear. All of these are one 2D pass over a finished image, and Avalonia's own drawing is the right place for them: it is one implementation instead of three shader dialects and a software rasteriser, it composites on the same surface with no intermediate bitmap, and it can be animated from the same clock as the scene.

Two are already inside the control, because they need the frame before it reaches you: BloomThreshold, BloomIntensity and BloomRadius bloom what is bright on screen, and Vignette darkens the corners. Both run where the finished image is handed to Skia, which is the one place in the frame there is exactly one of.

What genuinely cannot be done from outside is anything that needs the scene's own depth or colour buffer — depth of field, ambient occlusion, screen-space reflection. Those need a second pass on every backend, which this control does not have. A refraction faked as a distortion of a material's own map is a material question and is answerable; a refraction of whatever happens to be behind the surface is not.