DrawingCanvas API: Replace imperative extension methods with stateful canvas-based drawing model

Merged
JI
6mo ago
+72394-40351

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following matches the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules :cop:.
  • I have provided test coverage for my change (where applicable)

Breaking Changes: DrawingCanvas API

Fix #106
Fix #244
Fix #344
Fix #367

This is a major breaking change. The library's public drawing API has been redesigned around a canvas-based model, replacing the previous collection of imperative drawing extension methods.

What changed

The old API surface — dozens of IImageProcessingContext extension methods like DrawLine(), DrawPolygon(), FillPolygon(), DrawBeziers(), DrawImage(), DrawText(), etc. — has been removed. These methods were individually simple, but had several architectural limitations:

  • Each call was an independent image processor that rasterized and composited in isolation, making batching impossible.
  • State such as blending, clipping, and transforms had to be supplied repeatedly.
  • Alternate rendering backends had no clean way to intercept or accelerate a sequence of draw calls.
  • Reusing complex geometry across frames was not possible through the public drawing API.

The new model: DrawingCanvas

All drawing now goes through DrawingCanvas, a stateful canvas that records drawing commands into an ordered timeline.

DrawingCanvas<TPixel> remains the typed implementation used internally where the pixel format is required for brush normalization, readback, and backend execution. Public factory methods return DrawingCanvas, so CPU and WebGPU entry points expose the same canvas-facing API.

Via Image.Mutate() (most common)

using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;

image.Mutate(ctx => ctx.Paint(canvas =>
{
    canvas.Fill(Brushes.Solid(Color.Red), new EllipsePolygon(200, 200, 100));
    canvas.Draw(Pens.Solid(Color.Blue, 3), new RectangularPolygon(50, 50, 200, 100));
    canvas.DrawLine(Pens.Solid(Color.Green, 2), new PointF(0, 0), new PointF(100, 100));

    canvas.DrawText(
        new RichTextOptions(font) { Origin = new PointF(10, 10) },
        "Hello, World!",
        brush: Brushes.Solid(Color.Black),
        pen: null);

    canvas.DrawImage(sourceImage, sourceRect, destinationRect);

    canvas.Save(new DrawingOptions
    {
        GraphicsOptions = new GraphicsOptions { BlendPercentage = 0.5f }
    });

    canvas.Fill(brush, path);
    canvas.Restore();

    canvas.Apply(path, inner => inner.Brightness(0.5f));

    // Commands are rendered when the Paint canvas is disposed.
}));

Canvas state management

The canvas supports a save/restore stack, similar to HTML Canvas or SkCanvas:

int saveCount = canvas.Save();              // Push current state.
canvas.Save(options, clipPath1, clipPath2); // Push and replace state.

canvas.Restore();            // Pop one level.
canvas.RestoreTo(saveCount); // Pop to a specific level.

State includes DrawingOptions (graphics options, shape options, transform) and clip paths. SaveLayer(...) creates an isolated layer entry in the canvas timeline. The layer is closed by Restore() or RestoreTo(...) and is composited when the canvas timeline is rendered.

Apply(...) is also represented in the same timeline. It acts as a barrier: drawing before the barrier is rendered first, the requested image operation is applied to the target region, and drawing after the barrier continues in order.

Retained scenes

The canvas can create reusable retained scenes:

DrawingBackendScene background;

using (DrawingCanvas canvas = image.Frames.RootFrame.CreateCanvas(image.Configuration, new DrawingOptions()))
{
    DrawBackground(canvas);
    background = canvas.CreateScene();
}

image.Mutate(ctx => ctx.Paint(canvas =>
{
    canvas.RenderScene(background);
    DrawMovingObjects(canvas);
}));

CreateScene() converts the currently queued drawing commands into a backend scene. It does not render to the target.

RenderScene(scene) records an existing retained scene into the current canvas timeline. It does not render immediately. Any pending commands are sealed first, so normal drawing, retained scene replay, Flush(), layers, and Apply(...) barriers all preserve submission order.

This enables scenarios such as rendering a static background scene once, then replaying it repeatedly while drawing changing foreground content over it.

Flush()

Flush() seals the currently queued drawing commands into the canvas timeline. It does not write to the target by itself.

The root canvas renders the timeline when disposed. Paint(...) owns that disposal for the common Image.Mutate(...) path.

IDrawingBackend — bring your own renderer

Rasterization and composition are abstracted behind IDrawingBackend.

The canvas owns command ordering. Backends do not receive individual drawing calls; they receive prepared command batches and turn them into retained backend scenes.

MethodPurpose
CreateSceneConverts one prepared DrawingCommandBatch into a retained backend scene. This does not render to the target.
RenderScene<TPixel>Renders a retained backend scene into the target frame.
ReadRegion<TPixel>Reads pixels back from the target for operations that need current destination pixels, such as Apply(...).

The library ships with two backend implementations: DefaultDrawingBackend, the CPU backend built around a tiled fixed-point rasterizer, and WebGPUDrawingBackend, the WebGPU backend for native GPU surfaces. Both implement the same retained-scene contract, so callers use the same canvas API whether rendering through CPU memory or WebGPU targets.

Backends are registered on Configuration:

configuration.SetDrawingBackend(myCustomBackend);

The public DrawingCanvas API stays backend-neutral. Backend-specific retained data is hidden behind DrawingBackendScene.

Migration guide

Old APINew API
ctx.Fill(color, path)ctx.Paint(c => c.Fill(Brushes.Solid(color), path))
ctx.Fill(brush, path)ctx.Paint(c => c.Fill(brush, path))
ctx.Draw(pen, path)ctx.Paint(c => c.Draw(pen, path))
ctx.DrawLine(pen, points)ctx.Paint(c => c.DrawLine(pen, points))
ctx.DrawPolygon(pen, points)ctx.Paint(c => c.Draw(pen, new Polygon(new LinearLineSegment(points))))
ctx.FillPolygon(brush, points)ctx.Paint(c => c.Fill(brush, new Polygon(new LinearLineSegment(points))))
ctx.DrawText(text, font, color, origin)ctx.Paint(c => c.DrawText(new RichTextOptions(font) { Origin = origin }, text, Brushes.Solid(color), null))
ctx.DrawImage(overlay, opacity)ctx.Paint(c => c.DrawImage(overlay, sourceRect, destRect))
Multiple independent draw callsSingle Paint(...) block; commands are ordered through one canvas timeline

Other breaking changes in this PR

  • AntialiasSubpixelDepth removed — The rasterizer now uses fixed 24.8 coordinate precision. The old property controlled vertical subpixel sampling depth, but the new fixed-point scanline rasterizer integrates area/cover analytically per cell rather than sampling discrete subpixel rows.
  • GraphicsOptions.Antialias — now controls RasterizationMode (antialiased vs aliased). When false, coverage is snapped to binary using AntialiasThreshold.
  • GraphicsOptions.AntialiasThreshold — new property (0–1, default 0.5) controlling the coverage cutoff in aliased mode.

Benchmarks

All benchmarks run under the following environment.

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.26200
Unknown processor
.NET SDK=10.0.103
  [Host] : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT

Toolchain=InProcessEmitToolchain  InvocationCount=1  IterationCount=40
LaunchCount=3  UnrollFactor=1  WarmupCount=40

DrawPolygonAll - Renders a 7200x4800px path of the state of Mississippi with a 2px stroke.

MethodMeanErrorStdDevMedianRatioRatioSD
SkiaSharp42.20 ms2.197 ms6.976 ms38.18 ms1.000.00
SystemDrawing44.10 ms0.172 ms0.538 ms44.05 ms1.070.16
ImageSharp12.09 ms0.083 ms0.269 ms12.06 ms0.290.05
ImageSharpWebGPU12.47 ms0.291 ms0.940 ms12.71 ms0.300.05

FillParis - Renders a 1096x1060px scene containing 50K fill paths.

MethodMeanErrorStdDevMedianRatioRatioSD
SkiaSharp103.165 ms0.4633 ms1.4574 ms103.323 ms1.000.00
SystemDrawing143.026 ms0.6269 ms1.9901 ms142.638 ms1.390.02
ImageSharp48.544 ms0.7920 ms2.3353 ms48.342 ms0.470.02
ImageSharpRetainedScene17.879 ms0.4229 ms1.3728 ms18.168 ms0.170.01
ImageSharpWebGPU24.841 ms0.4839 ms1.5006 ms24.857 ms0.240.02
ImageSharpWebGPURetainedScene5.476 ms0.3023 ms0.9813 ms4.933 ms0.050.01