[Feature] DrawingRecording - #22061
Conversation
Avalonia has no cheap retained-drawing primitive. DrawingGroup re-walks a managed object tree on every draw, and a RenderTargetBitmap is rasterized at a fixed resolution and stops being vector content. Anything that draws the same vector content repeatedly - an icon set, a symbol reused many times in one document, a vector image drawn at several sizes - pays full re-emission every frame, and a recorded drawing cannot be handed to the compositor at all. - DrawingRecording records draw operations once and replays them through DrawingContext.DrawRecording. The immutable form snapshots its brushes, pens and effects at record time; the compositor-bound form registers mutable resources instead, so animating a brush needs no re-recording. - Nested recordings are referenced and ref-counted rather than copied, and DrawingRecordingOwnership lets a parent take over a child's disposal. - Render data nodes gained a client/server bounds split, so an animated pen keeps server-side bounds current without re-recording. - DrawingRecordingBrush paints a pre-recorded drawing as a tile brush. - CompositionRecordingVisual (via Compositor.CreateRecordingVisual) renders a compositor-bound recording behind its children, so retained content animates entirely on the render thread. - ICompositionImage lets an IImage opt into that hosting; CompositionImageHost and Image wire it up and fall back to IImage.Draw for other sources. CompositionVisual.ClipToBounds now defaults to false, matching Visual.ClipToBounds and UWP composition semantics. The previous true default combined with the (0,0) Size default gave every directly created visual an empty own-clip rect, which nulled its subtree bounds and made the render pass cull it and its children, so a visual attached with SetElementChildVisual never rendered unless Size was set explicitly.
The invalidation chain needs no new mechanism: the recording server render data observes every server resource in its stream, and the recording visual already observes the render data, so a composition brush change repaints the visuals showing the recording just like a tracked mutable Media brush. The suite pins that chain end to end: client-driven and render-thread-driven changes, an animated gradient stop, a nested recording, a brush on a pen, and DrawingRecordingBrush content, plus bounds stability and disposal ordering. Immutable recordings keep rejecting composition brushes; the message now points at the compositor-bound Create overload instead of suggesting an immutable brush that cannot exist for one.
The centre disc is filled with a CompositionLinearGradientBrush whose stop colors cross-fade via render-thread keyframe animations while the ring spins: the recording is captured once and repaints through the brush observer chain, with no UI-thread work and no re-recording. Composition objects have no public dispose, so the brush and stops are created once per compositor and reused across attach cycles, stopping their animations while detached.
Conflict resolution: CompositionRenderData needs both sides' usings (our Render(IDrawingContextImpl) plus upstream's HitTest(Geometry) from the geometry hit-testing feature), and OnDrawRecording in the hit-test visitor is adapted to the reworked visitor state, where the unconditional Current point became the nullable CurrentPoint.
Upstream's geometry hit testing walks the same visitor as point hit testing, but a recording op only answered for points, and the visual override fell back to the base's blanket Intersects. - OnDrawRecording recurses into the child recording for geometry queries, transforming the query by the op's inverse matrix; the child's Empty does not stop the walk, since later ops in the outer stream can still intersect - DrawingRecording.HitTest(Geometry) mirrors the point overload (existing HitTest(default) call sites disambiguate to Point) - CompositionRecordingVisual answers geometry queries from its recording instead of the base default - CompositorBoundDrawingRecordingTests constructs its services in the ctor: a field initializer runs before ScopedTestBase replaces the locator, discarding the registrations geometries resolve against
Release turns warnings into errors, and CI compiles projects the local Debug gates skipped: - DrawingRecordingBenchmarks used PushLayer, which does not exist at this point in the stack; the layered replay benchmark now records an opacity layer via PushOpacity - the server-bounds thread-affinity test blocked on a task (xUnit1031); it now evaluates bounds on a dedicated joined thread, which also keeps the test body on the dispatcher thread for the trailing disposals - an awaited Task.Run resumes on a pool thread and trips VerifyAccess there
|
You can test this PR using the following package version. |
|
Not to get hung up on the naming right away but Why not something like:
|
We record a drawing, so I chose that name. Look at the family of classes that are related to that type: DrawingRecordingBrush, CompositionRecordingVisual, CreateRecordingVisual, DrawRecording, DrawingRecordingOwnership. Would your suggestion fit here? The term Drawing is already part of the framework. |
|
I'll compare names when I have a chance. But it looks like this concept already exists in DirectX 11+ as well:
Anyway, I'll try to look at this more soon. |
What does the pull request do?
Adds
DrawingRecording: a retained draw list that is recorded once and replayed cheaply, optionally bound to a compositor so it animates without being re-recorded, and optionally hosted as a composition visual so it animates entirely on the render thread.Avalonia has no cheap retained-drawing primitive today.
DrawingGroupre-walks a managed object tree on every draw, and aRenderTargetBitmapis rasterized at a fixed resolution and stops being vector content. Anything that draws the same vector content repeatedly - an icon set, a symbol reused many times in one document, a vector image drawn at several sizes, an animated vector document - pays full re-emission on every frame, and there is no way to hand a recorded drawing to the compositor at all.Alongside the recording primitive:
DrawingRecordingBrush, aTileBrushthat paints with an existing recording instead of re-recording aDrawingper compositor, the wayDrawingBrushdoes.CompositionSolidColorBrush, the composition gradient brushes) work inside a compositor-bound recording, so a recorded fill can be animated from the render thread.CompositionRecordingVisual, so a recording can be attached to a control as a child visual and animated server-side.What drives it is SVG support: a document compiles once into a recording, replays shared sub-recordings at every
usesite, and animates paint through mutable brushes and structure through composition visuals. COLRv1 color-font rendering wants the same shape, a glyph's paint graph recorded once and replayed at every instance.None of the API is specific to either. The recording primitive serves icon systems, chart and diagram renderers, and any control that draws the same vector content repeatedly.
What is the updated/expected behavior with this PR?
Record once and replay, with no re-emission per draw:
Bind it to a compositor and mutable brushes animate without re-recording:
Paint an area with a recording, tiled by the usual
TileBrushproperties:Host it as a composition visual and it animates on the render thread, with no per-frame UI-thread work:
How was the solution implemented (if it's not obvious)?
Recording.
DrawingRecording.Create(...)runs the caller's callback againstRenderDataDrawingContext, aDrawingContextthat records draw operations as nodes instead of rasterizing them. The immutable overload snapshots brushes, pens and effects at record time and rejects content it cannot snapshot; the compositor-bound overload registers mutable resources as composition resources instead, so later mutation is change-tracked. Nested recordings are referenced and ref-counted rather than copied, andDrawingRecordingOwnershiplets a parent recording take over disposal of a child.Replay and the client/server bounds split. Each node exposes both a client
Bounds(UI thread, may read live mutable resources) and aServerBounds(render thread, immutable data and server shadows only). That split is what lets an animated pen thickness keep the server-side bounds current without re-recording anything.Composition brushes need no new invalidation mechanism. A recording's server render data already observes every server resource in its stream, and a recording visual already observes that render data, so a composition brush change repaints the visuals showing the recording exactly like a tracked mutable
Mediabrush does. Immutable recordings keep rejecting composition brushes, since no immutable form of one exists; the exception now points at the compositor-boundCreateoverload.Composition hosting.
Compositor.CreateRecordingVisual()produces aCompositionRecordingVisual, which renders one compositor-bound recording behind its children. It serializes a reference to the recording's server render data rather than the draw items, and the server visual observes that render data instead of owning it. Combined with the animatable visual properties this moves retained content entirely onto the render thread. The internalImage-side hosting that lets anIImagerender as a composition visual subtree ships as its own follow-up PR.Checklist
Breaking changes
One, needing a core-team call given the API freeze:
CompositionVisual.ClipToBoundsnow defaults tofalse(wastrue), matchingVisual.ClipToBoundsand UWP composition semantics. The renderer sets it explicitly on element visuals every sync, so element visuals are unaffected. The old default combined with the(0,0)Sizedefault gave every directly created visual an empty own-clip rect, which nulled its subtree bounds and made the render pass cull it and its children - a visual attached withElementComposition.SetElementChildVisualnever rendered unlessSizewas set explicitly.Two smaller behavioral changes, both turning a failure into the useful result:
BrushExtensions.ToImmutablehandles scene brushes.Brushimplements neitherIMutableBrushnorIImmutableBrush, so the old body's(IImmutableBrush)brushcast threwInvalidCastExceptionforVisualBrushandDrawingBrush. It now returns an immutable snapshot of the scene brush's current content, with tile-brush properties captured at call time, orBrushes.Transparentwhen the brush has no content.ElementComposition.SetElementChildVisualun-parents the previous child eagerly. The renderer sync that would reconcile children may never run again for that element - clearing the child visual fromOnDetachedFromVisualTreeruns beforeDetachFromCompositordiscards the element's composition visual - and the stale parenting made any later attach throw.No backend has to change: nothing is added to
IDrawingContextImpl, and replay goes through the existing operations. (Backend interfaces are[Unstable]and not stable API, so this is a note about the work being self-contained, not about avoiding a break.)Obsoletions / Deprecations
None.
Fixed issues