DrawingCanvas API: Replace imperative extension methods with stateful canvas-based drawing model
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.
| Method | Purpose |
|---|---|
CreateScene | Converts 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 API | New 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 calls | Single Paint(...) block; commands are ordered through one canvas timeline |
Other breaking changes in this PR
AntialiasSubpixelDepthremoved — 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 controlsRasterizationMode(antialiased vs aliased). Whenfalse, coverage is snapped to binary usingAntialiasThreshold.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.
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD |
|---|---|---|---|---|---|---|
| SkiaSharp | 42.20 ms | 2.197 ms | 6.976 ms | 38.18 ms | 1.00 | 0.00 |
| SystemDrawing | 44.10 ms | 0.172 ms | 0.538 ms | 44.05 ms | 1.07 | 0.16 |
| ImageSharp | 12.09 ms | 0.083 ms | 0.269 ms | 12.06 ms | 0.29 | 0.05 |
| ImageSharpWebGPU | 12.47 ms | 0.291 ms | 0.940 ms | 12.71 ms | 0.30 | 0.05 |
FillParis - Renders a 1096x1060px scene containing 50K fill paths.
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD |
|---|---|---|---|---|---|---|
| SkiaSharp | 103.165 ms | 0.4633 ms | 1.4574 ms | 103.323 ms | 1.00 | 0.00 |
| SystemDrawing | 143.026 ms | 0.6269 ms | 1.9901 ms | 142.638 ms | 1.39 | 0.02 |
| ImageSharp | 48.544 ms | 0.7920 ms | 2.3353 ms | 48.342 ms | 0.47 | 0.02 |
| ImageSharpRetainedScene | 17.879 ms | 0.4229 ms | 1.3728 ms | 18.168 ms | 0.17 | 0.01 |
| ImageSharpWebGPU | 24.841 ms | 0.4839 ms | 1.5006 ms | 24.857 ms | 0.24 | 0.02 |
| ImageSharpWebGPURetainedScene | 5.476 ms | 0.3023 ms | 0.9813 ms | 4.933 ms | 0.05 | 0.01 |