Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions samples/RenderDemo/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,11 @@
<TabItem Header="Resize Pattern">
<pages:ResizePatternPage />
</TabItem>
<TabItem Header="DrawingRecording">
<pages:DrawingRecordingPage />
</TabItem>
<TabItem Header="RecordingComposition">
<pages:RecordingCompositionPage />
</TabItem>
</controls:HamburgerMenu>
</Window>
150 changes: 150 additions & 0 deletions samples/RenderDemo/Pages/DrawingRecordingPage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Immutable;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;

namespace RenderDemo.Pages
{
public class DrawingRecordingPage : Control
{
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private DrawingRecording? _recording;
private Compositor? _compositor;
private SolidColorBrush? _animatedBrush;

public DrawingRecordingPage()
{
ClipToBounds = true;
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_compositor = ElementComposition.GetElementVisual(this)?.Compositor;
Dispatcher.UIThread.InvokeAsync(AnimationTick, DispatcherPriority.Background);
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_recording?.Dispose();
_recording = null;
_compositor = null;
}

private void AnimationTick()
{
if (_compositor == null)
return;

var t = _stopwatch.Elapsed.TotalSeconds;

// Animate the brush color through the spectrum
var r = (byte)(Math.Sin(t * 0.7) * 127 + 128);
var g = (byte)(Math.Sin(t * 0.7 + 2.094) * 127 + 128);
var b = (byte)(Math.Sin(t * 0.7 + 4.189) * 127 + 128);

if (_animatedBrush != null)
{
// Update existing brush — change propagates through compositor automatically
_animatedBrush.Color = Color.FromRgb(r, g, b);
}

InvalidateVisual();
Dispatcher.UIThread.InvokeAsync(AnimationTick, DispatcherPriority.Background);
}

public override void Render(DrawingContext context)
{
base.Render(context);

if (_compositor == null)
return;

var w = Bounds.Width;
var h = Bounds.Height;
if (w <= 0 || h <= 0)
return;

// Draw label
var labelY = 10.0;

// === Left side: Immutable recording (recreated each frame with new colors) ===
var t = _stopwatch.Elapsed.TotalSeconds;
var immutableRecording = DrawingRecording.Create(ctx =>
{
var size = Math.Min(w / 2 - 40, h - 80) / 4;
var cx = w / 4;
var cy = h / 2;

for (int i = 0; i < 6; i++)
{
var angle = t * 0.5 + i * Math.PI / 3;
var x = cx + Math.Cos(angle) * size * 1.5 - size / 2;
var y = cy + Math.Sin(angle) * size * 1.5 - size / 2;

var cr = (byte)(Math.Sin(t + i * 1.0) * 127 + 128);
var cg = (byte)(Math.Sin(t + i * 1.0 + 2.094) * 127 + 128);
var cb = (byte)(Math.Sin(t + i * 1.0 + 4.189) * 127 + 128);

ctx.DrawRectangle(
new ImmutableSolidColorBrush(Color.FromArgb(180, cr, cg, cb)),
null,
new Rect(x, y, size, size));
}
});

using (immutableRecording)
{
context.DrawRecording(immutableRecording);
}

// === Right side: Compositor-bound recording (brush animates via compositor) ===
if (_recording == null)
{
_animatedBrush = new SolidColorBrush(Colors.Red);

_recording = DrawingRecording.Create(_compositor, ctx =>
{
var size = Math.Min(w / 2 - 40, h - 80) / 4;
var cx = w * 3 / 4;
var cy = h / 2;

// Central circle with animated brush
ctx.DrawEllipse(_animatedBrush, null, new Rect(cx - size, cy - size, size * 2, size * 2));

// Static surrounding shapes
for (int i = 0; i < 8; i++)
{
var angle = i * Math.PI / 4;
var x = cx + Math.Cos(angle) * size * 2 - size / 4;
var y = cy + Math.Sin(angle) * size * 2 - size / 4;

ctx.DrawRectangle(
new ImmutableSolidColorBrush(Color.FromArgb(120, 100, 100, 100)),
new ImmutablePen(Brushes.White, 1),
new Rect(x, y, size / 2, size / 2));
}
});
}

context.DrawRecording(_recording);

// Draw labels
var immutableLabel = "Immutable (recreated each frame)";
var compositorLabel = "Compositor-bound (brush animates)";

var ft1 = new FormattedText(immutableLabel, System.Globalization.CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, Typeface.Default, 14, Brushes.White);
var ft2 = new FormattedText(compositorLabel, System.Globalization.CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, Typeface.Default, 14, Brushes.White);

context.DrawText(ft1, new Point(w / 4 - ft1.Width / 2, labelY));
context.DrawText(ft2, new Point(w * 3 / 4 - ft2.Width / 2, labelY));
}
}
}
155 changes: 155 additions & 0 deletions samples/RenderDemo/Pages/RecordingCompositionPage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Immutable;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Animations;

namespace RenderDemo.Pages
{
/// <summary>
/// Hosts a compositor-bound <see cref="DrawingRecording"/> as a
/// <see cref="CompositionRecordingVisual"/> child visual that spins entirely on
/// the render thread. The drawing is recorded once and the rotation is sent to
/// the compositor once; after that the UI thread does no per-frame work — the
/// server evaluates the animation every frame. The centre disc is filled with a
/// composition gradient whose stop colors animate on the render thread too:
/// the recording repaints because its render data observes the brush, without
/// any re-recording.
///
/// Everything here is wired by hand from the public primitives so the moving
/// parts are visible. Contrast the neighbouring DrawingRecording page, which
/// replays a recording through DrawingContext.DrawRecording every frame.
/// </summary>
public class RecordingCompositionPage : Control
{
// The drawing is authored around this point; the visual rotates about it.
private const double Center = 150;

private DrawingRecording? _recording;
private CompositionRecordingVisual? _visual;
private CompositionLinearGradientBrush? _gradient;
private CompositionGradientStop? _stopA;
private CompositionGradientStop? _stopB;

public RecordingCompositionPage()
{
ClipToBounds = true;
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);

var compositor = ElementComposition.GetElementVisual(this)?.Compositor;
if (compositor is null || _visual?.Compositor == compositor)
return;

// A composition brush is captured by reference and read live at
// replay time, so its animated values repaint the recording with no
// UI-thread work and no re-recording - the recording's server render
// data observes the brush. The stop colors cross-fade on the render
// thread while the ring spins. Composition objects have no public
// dispose, so the brush and its stops are created once per
// compositor and reused across attach cycles.
if (_gradient is null || _gradient.Compositor != compositor)
{
_stopA = compositor.CreateGradientStop(0, Color.FromRgb(244, 114, 182));
_stopB = compositor.CreateGradientStop(1, Color.FromRgb(37, 99, 235));
_gradient = compositor.CreateLinearGradientBrush();
_gradient.StartPoint = RelativePoint.TopLeft;
_gradient.EndPoint = RelativePoint.BottomRight;
_gradient.GradientStops.Add(_stopA);
_gradient.GradientStops.Add(_stopB);
}

var cycleA = compositor.CreateColorKeyFrameAnimation();
cycleA.InsertKeyFrame(0f, Color.FromRgb(244, 114, 182));
cycleA.InsertKeyFrame(0.5f, Color.FromRgb(37, 99, 235));
cycleA.InsertKeyFrame(1f, Color.FromRgb(244, 114, 182));
cycleA.Duration = TimeSpan.FromSeconds(6);
cycleA.IterationBehavior = AnimationIterationBehavior.Forever;
_stopA!.StartAnimation("Color", cycleA);

var cycleB = compositor.CreateColorKeyFrameAnimation();
cycleB.InsertKeyFrame(0f, Color.FromRgb(37, 99, 235));
cycleB.InsertKeyFrame(0.5f, Color.FromRgb(244, 114, 182));
cycleB.InsertKeyFrame(1f, Color.FromRgb(37, 99, 235));
cycleB.Duration = TimeSpan.FromSeconds(6);
cycleB.IterationBehavior = AnimationIterationBehavior.Forever;
_stopB!.StartAnimation("Color", cycleB);

// Record the drawing once. Compositor-bound so the recording can be
// carried to the render thread by reference and hosted by a visual.
_recording = DrawingRecording.Create(compositor, ctx =>
{
ctx.DrawEllipse(_gradient, null,
new Rect(Center - 60, Center - 60, 120, 120));

for (var i = 0; i < 8; i++)
{
var angle = i * Math.PI / 4;
var x = Center + Math.Cos(angle) * 90;
var y = Center + Math.Sin(angle) * 90;
var t = (byte)(i * 30);

ctx.DrawRectangle(
new ImmutableSolidColorBrush(Color.FromRgb(t, (byte)(255 - t), 200)),
new ImmutablePen(Brushes.White, 2),
new Rect(x - 22, y - 22, 44, 44));
}
});

_visual = compositor.CreateRecordingVisual();
_visual.Recording = _recording;
_visual.CenterPoint = new((float)Center, (float)Center, 0f);
UpdateOffset();

// Parent it under this control's own composition visual. The recording
// renders behind any children (there are none here).
ElementComposition.SetElementChildVisual(this, _visual);

// One animation, sent once. RotationAngle is a render-thread-animated
// property, so the spin runs on the compositor with no UI-thread ticks.
var spin = compositor.CreateScalarKeyFrameAnimation();
spin.InsertKeyFrame(0f, 0f);
spin.InsertKeyFrame(1f, (float)(Math.PI * 2));
spin.Duration = TimeSpan.FromSeconds(8);
spin.IterationBehavior = AnimationIterationBehavior.Forever;
_visual.StartAnimation("RotationAngle", spin);
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);

ElementComposition.SetElementChildVisual(this, null);
_visual = null;
_recording?.Dispose();
_recording = null;
// The brush outlives the recording (no public dispose); just stop
// its animations while nothing displays it.
_stopA?.StopAnimation("Color");
_stopB?.StopAnimation("Color");
}

protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == BoundsProperty)
UpdateOffset();
}

// Centre the authored drawing within the page. Offset is a static translate
// that composes with the animated rotation.
private void UpdateOffset()
{
if (_visual != null)
_visual.Offset = new(
(float)(Bounds.Width / 2 - Center),
(float)(Bounds.Height / 2 - Center),
0f);
}
}
}
17 changes: 14 additions & 3 deletions src/Avalonia.Base/Media/BrushExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using Avalonia.Media.Immutable;
using Avalonia.Rendering.Composition.Drawing;

namespace Avalonia.Media
{
Expand All @@ -13,14 +14,24 @@ public static class BrushExtensions
/// </summary>
/// <param name="brush">The brush.</param>
/// <returns>
/// The result of calling <see cref="IMutableBrush.ToImmutable"/> if the brush is mutable,
/// otherwise <paramref name="brush"/>.
/// The result of calling <see cref="IMutableBrush.ToImmutable"/> if the brush is mutable;
/// for an <see cref="ISceneBrush"/> (<see cref="VisualBrush"/>, <see cref="DrawingBrush"/>,
/// <see cref="DrawingRecordingBrush"/>, …) an immutable snapshot of the brush's current
/// content with the tile-brush properties captured at call time (a transparent brush when
/// the scene brush has no content); otherwise <paramref name="brush"/> itself.
/// </returns>
public static IImmutableBrush ToImmutable(this IBrush brush)
{
_ = brush ?? throw new ArgumentNullException(nameof(brush));

return (brush as IMutableBrush)?.ToImmutable() ?? (IImmutableBrush)brush;
return brush switch
{
ISceneBrush scene => scene.CreateContent() is { } content
? new EmbeddedSceneBrushContent(content)
: (IImmutableBrush)Brushes.Transparent,
IMutableBrush mutable => mutable.ToImmutable(),
_ => (IImmutableBrush)brush
};
}

/// <summary>
Expand Down
Loading