Browse Source

Don't create a layer if the previous frame is retained by the render target (#14924)

* Don't create a layer if the previous frame is retained by the render target

* D2D

* compile

* Only check PreviousFrameIsRetained if not using layer

* ABI
pull/14938/head
Nikita Tsukanov 3 years ago
committed by GitHub
parent
commit
086fe5267f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      samples/ControlCatalog.NetCore/Program.cs
  2. 27
      src/Avalonia.Base/Platform/IRenderTarget.cs
  3. 30
      src/Avalonia.Base/Platform/RenderTargetProperties.cs
  4. 54
      src/Avalonia.Base/Platform/RetainedFramebuffer.cs
  5. 5
      src/Avalonia.Base/Rendering/Composition/Compositor.cs
  6. 86
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
  7. 4
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs
  8. 2
      src/Avalonia.Controls/Avalonia.Controls.csproj
  9. 42
      src/Avalonia.Controls/Platform/Surfaces/IFramebufferPlatformSurface.cs
  10. 64
      src/Avalonia.X11/X11Framebuffer.cs
  11. 59
      src/Avalonia.X11/X11FramebufferSurface.cs
  12. 8
      src/Avalonia.X11/X11Platform.cs
  13. 2
      src/Avalonia.X11/X11Window.cs
  14. 4
      src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs
  15. 18
      src/Linux/Avalonia.LinuxFramebuffer/Output/FbDevBackBuffer.cs
  16. 28
      src/Linux/Avalonia.LinuxFramebuffer/Output/FbDevOutputOptions.cs
  17. 44
      src/Linux/Avalonia.LinuxFramebuffer/Output/FbdevOutput.cs
  18. 33
      src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs
  19. 1
      src/Windows/Avalonia.Direct2D1/Media/Imaging/WicRenderTargetBitmapImpl.cs
  20. 135
      tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs
  21. 23
      tests/Avalonia.RenderTests/TestBase.cs
  22. BIN
      tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-False_initial.expected.png
  23. BIN
      tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-False_updated.expected.png
  24. BIN
      tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-True_initial.expected.png
  25. BIN
      tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-True_updated.expected.png

8
samples/ControlCatalog.NetCore/Program.cs

@ -9,6 +9,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Fonts.Inter; using Avalonia.Fonts.Inter;
using Avalonia.Headless; using Avalonia.Headless;
using Avalonia.LinuxFramebuffer.Output;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Rendering.Composition; using Avalonia.Rendering.Composition;
using Avalonia.Threading; using Avalonia.Threading;
@ -52,8 +53,11 @@ namespace ControlCatalog.NetCore
} }
if (s_useFramebuffer) if (s_useFramebuffer)
{ {
SilenceConsole(); SilenceConsole();
return builder.StartLinuxFbDev(args, scaling: GetScaling()); return builder.StartLinuxFbDev(args, new FbDevOutputOptions()
{
Scaling = GetScaling()
});
} }
else if (args.Contains("--vnc")) else if (args.Contains("--vnc"))
{ {

27
src/Avalonia.Base/Platform/IRenderTarget.cs

@ -24,4 +24,31 @@ namespace Avalonia.Platform
/// </summary> /// </summary>
public bool IsCorrupted { get; } public bool IsCorrupted { get; }
} }
[PrivateApi]
public interface IRenderTargetWithProperties : IRenderTarget
{
RenderTargetProperties Properties { get; }
/// <summary>
/// Creates an <see cref="IDrawingContextImpl"/> for a rendering session.
/// </summary>
/// <param name="useScaledDrawing">Apply DPI reported by the render target as a hidden transform matrix</param>
/// <param name="properties">Returns various properties about the returned drawing context</param>
IDrawingContextImpl CreateDrawingContext(bool useScaledDrawing, out RenderTargetDrawingContextProperties properties);
}
internal static class RenderTargetExtensions
{
public static IDrawingContextImpl CreateDrawingContextWithProperties(
this IRenderTarget renderTarget,
bool useScaledDrawing,
out RenderTargetDrawingContextProperties properties)
{
if (renderTarget is IRenderTargetWithProperties target)
return target.CreateDrawingContext(useScaledDrawing, out properties);
properties = default;
return renderTarget.CreateDrawingContext(useScaledDrawing);
}
}
} }

30
src/Avalonia.Base/Platform/RenderTargetProperties.cs

@ -0,0 +1,30 @@
using Avalonia.Metadata;
namespace Avalonia.Platform;
[PrivateApi]
public struct RenderTargetProperties
{
/// <summary>
/// Indicates that render target contents are preserved between CreateDrawingContext calls.
/// Notable examples are retained CPU-memory framebuffers and
/// swapchains with DXGI_SWAP_EFFECT_SEQUENTIAL/DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL
/// </summary>
public bool RetainsPreviousFrameContents { get; init; }
/// <summary>
/// Indicates that the render target can be used without CreateLayer
/// It's currently not true for every render target, since with OpenGL rendering we often use
/// framebuffers without a stencil attachment that is required for clipping with Skia
/// </summary>
public bool IsSuitableForDirectRendering { get; init; }
}
[PrivateApi]
public struct RenderTargetDrawingContextProperties
{
/// <summary>
/// Indicates that the drawing context targets a surface that preserved its contents since the previous frame
/// </summary>
public bool PreviousFrameIsRetained { get; init; }
}

54
src/Avalonia.Base/Platform/RetainedFramebuffer.cs

@ -0,0 +1,54 @@
using System;
using System.Runtime.InteropServices;
using Avalonia.Metadata;
using Avalonia.Platform.Internal;
namespace Avalonia.Platform;
internal class RetainedFramebuffer : IDisposable
{
public PixelSize Size { get; }
public int RowBytes { get; }
public PixelFormat Format { get; }
public IntPtr Address => _blob?.Address ?? throw new ObjectDisposedException(nameof(RetainedFramebuffer));
private UnmanagedBlob? _blob;
static PixelFormat ValidateKnownFormat(PixelFormat format) => format.BitsPerPixel % 8 == 0
? format
: throw new ArgumentOutOfRangeException(nameof(format));
public RetainedFramebuffer(PixelSize size, PixelFormat format) : this(size, ValidateKnownFormat(format),
format.BitsPerPixel / 8 * size.Width)
{
}
public RetainedFramebuffer(PixelSize size, PixelFormat format, int rowBytes)
{
if (size.Width <= 0 || size.Height <= 0)
throw new ArgumentOutOfRangeException(nameof(size));
if (size.Width * (format.BitsPerPixel / 8) > rowBytes)
throw new ArgumentOutOfRangeException(nameof(rowBytes));
Size = size;
RowBytes = rowBytes;
Format = format;
_blob = new UnmanagedBlob(RowBytes * size.Height);
}
public ILockedFramebuffer Lock(Vector dpi, Action<RetainedFramebuffer> blit)
{
if (_blob == null)
throw new ObjectDisposedException(nameof(RetainedFramebuffer));
return new LockedFramebuffer(_blob.Address, Size, RowBytes, dpi, Format, () =>
{
blit(this);
GC.KeepAlive(this);
});
}
public void Dispose()
{
_blob?.Dispose();
_blob = null;
}
}

5
src/Avalonia.Base/Rendering/Composition/Compositor.cs

@ -77,14 +77,15 @@ namespace Avalonia.Rendering.Composition
internal Compositor(IRenderLoop loop, IPlatformGraphics? gpu, internal Compositor(IRenderLoop loop, IPlatformGraphics? gpu,
bool useUiThreadForSynchronousCommits, bool useUiThreadForSynchronousCommits,
ICompositorScheduler scheduler, bool reclaimBuffersImmediately, ICompositorScheduler scheduler, bool reclaimBuffersImmediately,
Dispatcher dispatcher) Dispatcher dispatcher, CompositionOptions? options = null)
{ {
options ??= AvaloniaLocator.Current.GetService<CompositionOptions>() ?? new();
Loop = loop; Loop = loop;
UseUiThreadForSynchronousCommits = useUiThreadForSynchronousCommits; UseUiThreadForSynchronousCommits = useUiThreadForSynchronousCommits;
Dispatcher = dispatcher; Dispatcher = dispatcher;
_batchMemoryPool = new(reclaimBuffersImmediately); _batchMemoryPool = new(reclaimBuffersImmediately);
_batchObjectPool = new(reclaimBuffersImmediately); _batchObjectPool = new(reclaimBuffersImmediately);
_server = new ServerCompositor(loop, gpu, _batchObjectPool, _batchMemoryPool); _server = new ServerCompositor(loop, gpu, options, _batchObjectPool, _batchMemoryPool);
_triggerCommitRequested = () => scheduler.CommitRequested(this); _triggerCommitRequested = () => scheduler.CommitRequested(this);
DefaultEasing = new SplineEasing(new KeySpline(0.25, 0.1, 0.25, 1.0)); DefaultEasing = new SplineEasing(new KeySpline(0.25, 0.1, 0.25, 1.0));

86
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs

@ -30,6 +30,7 @@ namespace Avalonia.Rendering.Composition.Server
private IDrawingContextLayerImpl? _layer; private IDrawingContextLayerImpl? _layer;
private bool _updateRequested; private bool _updateRequested;
private bool _redrawRequested; private bool _redrawRequested;
private bool _fullRedrawRequested;
private bool _disposed; private bool _disposed;
private readonly HashSet<ServerCompositionVisual> _attachedVisuals = new(); private readonly HashSet<ServerCompositionVisual> _attachedVisuals = new();
private readonly Queue<ServerCompositionVisual> _adornerUpdateQueue = new(); private readonly Queue<ServerCompositionVisual> _adornerUpdateQueue = new();
@ -116,6 +117,7 @@ namespace Avalonia.Rendering.Composition.Server
partial void DeserializeChangesExtra(BatchStreamReader c) partial void DeserializeChangesExtra(BatchStreamReader c)
{ {
_redrawRequested = true; _redrawRequested = true;
_fullRedrawRequested = true;
} }
public void Render() public void Render()
@ -175,46 +177,56 @@ namespace Avalonia.Rendering.Composition.Server
if (!_redrawRequested) if (!_redrawRequested)
return; return;
_redrawRequested = false; _redrawRequested = false;
using (var targetContext = _renderTarget.CreateDrawingContext(false))
var renderTargetWithProperties = _renderTarget as IRenderTargetWithProperties;
var needLayer = DebugOverlays != RendererDebugOverlays.None // Check if we don't need overlays
// Check if render target can be rendered to directly and preserves the previous frame
|| !(renderTargetWithProperties?.Properties.RetainsPreviousFrameContents == true
&& renderTargetWithProperties?.Properties.IsSuitableForDirectRendering == true);
using (var renderTargetContext = _renderTarget.CreateDrawingContextWithProperties(false, out var properties))
{ {
if (PixelSize != _layerSize || _layer == null || _layer.IsCorrupted) if(needLayer && (PixelSize != _layerSize || _layer == null || _layer.IsCorrupted))
{ {
_layer?.Dispose(); _layer?.Dispose();
_layer = null; _layer = null;
_layer = targetContext.CreateLayer(PixelSize); _layer = renderTargetContext.CreateLayer(PixelSize);
_layerSize = PixelSize; _layerSize = PixelSize;
DirtyRects.AddRect(new PixelRect(_layerSize)); DirtyRects.AddRect(new PixelRect(_layerSize));
} }
else if (!needLayer)
{
_layer?.Dispose();
_layer = null;
}
if (_fullRedrawRequested || (!needLayer && !properties.PreviousFrameIsRetained))
{
DirtyRects.AddRect(new PixelRect(_layerSize));
_fullRedrawRequested = false;
}
if (!DirtyRects.IsEmpty) if (!DirtyRects.IsEmpty)
{ {
var useLayerClip = Compositor.Options.UseSaveLayerRootClip ?? if (_layer != null)
Compositor.RenderInterface.GpuContext != null;
using (var context = _layer.CreateDrawingContext(false))
{ {
using (DirtyRects.BeginDraw(context)) using (var context = _layer.CreateDrawingContext(false))
RenderRootToContextWithClip(context, Root);
renderTargetContext.Clear(Colors.Transparent);
renderTargetContext.Transform = Matrix.Identity;
if (_layer.CanBlit)
_layer.Blit(renderTargetContext);
else
{ {
context.Clear(Colors.Transparent); var rect = new PixelRect(default, PixelSize).ToRect(1);
if (useLayerClip) renderTargetContext.DrawBitmap(_layer, 1, rect, rect);
context.PushLayer(DirtyRects.CombinedRect.ToRect(1));
Root.Render(new CompositorDrawingContextProxy(context), null, DirtyRects);
if (useLayerClip)
context.PopLayer();
} }
} }
} else
RenderRootToContextWithClip(renderTargetContext, Root);
targetContext.Clear(Colors.Transparent);
targetContext.Transform = Matrix.Identity;
if (_layer.CanBlit)
_layer.Blit(targetContext);
else
{
var rect = new PixelRect(default, PixelSize).ToRect(1);
targetContext.DrawBitmap(_layer, 1, rect, rect);
} }
if (DebugOverlays != RendererDebugOverlays.None) if (DebugOverlays != RendererDebugOverlays.None)
@ -225,7 +237,7 @@ namespace Avalonia.Rendering.Composition.Server
RenderTimeGraph?.AddFrameValue(elapsed.TotalMilliseconds); RenderTimeGraph?.AddFrameValue(elapsed.TotalMilliseconds);
} }
DrawOverlays(targetContext, PixelSize.ToSize(Scaling)); DrawOverlays(renderTargetContext, PixelSize.ToSize(Scaling));
} }
RenderedVisuals = 0; RenderedVisuals = 0;
@ -234,6 +246,26 @@ namespace Avalonia.Rendering.Composition.Server
} }
} }
void RenderRootToContextWithClip(IDrawingContextImpl context, ServerCompositionVisual root)
{
var useLayerClip = Compositor.Options.UseSaveLayerRootClip ??
Compositor.RenderInterface.GpuContext != null;
using (DirtyRects.BeginDraw(context))
{
context.Clear(Colors.Transparent);
if (useLayerClip)
context.PushLayer(DirtyRects.CombinedRect.ToRect(1));
root.Render(new CompositorDrawingContextProxy(context), null, DirtyRects);
if (useLayerClip)
context.PopLayer();
}
}
private void DrawOverlays(IDrawingContextImpl targetContext, Size logicalSize) private void DrawOverlays(IDrawingContextImpl targetContext, Size logicalSize)
{ {
if ((DebugOverlays & RendererDebugOverlays.DirtyRects) != 0) if ((DebugOverlays & RendererDebugOverlays.DirtyRects) != 0)

4
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositor.cs

@ -38,11 +38,13 @@ namespace Avalonia.Rendering.Composition.Server
internal static readonly object RenderThreadDisposeStartMarker = new(); internal static readonly object RenderThreadDisposeStartMarker = new();
internal static readonly object RenderThreadJobsStartMarker = new(); internal static readonly object RenderThreadJobsStartMarker = new();
internal static readonly object RenderThreadJobsEndMarker = new(); internal static readonly object RenderThreadJobsEndMarker = new();
public CompositionOptions Options { get; } = AvaloniaLocator.Current.GetService<CompositionOptions>() ?? new(); public CompositionOptions Options { get; }
public ServerCompositor(IRenderLoop renderLoop, IPlatformGraphics? platformGraphics, public ServerCompositor(IRenderLoop renderLoop, IPlatformGraphics? platformGraphics,
CompositionOptions options,
BatchStreamObjectPool<object?> batchObjectPool, BatchStreamMemoryPool batchMemoryPool) BatchStreamObjectPool<object?> batchObjectPool, BatchStreamMemoryPool batchMemoryPool)
{ {
Options = options;
_renderLoop = renderLoop; _renderLoop = renderLoop;
RenderInterface = new PlatformRenderInterfaceContextManager(platformGraphics); RenderInterface = new PlatformRenderInterfaceContextManager(platformGraphics);
RenderInterface.ContextDisposed += RT_OnContextDisposed; RenderInterface.ContextDisposed += RT_OnContextDisposed;

2
src/Avalonia.Controls/Avalonia.Controls.csproj

@ -13,6 +13,7 @@
<ItemGroup Label="InternalsVisibleTo"> <ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="Avalonia.Controls.ItemsRepeater, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Controls.ItemsRepeater, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Skia.RenderTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Base.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Base.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Controls.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Controls.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Markup.UnitTests, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Markup.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
@ -23,6 +24,7 @@
<InternalsVisibleTo Include="Avalonia.Native, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Native, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Win32, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Win32, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.LinuxFramebuffer, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.DesignerSupport.Remote, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.DesignerSupport.Remote, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" /> <InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7" /> <InternalsVisibleTo Include="DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7" />

42
src/Avalonia.Controls/Platform/Surfaces/IFramebufferPlatformSurface.cs

@ -21,6 +21,23 @@ namespace Avalonia.Controls.Platform.Surfaces
/// </remarks> /// </remarks>
ILockedFramebuffer Lock(); ILockedFramebuffer Lock();
} }
[PrivateApi]
public interface IFramebufferRenderTargetWithProperties : IFramebufferRenderTarget
{
/// <summary>
/// Provides a framebuffer descriptor for drawing.
/// </summary>
/// <remarks>
/// Contents should be drawn on actual window after disposing
/// </remarks>
ILockedFramebuffer Lock(out FramebufferLockProperties properties);
bool RetainsFrameContents { get; }
}
[PrivateApi]
public record struct FramebufferLockProperties(bool PreviousFrameIsRetained);
/// <summary> /// <summary>
/// For simple cases when framebuffer is always available /// For simple cases when framebuffer is always available
@ -41,4 +58,29 @@ namespace Avalonia.Controls.Platform.Surfaces
public ILockedFramebuffer Lock() => _lockFramebuffer(); public ILockedFramebuffer Lock() => _lockFramebuffer();
} }
internal class FuncRetainedFramebufferRenderTarget : IFramebufferRenderTargetWithProperties
{
public delegate ILockedFramebuffer LockDelegate(out FramebufferLockProperties properties);
private readonly LockDelegate _lockFramebuffer;
public FuncRetainedFramebufferRenderTarget(LockDelegate lockFramebuffer)
{
_lockFramebuffer = lockFramebuffer;
}
public void Dispose()
{
// No-op
}
public ILockedFramebuffer Lock() => _lockFramebuffer(out _);
public ILockedFramebuffer Lock(out FramebufferLockProperties properties)
{
return _lockFramebuffer(out properties);
}
public bool RetainsFrameContents => true;
}
} }

64
src/Avalonia.X11/X11Framebuffer.cs

@ -1,64 +0,0 @@
using System;
using System.IO;
using Avalonia.Platform;
using Avalonia.Platform.Internal;
using SkiaSharp;
using static Avalonia.X11.XLib;
namespace Avalonia.X11
{
internal class X11Framebuffer : ILockedFramebuffer
{
private readonly IntPtr _display;
private readonly IntPtr _xid;
private readonly int _depth;
private UnmanagedBlob _blob;
public X11Framebuffer(IntPtr display, IntPtr xid, int depth, int width, int height, double factor)
{
// HACK! Please fix renderer, should never ask for 0x0 bitmap.
width = Math.Max(1, width);
height = Math.Max(1, height);
_display = display;
_xid = xid;
_depth = depth;
Size = new PixelSize(width, height);
RowBytes = width * 4;
Dpi = new Vector(96, 96) * factor;
Format = PixelFormat.Bgra8888;
_blob = new UnmanagedBlob(RowBytes * height);
Address = _blob.Address;
}
public void Dispose()
{
var image = new XImage();
int bitsPerPixel = 32;
image.width = Size.Width;
image.height = Size.Height;
image.format = 2; //ZPixmap;
image.data = Address;
image.byte_order = 0;// LSBFirst;
image.bitmap_unit = bitsPerPixel;
image.bitmap_bit_order = 0;// LSBFirst;
image.bitmap_pad = bitsPerPixel;
image.depth = _depth;
image.bytes_per_line = RowBytes;
image.bits_per_pixel = bitsPerPixel;
XLockDisplay(_display);
XInitImage(ref image);
var gc = XCreateGC(_display, _xid, 0, IntPtr.Zero);
XPutImage(_display, _xid, gc, ref image, 0, 0, 0, 0, (uint) Size.Width, (uint) Size.Height);
XFreeGC(_display, gc);
XSync(_display, true);
XUnlockDisplay(_display);
_blob.Dispose();
}
public IntPtr Address { get; }
public PixelSize Size { get; }
public int RowBytes { get; }
public Vector Dpi { get; }
public PixelFormat Format { get; }
}
}

59
src/Avalonia.X11/X11FramebufferSurface.cs

@ -9,25 +9,70 @@ namespace Avalonia.X11
private readonly IntPtr _display; private readonly IntPtr _display;
private readonly IntPtr _xid; private readonly IntPtr _xid;
private readonly int _depth; private readonly int _depth;
private readonly Func<double> _scaling; private readonly bool _retain;
private RetainedFramebuffer? _fb;
public X11FramebufferSurface(IntPtr display, IntPtr xid, int depth, Func<double> scaling) public X11FramebufferSurface(IntPtr display, IntPtr xid, int depth, bool retain)
{ {
_display = display; _display = display;
_xid = xid; _xid = xid;
_depth = depth; _depth = depth;
_scaling = scaling; _retain = retain;
}
void Blit(RetainedFramebuffer fb)
{
var image = new XImage();
int bitsPerPixel = 32;
image.width = fb.Size.Width;
image.height = fb.Size.Height;
image.format = 2; //ZPixmap;
image.data = fb.Address;
image.byte_order = 0;// LSBFirst;
image.bitmap_unit = bitsPerPixel;
image.bitmap_bit_order = 0;// LSBFirst;
image.bitmap_pad = bitsPerPixel;
image.depth = _depth;
image.bytes_per_line = fb.RowBytes;
image.bits_per_pixel = bitsPerPixel;
XLockDisplay(_display);
XInitImage(ref image);
var gc = XCreateGC(_display, _xid, 0, IntPtr.Zero);
XPutImage(_display, _xid, gc, ref image, 0, 0, 0, 0, (uint)fb.Size.Width, (uint)fb.Size.Height);
XFreeGC(_display, gc);
XSync(_display, true);
XUnlockDisplay(_display);
if (!_retain)
{
_fb?.Dispose();
_fb = null;
}
} }
public ILockedFramebuffer Lock() public ILockedFramebuffer Lock(out FramebufferLockProperties properties)
{ {
XLockDisplay(_display); XLockDisplay(_display);
XGetGeometry(_display, _xid, out var root, out var x, out var y, out var width, out var height, XGetGeometry(_display, _xid, out var root, out var x, out var y, out var width, out var height,
out var bw, out var d); out var bw, out var d);
XUnlockDisplay(_display); XUnlockDisplay(_display);
return new X11Framebuffer(_display, _xid, _depth, width, height, _scaling());
var framebufferValid = (_fb != null && _fb.Size.Width == width && _fb.Size.Height == height);
if (!framebufferValid)
{
_fb?.Dispose();
_fb = null;
_fb = new RetainedFramebuffer(new PixelSize(width, height), PixelFormat.Bgra8888);
}
properties = new FramebufferLockProperties(framebufferValid);
return _fb.Lock(new Vector(96, 96), Blit);
}
public IFramebufferRenderTarget CreateFramebufferRenderTarget()
{
return _retain
? new FuncRetainedFramebufferRenderTarget(Lock)
: new FuncFramebufferRenderTarget(() => Lock(out _));
} }
public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncFramebufferRenderTarget(Lock);
} }
} }

8
src/Avalonia.X11/X11Platform.cs

@ -344,6 +344,14 @@ namespace Avalonia
/// </remarks> /// </remarks>
public bool? EnableMultiTouch { get; set; } = true; public bool? EnableMultiTouch { get; set; } = true;
/// <summary>
/// Retain window framebuffer contents if using CPU rendering mode.
/// This will keep an offscreen bitmap for each window with contents of the previous frame
/// While improving performance by saving a blit, it will increase memory consumption
/// if you have many windows
/// </summary>
public bool? UseRetainedFramebuffer { get; set; }
public X11PlatformOptions() public X11PlatformOptions()
{ {
try try

2
src/Avalonia.X11/X11Window.cs

@ -192,7 +192,7 @@ namespace Avalonia.X11
var surfaces = new List<object> var surfaces = new List<object>
{ {
new X11FramebufferSurface(_x11.DeferredDisplay, _renderHandle, new X11FramebufferSurface(_x11.DeferredDisplay, _renderHandle,
depth, () => RenderScaling) depth, _platform.Options.UseRetainedFramebuffer ?? false)
}; };
if (egl != null) if (egl != null)

4
src/Linux/Avalonia.LinuxFramebuffer/LinuxFramebufferPlatform.cs

@ -166,6 +166,10 @@ public static class LinuxFramebufferPlatformExtensions
public static int StartLinuxFbDev(this AppBuilder builder, string[] args, string fbdev, PixelFormat? format, double scaling, IInputBackend? inputBackend = default) public static int StartLinuxFbDev(this AppBuilder builder, string[] args, string fbdev, PixelFormat? format, double scaling, IInputBackend? inputBackend = default)
=> StartLinuxDirect(builder, args, new FbdevOutput(fileName: fbdev, format: format) { Scaling = scaling }, inputBackend); => StartLinuxDirect(builder, args, new FbdevOutput(fileName: fbdev, format: format) { Scaling = scaling }, inputBackend);
public static int StartLinuxFbDev(this AppBuilder builder, string[] args, FbDevOutputOptions options,
IInputBackend? inputBackend = default)
=> StartLinuxDirect(builder, args, new FbdevOutput(options), inputBackend);
public static int StartLinuxDrm(this AppBuilder builder, string[] args, string? card = null, double scaling = 1, IInputBackend? inputBackend = default) public static int StartLinuxDrm(this AppBuilder builder, string[] args, string? card = null, double scaling = 1, IInputBackend? inputBackend = default)
=> StartLinuxDirect(builder, args, new DrmOutput(card) { Scaling = scaling }, inputBackend); => StartLinuxDirect(builder, args, new DrmOutput(card) { Scaling = scaling }, inputBackend);
public static int StartLinuxDrm(this AppBuilder builder, string[] args, string? card = null, bool connectorsForceProbe = false, DrmOutputOptions? options = null, IInputBackend? inputBackend = default) public static int StartLinuxDrm(this AppBuilder builder, string[] args, string? card = null, bool connectorsForceProbe = false, DrmOutputOptions? options = null, IInputBackend? inputBackend = default)

18
src/Linux/Avalonia.LinuxFramebuffer/Output/FbDevBackBuffer.cs

@ -32,17 +32,23 @@ namespace Avalonia.LinuxFramebuffer.Output
} }
} }
public static LockedFramebuffer LockFb(IntPtr address, fb_var_screeninfo varInfo,
fb_fix_screeninfo fixedInfo, Vector dpi, Action? dispose)
{
return new LockedFramebuffer(address,
new PixelSize((int)varInfo.xres, (int)varInfo.yres),
(int)fixedInfo.line_length, dpi,
varInfo.bits_per_pixel == 16 ? PixelFormat.Rgb565
: varInfo.blue.offset == 16 ? PixelFormat.Rgba8888
: PixelFormat.Bgra8888, dispose);
}
public ILockedFramebuffer Lock(Vector dpi) public ILockedFramebuffer Lock(Vector dpi)
{ {
Monitor.Enter(_lock); Monitor.Enter(_lock);
try try
{ {
return new LockedFramebuffer(Address, return LockFb(Address, _varInfo, _fixedInfo, dpi,
new PixelSize((int)_varInfo.xres, (int)_varInfo.yres),
(int)_fixedInfo.line_length, dpi,
_varInfo.bits_per_pixel == 16 ? PixelFormat.Rgb565
: _varInfo.blue.offset == 16 ? PixelFormat.Rgba8888
: PixelFormat.Bgra8888,
() => () =>
{ {
try try

28
src/Linux/Avalonia.LinuxFramebuffer/Output/FbDevOutputOptions.cs

@ -0,0 +1,28 @@
using Avalonia.Platform;
namespace Avalonia.LinuxFramebuffer.Output;
public class FbDevOutputOptions
{
/// <summary>
/// The frame buffer device name.
/// Defaults to the value in environment variable FRAMEBUFFER or /dev/fb0 when FRAMEBUFFER is not set
/// </summary>
public string? FileName { get; set; }
/// <summary>
/// The required pixel format for the frame buffer.
/// A null value will leave the frame buffer in the current pixel format.
/// Otherwise sets the frame buffer to the required format
/// </summary>
public PixelFormat? PixelFormat { get; set; }
/// <summary>
/// If set to true, double-buffering will be disabled and scene will be composed directly into mmap-ed memory region
/// While this mode saves a blit, you need to check if it won't cause rendering artifacts your particular device.
/// </summary>
public bool RenderDirectlyToMappedMemory { get; set; }
/// <summary>
/// The initial scale factor to use
/// </summary>
public double Scaling { get; set; } = 1;
}

44
src/Linux/Avalonia.LinuxFramebuffer/Output/FbdevOutput.cs

@ -15,6 +15,8 @@ namespace Avalonia.LinuxFramebuffer
private IntPtr _mappedLength; private IntPtr _mappedLength;
private IntPtr _mappedAddress; private IntPtr _mappedAddress;
private FbDevBackBuffer _backBuffer; private FbDevBackBuffer _backBuffer;
private readonly FbDevOutputOptions _options;
private bool _lockedAtLeastOnce;
public double Scaling { get; set; } public double Scaling { get; set; }
/// <summary> /// <summary>
@ -34,16 +36,30 @@ namespace Avalonia.LinuxFramebuffer
/// <param name="format">The required pixel format for the frame buffer. /// <param name="format">The required pixel format for the frame buffer.
/// A null value will leave the frame buffer in the current pixel format. /// A null value will leave the frame buffer in the current pixel format.
/// Otherwise sets the frame buffer to the required format</param> /// Otherwise sets the frame buffer to the required format</param>
public FbdevOutput(string fileName, PixelFormat? format) public FbdevOutput(string fileName, PixelFormat? format) : this(new FbDevOutputOptions()
{ {
fileName ??= Environment.GetEnvironmentVariable("FRAMEBUFFER") ?? "/dev/fb0"; FileName = fileName,
PixelFormat = format
})
{
}
/// <summary>
/// Create a Linux frame buffer device output
/// </summary>
/// <param name="options">Options</param>
public FbdevOutput(FbDevOutputOptions options)
{
var fileName = options.FileName ?? Environment.GetEnvironmentVariable("FRAMEBUFFER") ?? "/dev/fb0";
_fd = NativeUnsafeMethods.open(fileName, 2, 0); _fd = NativeUnsafeMethods.open(fileName, 2, 0);
if (_fd <= 0) if (_fd <= 0)
throw new Exception("Error: " + Marshal.GetLastWin32Error()); throw new Exception("Error: " + Marshal.GetLastWin32Error());
_options = options;
Scaling = options.Scaling;
try try
{ {
Init(format); Init(options.PixelFormat);
} }
catch catch
{ {
@ -144,16 +160,32 @@ namespace Avalonia.LinuxFramebuffer
} }
} }
public ILockedFramebuffer Lock() public ILockedFramebuffer Lock() => Lock(out _);
private ILockedFramebuffer Lock(out FramebufferLockProperties properties)
{ {
if (_fd <= 0) if (_fd <= 0)
throw new ObjectDisposedException("LinuxFramebuffer"); throw new ObjectDisposedException("LinuxFramebuffer");
var dpi = new Vector(96, 96) * Scaling;
if (_options.RenderDirectlyToMappedMemory)
{
properties = new FramebufferLockProperties(_lockedAtLeastOnce);
_lockedAtLeastOnce = true;
NativeUnsafeMethods.ioctl(_fd, FbIoCtl.FBIO_WAITFORVSYNC, null);
return FbDevBackBuffer.LockFb(_mappedAddress, _varInfo, _fixedInfo, dpi, null);
}
var retained = _lockedAtLeastOnce && _backBuffer != null;
_lockedAtLeastOnce = true;
properties = new FramebufferLockProperties(retained);
return (_backBuffer ??= return (_backBuffer ??=
new FbDevBackBuffer(_fd, _fixedInfo, _varInfo, _mappedAddress)) new FbDevBackBuffer(_fd, _fixedInfo, _varInfo, _mappedAddress))
.Lock(new Vector(96, 96) * Scaling); .Lock(new Vector(96, 96) * Scaling);
} }
public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncFramebufferRenderTarget(Lock); public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncRetainedFramebufferRenderTarget(Lock);
private void ReleaseUnmanagedResources() private void ReleaseUnmanagedResources()

33
src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs

@ -10,7 +10,7 @@ namespace Avalonia.Skia
/// <summary> /// <summary>
/// Skia render target that renders to a framebuffer surface. No gpu acceleration available. /// Skia render target that renders to a framebuffer surface. No gpu acceleration available.
/// </summary> /// </summary>
internal class FramebufferRenderTarget : IRenderTarget internal class FramebufferRenderTarget : IRenderTargetWithProperties
{ {
private SKImageInfo _currentImageInfo; private SKImageInfo _currentImageInfo;
private IntPtr _currentFramebufferAddress; private IntPtr _currentFramebufferAddress;
@ -18,6 +18,8 @@ namespace Avalonia.Skia
private PixelFormatConversionShim? _conversionShim; private PixelFormatConversionShim? _conversionShim;
private IDisposable? _preFramebufferCopyHandler; private IDisposable? _preFramebufferCopyHandler;
private IFramebufferRenderTarget? _renderTarget; private IFramebufferRenderTarget? _renderTarget;
private IFramebufferRenderTargetWithProperties? _renderTargetWithProperties;
private bool _hadConversionShim;
/// <summary> /// <summary>
/// Create new framebuffer render target using a target surface. /// Create new framebuffer render target using a target surface.
@ -26,6 +28,7 @@ namespace Avalonia.Skia
public FramebufferRenderTarget(IFramebufferPlatformSurface platformSurface) public FramebufferRenderTarget(IFramebufferPlatformSurface platformSurface)
{ {
_renderTarget = platformSurface.CreateFramebufferRenderTarget(); _renderTarget = platformSurface.CreateFramebufferRenderTarget();
_renderTargetWithProperties = _renderTarget as IFramebufferRenderTargetWithProperties;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -33,21 +36,36 @@ namespace Avalonia.Skia
{ {
_renderTarget?.Dispose(); _renderTarget?.Dispose();
_renderTarget = null; _renderTarget = null;
_renderTargetWithProperties = null;
FreeSurface(); FreeSurface();
} }
public RenderTargetProperties Properties => new()
{
RetainsPreviousFrameContents = !_hadConversionShim
&& _renderTargetWithProperties?.RetainsFrameContents == true,
IsSuitableForDirectRendering = true
};
/// <inheritdoc />
public IDrawingContextImpl CreateDrawingContext(bool scaleDrawingToDpi) =>
CreateDrawingContext(scaleDrawingToDpi, out _);
/// <inheritdoc /> /// <inheritdoc />
public IDrawingContextImpl CreateDrawingContext(bool scaleDrawingToDpi) public IDrawingContextImpl CreateDrawingContext(bool useScaledDrawing, out RenderTargetDrawingContextProperties properties)
{ {
if (_renderTarget == null) if (_renderTarget == null)
throw new ObjectDisposedException(nameof(FramebufferRenderTarget)); throw new ObjectDisposedException(nameof(FramebufferRenderTarget));
var framebuffer = _renderTarget.Lock(); FramebufferLockProperties lockProperties = default;
var framebuffer = _renderTargetWithProperties?.Lock(out lockProperties) ?? _renderTarget.Lock();
var framebufferImageInfo = new SKImageInfo(framebuffer.Size.Width, framebuffer.Size.Height, var framebufferImageInfo = new SKImageInfo(framebuffer.Size.Width, framebuffer.Size.Height,
framebuffer.Format.ToSkColorType(), framebuffer.Format.ToSkColorType(),
framebuffer.Format == PixelFormat.Rgb565 ? SKAlphaType.Opaque : SKAlphaType.Premul); framebuffer.Format == PixelFormat.Rgb565 ? SKAlphaType.Opaque : SKAlphaType.Premul);
CreateSurface(framebufferImageInfo, framebuffer); CreateSurface(framebufferImageInfo, framebuffer);
_hadConversionShim |= _conversionShim != null;
var canvas = _framebufferSurface.Canvas; var canvas = _framebufferSurface.Canvas;
@ -59,9 +77,14 @@ namespace Avalonia.Skia
{ {
Surface = _framebufferSurface, Surface = _framebufferSurface,
Dpi = framebuffer.Dpi, Dpi = framebuffer.Dpi,
ScaleDrawingToDpi = scaleDrawingToDpi ScaleDrawingToDpi = useScaledDrawing
}; };
properties = new()
{
PreviousFrameIsRetained = !_hadConversionShim && lockProperties.PreviousFrameIsRetained
};
return new DrawingContextImpl(createInfo, _preFramebufferCopyHandler, canvas, framebuffer); return new DrawingContextImpl(createInfo, _preFramebufferCopyHandler, canvas, framebuffer);
} }

1
src/Windows/Avalonia.Direct2D1/Media/Imaging/WicRenderTargetBitmapImpl.cs

@ -2,6 +2,7 @@ using System;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Rendering; using Avalonia.Rendering;
using SharpDX.Direct2D1; using SharpDX.Direct2D1;
using RenderTargetProperties = SharpDX.Direct2D1.RenderTargetProperties;
namespace Avalonia.Direct2D1.Media namespace Avalonia.Direct2D1.Media
{ {

135
tests/Avalonia.RenderTests/Composition/DirectFbCompositionTests.cs

@ -0,0 +1,135 @@
#if AVALONIA_SKIA
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform.Surfaces;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Shapes;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;
using Avalonia.UnitTests;
using SkiaSharp;
using Xunit;
namespace Avalonia.Skia.RenderTests;
public class DirectFbCompositionTests : TestBase
{
public DirectFbCompositionTests()
: base(@"Composition\DirectFb")
{
}
class FuncFramebufferSurface : IFramebufferPlatformSurface
{
private readonly Func<IFramebufferRenderTarget> _cb;
public FuncFramebufferSurface(Func<IFramebufferRenderTarget> cb)
{
_cb = cb;
}
public IFramebufferRenderTarget CreateFramebufferRenderTarget()
{
return _cb();
}
}
[Theory,
InlineData(false),
InlineData(true)]
void Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised(bool advertised)
{
var timer = new ManualRenderTimer();
var compositor = new Compositor(new RenderLoop(timer), null, true,
new DispatcherCompositorScheduler(), true, Dispatcher.UIThread, new CompositionOptions
{
UseRegionDirtyRectClipping = true
});
Rectangle r1, r2;
var control = new Canvas
{
Width = 200, Height = 200, Background = Brushes.Yellow,
Children =
{
(r1 = new Rectangle
{
Fill = Brushes.Black,
Width = 40,
Height = 40,
Opacity = 0.6,
[Canvas.LeftProperty] = 40,
[Canvas.TopProperty] = 40,
}),
(r2 = new Rectangle
{
Fill = Brushes.Black,
Width = 40,
Height = 40,
Opacity = 0.6,
[Canvas.LeftProperty] = 120,
[Canvas.TopProperty] = 40,
}),
}
};
var root = new TestRenderRoot(1, null!);
SKBitmap fb = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul);
ILockedFramebuffer LockFb() => new LockedFramebuffer(fb.GetAddress(0, 0), new(fb.Width, fb.Height),
fb.RowBytes, new Vector(96, 96), PixelFormat.Rgba8888, null);
bool previousFrameIsRetained = false;
IFramebufferRenderTarget rt = advertised
? new FuncRetainedFramebufferRenderTarget((out FramebufferLockProperties props) =>
{
props = new() { PreviousFrameIsRetained = previousFrameIsRetained };
return LockFb();
})
: new FuncFramebufferRenderTarget(LockFb);
using var renderer =
new CompositingRenderer(root, compositor, () => new[] { new FuncFramebufferSurface(() => rt) });
root.Initialize(renderer, control);
control.Measure(new Size(control.Width, control.Height));
control.Arrange(new Rect(control.DesiredSize));
renderer.Start();
Dispatcher.UIThread.RunJobs();
timer.TriggerTick();
var image1 =
$"{nameof(Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised)}_advertized-{advertised}_initial";
SaveFile(fb, image1);
fb.Erase(SKColor.Empty);
previousFrameIsRetained = advertised;
r1.Fill = Brushes.Red;
r2.Fill = Brushes.Green;
Dispatcher.UIThread.RunJobs();
timer.TriggerTick();
var image2 =
$"{nameof(Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised)}_advertized-{advertised}_updated";
SaveFile(fb, image2);
CompareImages(image1, skipImmediate: true);
CompareImages(image2, skipImmediate: true);
}
void SaveFile(SKBitmap bmp, string name)
{
Directory.CreateDirectory(OutputPath);
var path = System.IO.Path.Combine(OutputPath, name + ".composited.out.png");
using var d = bmp.Encode(SKEncodedImageFormat.Png, 100);
using var f = File.Create(path);
d.SaveTo(f);
}
}
#endif

23
tests/Avalonia.RenderTests/TestBase.cs

@ -151,20 +151,25 @@ namespace Avalonia.Direct2D1.RenderTests
var compositedPath = Path.Combine(OutputPath, testName + ".composited.out.png"); var compositedPath = Path.Combine(OutputPath, testName + ".composited.out.png");
using (var expected = Image.Load<Rgba32>(expectedPath)) using (var expected = Image.Load<Rgba32>(expectedPath))
using (var immediate = Image.Load<Rgba32>(immediatePath)) using (var immediate = skipImmediate ? null: Image.Load<Rgba32>(immediatePath))
using (var composited = Image.Load<Rgba32>(compositedPath)) using (var composited = skipCompositor ? null : Image.Load<Rgba32>(compositedPath))
{ {
var immediateError = CompareImages(immediate, expected); if (!skipImmediate)
var compositedError = CompareImages(composited, expected);
if (immediateError > 0.022 && !skipImmediate)
{ {
Assert.True(false, immediatePath + ": Error = " + immediateError); var immediateError = CompareImages(immediate!, expected);
if (immediateError > 0.022)
{
Assert.True(false, immediatePath + ": Error = " + immediateError);
}
} }
if (compositedError > 0.022 && !skipCompositor) if (!skipCompositor)
{ {
Assert.True(false, compositedPath + ": Error = " + compositedError); var compositedError = CompareImages(composited!, expected);
if (compositedError > 0.022)
{
Assert.True(false, compositedPath + ": Error = " + compositedError);
}
} }
} }
} }

BIN
tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-False_initial.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

BIN
tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-False_updated.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

BIN
tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-True_initial.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

BIN
tests/TestFiles/Skia/Composition/DirectFb/Should_Only_Update_Clipped_Rects_When_Retained_Fb_Is_Advertised_advertized-True_updated.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Loading…
Cancel
Save