DrawingCanvas API: Replace imperative extension methods with stateful canvas-based drawing model#377
DrawingCanvas API: Replace imperative extension methods with stateful canvas-based drawing model#377JimBobSquarePants wants to merge 138 commits intomainfrom
Conversation
There was a problem hiding this comment.
This is massive. Before I would jump into the code I would need some help building up my understanding, ideally a better architectural doc.
It's a big opportunity that AI cuts the time needed to introduce such features, but the implementation is likely far from perfect, so humans need to jump into the loop and the vast mass of the change makes things quite difficult to start that work for a reviewer. Having better and more human docs is my best idea to address this problem.
| ELSE IF ProcessorCount >= 2 | ||
| -> Parallel.For across tiles (band-sorted edges) |
There was a problem hiding this comment.
For high-load server workloads it is very likely not good to have parallelism on by default since things will be running pretty much in parallel and saturate the CPU-s anyways. No matter how great is the algorithm, there will be some overhead around the parallelization because of CPU cache contention. With highly parallel processing at request/image level, all that Parallel.For is adding is the contention overhead.
We need to see benchmark results demonstrating Parallel Efficiency:
This would mean comparing the single-threaded execution time to the parallel one. In an ideal world the parallel run should be MaxDegreeOfParallelism times faster, in reality it won't be the case. We need to see how the value SingleThreadedTime compares to the ParallelTime * MaxDegreeOfParallelism.
| `DrawingCanvas<TPixel>` is the high-level drawing API. It manages a state stack, command batching, layer compositing, and delegates rasterization to an `IDrawingBackend`. It implements a deferred command model—draw calls queue `CompositionCommand` objects in a batcher, which are flushed to the backend on `Flush()` or `Dispose()`. | ||
|
|
||
| ## Class Structure | ||
|
|
||
| ```text | ||
| DrawingCanvas<TPixel> : IDrawingCanvas, IDisposable | ||
| Fields: | ||
| configuration : Configuration | ||
| backend : IDrawingBackend | ||
| targetFrame : ICanvasFrame<TPixel> (root frame, immutable) | ||
| batcher : DrawingCanvasBatcher<TPixel> (reassigned on SaveLayer/Restore) | ||
| savedStates : Stack<DrawingCanvasState> (min depth 1) | ||
| layerDataStack : Stack<LayerData<TPixel>> (one per active SaveLayer) | ||
| pendingImageResources : List<Image<TPixel>> (temp images awaiting flush) | ||
| isDisposed : bool | ||
| ``` |
There was a problem hiding this comment.
It would be extremely helpful for the review to see the big picture before jumping into the code, and ideally do it without reverse-engineering everything. I hoped this document would be helpful, but unfortunately it is not useful as a starting point for me in it's current state.
After mentioning command batching as the architectural basis, it jumps into all kinds of details (class structure, methods, properties bunch of new terms etc.) cross referencing each other which are not (yet) relevant big-picture wise. I'm missing the part that would explain the architecture.
That part should be either human-written or written with strong human guidance and should try really hard to introduce the thing to a newcomer:
- What are the most important problems the unification of various backends (CPU, GPU, vector files) brings us?
- What are key ideas for the solution?
- How do those ideas translate into architectural terms and what do those terms exactly mean (eg. layers, canvas frames - for me those can have different meanings and I really don't understand what do they mean or what purpose do they serve in this particular architecture)
| namespace SixLabors.ImageSharp.Drawing.Processing.Backends; | ||
|
|
||
| /// <summary> | ||
| /// One normalized composition command queued by <see cref="DrawingCanvasBatcher{TPixel}"/>. |
There was a problem hiding this comment.
| /// One normalized composition command queued by <see cref="DrawingCanvasBatcher{TPixel}"/>. | |
| /// A normalized composition command queued by <see cref="DrawingCanvasBatcher{TPixel}"/>. |
Is there any way to efficiently improve the LLM-generated docs?
What is normalization of a command? Such concepts should be explained either in architectural docs or around the types they are being introduced.
| /// <summary> | ||
| /// One normalized composition command queued by <see cref="DrawingCanvasBatcher{TPixel}"/>. | ||
| /// </summary> | ||
| public readonly struct CompositionCommand |
There was a problem hiding this comment.
I don't see how does the command define what it will be drawing. Or is it more complicated?
| { | ||
| private const int WindowWidth = 800; | ||
| private const int WindowHeight = 600; | ||
| private const int BallCount = 50; |
There was a problem hiding this comment.
Pushing this up to 1000 brings FPS down to 20-40 depending on whether there is text in the background or not. HTML canvas still runs smoothly with that amount balls on my machine (+much more text +gradient fill).
We need to understand where is the bottleneck.
|
Btw, once I get going, my strategy would be to implement a backend on top of a modern 2D renderer like Vello or Skia Graphite. I don't trust LLM output for relatively new tech like WebGPU, I think there's insufficient training material out there. |
Prerequisites
Breaking Changes: DrawingCanvas API
Fix #106
Fix #244
Fix #344
Fix #367
This is a major breaking change. The library's public API has been completely redesigned around a canvas-based drawing model, replacing the previous collection of imperative extension methods.
What changed
The old API surface — dozens of
IImageProcessingContextextension methods likeDrawLine(),DrawPolygon(),FillPolygon(),DrawBeziers(),DrawImage(),DrawText(), etc. — has been removed entirely. These methods were individually simple but suffered from several architectural limitations:The new model:
DrawingCanvasAll drawing now goes through
IDrawingCanvas/DrawingCanvas<TPixel>, a stateful canvas that queues draw commands and flushes them as a batch.Via
Image.Mutate()(most common)Standalone usage (without
Image.Mutate)DrawingCanvas<TPixel>can be constructed directly against an image frame:Canvas state management
The canvas supports a save/restore stack (similar to HTML Canvas or SkCanvas):
State includes
DrawingOptions(graphics options, shape options, transform) and clip paths.SaveLayercreates an offscreen layer that composites back onRestore.IDrawingBackend— bring your own rendererThe library's rasterization and composition pipeline is abstracted behind
IDrawingBackend. This interface has the following methods:FlushCompositions<TPixel>TryReadRegion<TPixel>Process()andDrawImage()).ComposeLayer<TPixel>CreateLayerFrame<TPixel>SaveLayer.ReleaseFrameResources<TPixel>The library ships with
DefaultDrawingBackend(CPU, tiled fixed-point rasterizer). An experimental WebGPU compute-shader backend (ImageSharp.Drawing.WebGPU) is also available, demonstrating how alternate backends plug in. Users can provide their own implementations — for example, GPU-accelerated backends, SVG emitters, or recording/replay layers.Backends are registered on
Configuration:Migration guide
ctx.Fill(color, path)ctx.ProcessWithCanvas(c => c.Fill(Brushes.Solid(color), path))ctx.Fill(brush, path)ctx.ProcessWithCanvas(c => c.Fill(brush, path))ctx.Draw(pen, path)ctx.ProcessWithCanvas(c => c.Draw(pen, path))ctx.DrawLine(pen, points)ctx.ProcessWithCanvas(c => c.DrawLine(pen, points))ctx.DrawPolygon(pen, points)ctx.ProcessWithCanvas(c => c.Draw(pen, new Polygon(new LinearLineSegment(points))))ctx.FillPolygon(brush, points)ctx.ProcessWithCanvas(c => c.Fill(brush, new Polygon(new LinearLineSegment(points))))ctx.DrawText(text, font, color, origin)ctx.ProcessWithCanvas(c => c.DrawText(new RichTextOptions(font) { Origin = origin }, text, Brushes.Solid(color), null))ctx.DrawImage(overlay, opacity)ctx.ProcessWithCanvas(c => c.DrawImage(overlay, sourceRect, destRect))ProcessWithCanvasblock — commands are batched and flushed togetherOther breaking changes in this PR
AntialiasSubpixelDepthremoved — The rasterizer now uses a fixed 256-step (8-bit) subpixel depth. The oldAntialiasSubpixelDepthproperty (default: 16) controlled how many vertical subpixel steps the rasterizer used per pixel row. The new fixed-point scanline rasterizer integrates area/cover analytically per cell rather than sampling at discrete subpixel rows, so the "depth" is a property of the coordinate precision (24.8 fixed-point), not a tunable sample count. 256 steps gives ~0.4% coverage granularity — more than sufficient for all practical use cases. The old default of 16 (~6.25% granularity) could produce visible banding on gentle slopes.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. Pixels with coverage at or above this value become fully opaque; pixels below are discarded.Benchmarks
The DrawPolygonAll benchmark renders a 7200x4800px path of the state of Mississippi with a 2px stroke.
Due to the fused design of our rasterizer, we're absolutely dominating. 🚀🚀🚀🚀🚀