diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/PaintFastBoundsBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/PaintFastBoundsBenchmark.cs new file mode 100644 index 00000000000..fa4afd89ae4 --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/PaintFastBoundsBenchmark.cs @@ -0,0 +1,111 @@ +using System; +using BenchmarkDotNet.Attributes; + +namespace SkiaSharp.Benchmarks; + +// SKPaint.GetFastBounds exists to power quick-reject culling: before issuing an expensive +// draw, a renderer can ask the paint for the conservative device-space rect the draw would touch +// - composing the path effect, stroke, mask filter and image filter - and skip the draw entirely +// when that rect misses the clip/viewport. +// +// This benchmark models a frame that submits many blurred rects scattered across a virtual space +// four times larger than the viewport, so roughly three quarters of the items land off-screen and +// are cullable. It compares drawing every item unconditionally against using GetFastBounds to +// cull off-screen items first, plus an isolated measurement of the raw GetFastBounds call cost +// (the P/Invoke crossing plus the native geometry math). +// +// Runs on the project's current TFM with the default job, matching SurfaceCanvasBenchmark. +[MemoryDiagnoser] +public class PaintFastBoundsBenchmark +{ + private const int Width = 256; + private const int Height = 256; + + // Frames rendered per invocation. 60 ~= one second at 60 FPS. + [Params(1, 60)] + public int Frames { get; set; } + + // Draw items submitted per frame. + [Params(500)] + public int ItemsPerFrame { get; set; } + + private SKSurface surface; + private SKCanvas canvas; + private SKPaint paint; + private SKMaskFilter blur; + private SKRect clip; + private SKRect[] items; + + [GlobalSetup] + public void GlobalSetup() + { + surface = SKSurface.Create(new SKImageInfo(Width, Height, SKColorType.Rgba8888, SKAlphaType.Premul)); + canvas = surface.Canvas; + blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6f); + paint = new SKPaint { IsAntialias = true, Color = SKColors.SteelBlue, MaskFilter = blur }; + + // The on-screen viewport that draws must intersect to be visible. + clip = SKRect.Create(0, 0, Width, Height); + + // Scatter items across a 4x larger virtual space so ~75% land off-screen and are + // cullable. A fixed seed keeps the scene identical across runs. + items = new SKRect[ItemsPerFrame]; + var rnd = new Random(42); + for (var i = 0; i < items.Length; i++) + { + var x = rnd.Next(-Width, Width * 3); + var y = rnd.Next(-Height, Height * 3); + items[i] = SKRect.Create(x, y, 24, 24); + } + } + + [GlobalCleanup] + public void GlobalCleanup() + { + paint?.Dispose(); + blur?.Dispose(); + surface?.Dispose(); + } + + // Baseline: draw every blurred item, letting the raster backend reject off-screen draws. + [Benchmark(Baseline = true)] + public void DrawAll() + { + for (var f = 0; f < Frames; f++) + { + foreach (var item in items) + canvas.DrawRect(item, paint); + } + } + + // Optimized: use GetFastBounds to skip items whose drawn bounds miss the viewport. + [Benchmark] + public void DrawWithFastBoundsCulling() + { + for (var f = 0; f < Frames; f++) + { + foreach (var item in items) + { + paint.GetFastBounds(item, out var bounds); + if (bounds.IntersectsWith(clip)) + canvas.DrawRect(item, paint); + } + } + } + + // Isolates the raw GetFastBounds cost (P/Invoke crossing plus native geometry). + [Benchmark] + public float GetFastBoundsOnly() + { + var sum = 0f; + for (var f = 0; f < Frames; f++) + { + foreach (var item in items) + { + paint.GetFastBounds(item, out var bounds); + sum += bounds.Width; + } + } + return sum; + } +} diff --git a/binding/SkiaSharp/SKPaint.cs b/binding/SkiaSharp/SKPaint.cs index 9c8869914ee..7588194be9e 100644 --- a/binding/SkiaSharp/SKPaint.cs +++ b/binding/SkiaSharp/SKPaint.cs @@ -734,6 +734,23 @@ private bool GetFillPath (SKPath src, SKPathBuilder dst, SKRect* cullRect, SKMat return result; } + // GetFastBounds + + public bool GetFastBounds (SKRect bounds, out SKRect fastBounds) + { + if (!SkiaApi.sk_paint_can_compute_fast_bounds (Handle)) { + GC.KeepAlive (this); + fastBounds = SKRect.Empty; + return false; + } + + fixed (SKRect* storage = &fastBounds) { + SkiaApi.sk_paint_compute_fast_bounds (Handle, &bounds, storage); + } + GC.KeepAlive (this); + return true; + } + // CountGlyphs [Obsolete ($"Use {nameof (SKFont)}.{nameof (SKFont.CountGlyphs)}() instead.", error: true)] diff --git a/binding/SkiaSharp/SkiaApi.generated.cs b/binding/SkiaSharp/SkiaApi.generated.cs index 037012699b7..8b7df455433 100644 --- a/binding/SkiaSharp/SkiaApi.generated.cs +++ b/binding/SkiaSharp/SkiaApi.generated.cs @@ -8514,6 +8514,28 @@ internal static bool sk_matrix_try_invert (SKMatrix* matrix, SKMatrix* result) = #region sk_paint.h + // bool sk_paint_can_compute_fast_bounds(const sk_paint_t* cpaint) + #if !USE_DELEGATES + #if USE_LIBRARY_IMPORT + [LibraryImport (SKIA)] + [return: MarshalAs (UnmanagedType.I1)] + internal static partial bool sk_paint_can_compute_fast_bounds (sk_paint_t cpaint); + #else // !USE_LIBRARY_IMPORT + [DllImport (SKIA, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs (UnmanagedType.I1)] + internal static extern bool sk_paint_can_compute_fast_bounds (sk_paint_t cpaint); + #endif + #else + private partial class Delegates { + [UnmanagedFunctionPointer (CallingConvention.Cdecl)] + [return: MarshalAs (UnmanagedType.I1)] + internal delegate bool sk_paint_can_compute_fast_bounds (sk_paint_t cpaint); + } + private static Delegates.sk_paint_can_compute_fast_bounds sk_paint_can_compute_fast_bounds_delegate; + internal static bool sk_paint_can_compute_fast_bounds (sk_paint_t cpaint) => + (sk_paint_can_compute_fast_bounds_delegate ??= GetSymbol ("sk_paint_can_compute_fast_bounds")).Invoke (cpaint); + #endif + // sk_paint_t* sk_paint_clone(sk_paint_t*) #if !USE_DELEGATES #if USE_LIBRARY_IMPORT @@ -8533,6 +8555,25 @@ internal static sk_paint_t sk_paint_clone (sk_paint_t param0) => (sk_paint_clone_delegate ??= GetSymbol ("sk_paint_clone")).Invoke (param0); #endif + // void sk_paint_compute_fast_bounds(const sk_paint_t* cpaint, const sk_rect_t* orig, sk_rect_t* storage) + #if !USE_DELEGATES + #if USE_LIBRARY_IMPORT + [LibraryImport (SKIA)] + internal static partial void sk_paint_compute_fast_bounds (sk_paint_t cpaint, SKRect* orig, SKRect* storage); + #else // !USE_LIBRARY_IMPORT + [DllImport (SKIA, CallingConvention = CallingConvention.Cdecl)] + internal static extern void sk_paint_compute_fast_bounds (sk_paint_t cpaint, SKRect* orig, SKRect* storage); + #endif + #else + private partial class Delegates { + [UnmanagedFunctionPointer (CallingConvention.Cdecl)] + internal delegate void sk_paint_compute_fast_bounds (sk_paint_t cpaint, SKRect* orig, SKRect* storage); + } + private static Delegates.sk_paint_compute_fast_bounds sk_paint_compute_fast_bounds_delegate; + internal static void sk_paint_compute_fast_bounds (sk_paint_t cpaint, SKRect* orig, SKRect* storage) => + (sk_paint_compute_fast_bounds_delegate ??= GetSymbol ("sk_paint_compute_fast_bounds")).Invoke (cpaint, orig, storage); + #endif + // void sk_paint_delete(sk_paint_t*) #if !USE_DELEGATES #if USE_LIBRARY_IMPORT diff --git a/externals/skia b/externals/skia index b16789ec354..280ec21adad 160000 --- a/externals/skia +++ b/externals/skia @@ -1 +1 @@ -Subproject commit b16789ec3540beaa43e8a71b050d9d328eb68025 +Subproject commit 280ec21adad7b21bdf8d8081a44c3191bb420fc3 diff --git a/samples/Gallery/Shared/Samples/PaintFastBoundsSample.cs b/samples/Gallery/Shared/Samples/PaintFastBoundsSample.cs new file mode 100644 index 00000000000..50bb6f4aed9 --- /dev/null +++ b/samples/Gallery/Shared/Samples/PaintFastBoundsSample.cs @@ -0,0 +1,125 @@ +using SkiaSharp; +using SkiaSharpSample.Controls; + +namespace SkiaSharpSample.Samples; + +public class PaintFastBoundsSample : CanvasSampleBase +{ + private static readonly string[] blurStyles = ["Normal", "Solid", "Outer", "Inner"]; + + private float sigma = 12f; + private float strokeWidth = 6f; + private int blurStyleIndex = 0; + private bool showBounds = true; + + public override string Title => "Paint Fast Bounds"; + + public override DateOnly? DateAdded => new DateOnly(2026, 6, 27); + + public override string Category => SampleManager.ImageFilters; + + public override string Description => + "Visualize SKPaint.GetFastBounds. The dashed rectangle is the conservative " + + "device-space region the paint can draw into - the renderer uses it to quick-reject " + + "draws and to size offscreen layers. GetFastBounds composes every effect on the " + + "paint, so the bounds account for both the stroke width and the blur mask filter. " + + "Increase the stroke or blur and watch the bounds grow."; + + public override IReadOnlyList ApiTags => + [ + "SKPaint", "SKPaint.GetFastBounds", + "SKMaskFilter.CreateBlur", "SKBlurStyle", "SKCanvas.DrawRect", + ]; + + public override IReadOnlyList Controls => + [ + new SliderControl("sigma", "Blur Sigma", 0, 40, sigma, 0.5f), + new SliderControl("stroke", "Stroke Width", 0, 30, strokeWidth, 0.5f), + new PickerControl("style", "Blur Style", blurStyles, blurStyleIndex), + new ToggleControl("bounds", "Show Fast Bounds", showBounds), + ]; + + protected override void OnControlChanged(string id, object value) + { + switch (id) + { + case "sigma": + sigma = (float)value; + break; + case "stroke": + strokeWidth = (float)value; + break; + case "style": + blurStyleIndex = (int)value; + break; + case "bounds": + showBounds = (bool)value; + break; + } + } + + protected override void OnDrawSample(SKCanvas canvas, int width, int height) + { + canvas.Clear(SKColors.White); + + var style = (SKBlurStyle)blurStyleIndex; + + // The geometry that will be drawn, centered in the canvas. + var size = Math.Min(width, height) * 0.4f; + var shape = new SKRect( + (width - size) / 2f, + (height - size) / 2f, + (width + size) / 2f, + (height + size) / 2f); + + using var blur = SKMaskFilter.CreateBlur(style, sigma); + + // A paint that both strokes and blurs the shape. + using var paint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = strokeWidth, + StrokeJoin = SKStrokeJoin.Round, + StrokeCap = SKStrokeCap.Round, + Color = SKColors.MediumPurple, + MaskFilter = blur, + }; + + // The conservative bounds the paint can draw into, composing the stroke and the blur. + paint.GetFastBounds(shape, out var fastBounds); + + // Draw the stroked, blurred shape. + canvas.DrawRect(shape, paint); + + if (showBounds) + { + // The original geometry, for reference. + using var shapeStroke = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = 1, + Color = SKColors.Gray, + }; + canvas.DrawRect(shape, shapeStroke); + + // The fast bounds returned by GetFastBounds. + using var dash = SKPathEffect.CreateDash([8f, 6f], 0); + using var boundsStroke = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = 2, + Color = SKColors.OrangeRed, + PathEffect = dash, + }; + canvas.DrawRect(fastBounds, boundsStroke); + + // Label the bounds size. + using var font = new SKFont { Size = 16 }; + using var label = new SKPaint { IsAntialias = true, Color = SKColors.OrangeRed }; + canvas.DrawText($"bounds {fastBounds.Width:0} x {fastBounds.Height:0}px", 12, height - 16, SKTextAlign.Left, font, label); + } + } +} diff --git a/tests/Tests/SkiaSharp/SKPaintTest.cs b/tests/Tests/SkiaSharp/SKPaintTest.cs index 1c728dd4b0e..df40125aaae 100644 --- a/tests/Tests/SkiaSharp/SKPaintTest.cs +++ b/tests/Tests/SkiaSharp/SKPaintTest.cs @@ -209,5 +209,138 @@ public void Clone() using var clonedPaint = paint.Clone(); using var clonedPaint2 = paint.Clone(); } + + [Fact] + [Trait(Traits.Category.Key, Traits.Category.Values.Smoke)] + public void GetFastBoundsWithBlurOutsetsByThreeSigma() + { + const float sigma = 4.5f; + using var blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, sigma); + using var paint = new SKPaint { MaskFilter = blur }; + + var src = new SKRect(10, 20, 110, 120); + Assert.True(paint.GetFastBounds(src, out var bounds)); + + // A fill paint adds no stroke inflation, so the blur mask filter outsets the + // source rect by 3 * sigma on every edge. + var pad = 3f * sigma; + Assert.Equal(src.Left - pad, bounds.Left, 3); + Assert.Equal(src.Top - pad, bounds.Top, 3); + Assert.Equal(src.Right + pad, bounds.Right, 3); + Assert.Equal(src.Bottom + pad, bounds.Bottom, 3); + } + + [Fact] + public void GetFastBoundsFillWithNoEffectsReturnsSource() + { + using var paint = new SKPaint(); + + var src = new SKRect(10, 20, 110, 120); + Assert.True(paint.GetFastBounds(src, out var bounds)); + + // Filling with no geometry-affecting effects cannot grow the bounds. + Assert.Equal(src, bounds); + } + + [Fact] + public void GetFastBoundsStrokeOutsetsByHalfStrokeWidth() + { + const float strokeWidth = 12f; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = strokeWidth, + StrokeJoin = SKStrokeJoin.Round, + StrokeCap = SKStrokeCap.Round, + }; + + var src = new SKRect(10, 20, 110, 120); + Assert.True(paint.GetFastBounds(src, out var bounds)); + + // Round join and round cap give an inflation multiplier of 1, so the rect + // outsets by strokeWidth / 2 on every edge. + var pad = strokeWidth / 2f; + Assert.Equal(src.Left - pad, bounds.Left, 3); + Assert.Equal(src.Top - pad, bounds.Top, 3); + Assert.Equal(src.Right + pad, bounds.Right, 3); + Assert.Equal(src.Bottom + pad, bounds.Bottom, 3); + } + + [Fact] + public void GetFastBoundsComposesStrokeAndBlur() + { + const float sigma = 5f; + const float strokeWidth = 8f; + using var blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, sigma); + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = strokeWidth, + StrokeJoin = SKStrokeJoin.Round, + StrokeCap = SKStrokeCap.Round, + MaskFilter = blur, + }; + + var src = new SKRect(20, 20, 120, 120); + Assert.True(paint.GetFastBounds(src, out var bounds)); + + // The paint composes effects: the stroke inflates by strokeWidth / 2, then the + // blur outsets that result by 3 * sigma. + var pad = (strokeWidth / 2f) + (3f * sigma); + Assert.Equal(src.Left - pad, bounds.Left, 3); + Assert.Equal(src.Top - pad, bounds.Top, 3); + Assert.Equal(src.Right + pad, bounds.Right, 3); + Assert.Equal(src.Bottom + pad, bounds.Bottom, 3); + } + + [Fact] + public void GetFastBoundsDoesNotModifySource() + { + using var blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 5f); + using var paint = new SKPaint { MaskFilter = blur }; + + var src = new SKRect(10, 10, 60, 60); + var original = src; + + Assert.True(paint.GetFastBounds(src, out _)); + + Assert.Equal(original, src); + } + + [Fact] + public void GetFastBoundsReturnsTrueForSimplePaints() + { + var src = new SKRect(0, 0, 50, 50); + + using var fill = new SKPaint(); + Assert.True(fill.GetFastBounds(src, out _)); + + using var blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6f); + using var blurred = new SKPaint { MaskFilter = blur }; + Assert.True(blurred.GetFastBounds(src, out _)); + + using var stroked = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 4 }; + Assert.True(stroked.GetFastBounds(src, out _)); + } + + [Fact] + public void GetFastBoundsMatchesNativeApi() + { + const float sigma = 3.25f; + using var blur = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, sigma); + using var paint = new SKPaint { MaskFilter = blur }; + + var src = new SKRect(5, 15, 95, 135); + + Assert.True(paint.GetFastBounds(src, out var managed)); + + SKRect native; + unsafe + { + SkiaApi.sk_paint_compute_fast_bounds(paint.Handle, &src, &native); + } + + Assert.Equal(native, managed); + } } }