From fd206b024be32963df10b7610f9584b396dfd67f Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sun, 28 Jun 2026 13:12:57 +0200 Subject: [PATCH] Bind SKPaint.GetFastBounds Expose the public SkPaint::computeFastBounds / SkPaint::canComputeFastBounds pair as a single SKPaint.GetFastBounds(SKRect bounds, out SKRect fastBounds) method. It returns the conservative device-space bounds a paint can draw into for a given source rect, composing the path effect, stroke radius, mask filter and image filter. Together with the already-bound SKCanvas.QuickReject this completes the documented quick-reject culling pattern. Skia's contract is that canComputeFastBounds() is a guard that must be checked before calling computeFastBounds(); the latter is unreliable for paints whose image filter or path effect cannot report bounds. Collapsing the two into one Try-style method folds that guard into the bool return value, so the API cannot be misused and matches the existing SKPath.GetBounds / SKPath.GetTightBounds and SKCanvas.GetLocalClipBounds out-bool family (note there is likewise no CanGetTightBounds). GetFastBounds returns false (and SKRect.Empty) when the paint cannot compute fast bounds. This binds the public SkPaint API rather than the private SkMaskFilterBase::computeFastBounds, which has been an internal implementation detail since 2018. SkPaint::computeFastBounds composes the mask filter bounds internally, so it is a strict superset of the per-mask-filter method. - C API: sk_paint_compute_fast_bounds, sk_paint_can_compute_fast_bounds - Regenerated SkiaApi.generated.cs - Tests in SKPaintTest (blur 3x sigma, stroke half-width, composition, fill no-op, native parity, returns-true for simple paints) - Gallery sample PaintFastBoundsSample - Benchmark PaintFastBoundsBenchmark (quick-reject culling) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Benchmarks/PaintFastBoundsBenchmark.cs | 111 +++++++++++++++ binding/SkiaSharp/SKPaint.cs | 17 +++ binding/SkiaSharp/SkiaApi.generated.cs | 41 ++++++ externals/skia | 2 +- .../Shared/Samples/PaintFastBoundsSample.cs | 125 ++++++++++++++++ tests/Tests/SkiaSharp/SKPaintTest.cs | 133 ++++++++++++++++++ 6 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 benchmarks/SkiaSharp.Benchmarks/Benchmarks/PaintFastBoundsBenchmark.cs create mode 100644 samples/Gallery/Shared/Samples/PaintFastBoundsSample.cs 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); + } } }