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 303 commits intomainfrom
Conversation
|
I want to take a look at this, but it looks massive so the earliest time I can really get to it is around next weekend. Before jumping into the code there is an important general question however: how would describe typical use-cases for this feature? There are two that come into my mind; if you are envisioning the same ones, how do you weight their importance? |
Thanks, mate. I see these as a complete re-envisioning of the library and how it should work with both those targets in mind. There are MAJOR breaking changes*. The new API can support both scenarios but the WebGPU So, in short: Target 2, then 1. I'm changing the entire shape of the library to focus around the new The canvas is designed based on the best features of both System.Drawing *I'm confident that these changes are both necessary and beneficial. This moves the library square into the expectations bracket for users. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #377 +/- ##
=====================================
+ Coverage 84% 86% +1%
=====================================
Files 101 107 +6
Lines 4529 8285 +3756
Branches 654 1066 +412
=====================================
+ Hits 3849 7140 +3291
- Misses 540 899 +359
- Partials 140 246 +106
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…rp.Drawing into js/canvas-api # Conflicts: # src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.CompositePixels.cs # src/ImageSharp.Drawing.WebGPU/WebGPUTextureSampleTypeHelper.cs
|
@JimBobSquarePants assuming we are still working in shifts I may push trivial fixes here and there (like my previous commit). Let me know if it breaks your workflow! |
There was a problem hiding this comment.
Still looking at the backend API-s, and top-level behaviors.
I would still consider plumbing DeviceSetUncapturedErrorCallback to some visible API surface. If there will be users encountering issues from WebGPU (which has very realistic chance), that would enable diagnostics, both for us & them.
| nameof(destination)); | ||
| } | ||
|
|
||
| NativeCanvasFrame<TPixel> frame = WebGPUCanvasFactory.CreateFrame<TPixel>(this.Bounds, this.Surface); |
There was a problem hiding this comment.
What will happen if TPixel is not compatible with this.Format? Do we have a test for that case?
There was a problem hiding this comment.
If TPixel is not compatible with the target texture format, readback fails with NotSupportedException before any GPU copy/readback work is submitted.
WebGPURenderTarget.ReadbackInto<TPixel>(...) delegates to WebGPUDrawingBackend.ReadRegion<TPixel>(...), and that path maps TPixel to the expected WebGPUTextureFormat. It then compares that expected format with the target's native TargetFormat and throws if they differ.
We have coverage in WebGPUDeviceContextTests.CreateCanvas_RejectsInvalidHandles_AndReadbackRejectsMismatchedFormat: it creates the default Rgba8Unorm render target and attempts to read it into Image<Bgra32>, asserting NotSupportedException.
@antonfirsov I've already wired this up. The public API surface is WebGPUEnvironment.UncapturedError (line 19). The native hook is wired here:
|
@JimBobSquarePants sorry, I somehow missed it! Regarding the failures I see locally, I'm afraid they are showing a real bug. For example:
Reference output
Actual output
It's pretty random whether it appears or not but on my system it's pretty frequent. Any ideas if / how could I help debugging this? I have a Geforce RTX 3070 Laptop GPU. PS: I don't really understand why is there a strip on the left on the correct output. PS2: I have a theory that when these tests succeed, it's because |
| using PfnRequestAdapterCallback callbackPtr = PfnRequestAdapterCallback.From(Callback); | ||
| RequestAdapterOptions options = new() | ||
| { | ||
| PowerPreference = PowerPreference.HighPerformance |
There was a problem hiding this comment.
If we wanted to make this (or any other wgpu setup details) user-configurable in the future, how would we do it?
There was a problem hiding this comment.
I'd treat adapter/device setup as environment-level configuration, not per-window or per-surface configuration.
For example, we could add a WebGPUEnvironmentOptions type and a WebGPUEnvironment.Configure(...) entry point, using ImageSharp.Drawing-owned enums/options rather than exposing Silk types. WebGPURuntime would read those options when it first creates the library-managed instance/adapter/device.
The important constraint is that these settings have to be supplied before the shared WebGPU device is created. Once ProbeAvailability(), WebGPURenderTarget, WebGPUWindow, etc. have caused device creation, changing adapter/device options would require resetting or recreating the environment.
Surface-specific settings, like format and present mode, should stay on WebGPUWindowOptions / WebGPUExternalSurfaceOptions; device-selection and wgpu setup details belong on the environment.
There was a problem hiding this comment.
Added options for power. We read everything else from the device.
Thanks, this is really helpful. I still can’t reproduce it locally, but the screenshot makes it look like the The actual output looks like negative repeat coordinates are not wrapping correctly on your RTX path. The WebGPU shader still had some Vello-style generic image sampling code in this area, including repeat handling intended for broader filtered image sampling. For our I’ve updated the WGSL path so ((value % length) + length) % lengthI also removed the unused image-quality/bilinear branch from the shader. There is nothing “low quality” about nearest sampling here; it is the current Could you please retry the failing WebGPU tests on the GeForce adapter when you get a chance? |
|
Looks like the fix worked! Now it's down to 9 failures where pixel differences don't seem to be visually significant, it's probably a floating point difference between HW. The tolerance values need to be tuned in these tests though.
|
| DebugSaveBackendPair(provider, $"DrawPath_PointStroke_LineCap_{lineCap}", defaultImage, nativeSurfaceImage); | ||
| AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.03F); | ||
| AssertBackendPairReferenceOutputs(provider, $"DrawPath_PointStroke_LineCap_{lineCap}", defaultImage, nativeSurfaceImage); |
There was a problem hiding this comment.
Nit: some of the tests are adding these redundant strings, which makes the output files longer and less convenient to work with.
|
@JimBobSquarePants I think we are getting very close in the sense that major user-facing issues and maintainability concerns are probably gone. This weekend I will be away from computers, but I'm planning to give this a final run next week. Amongst other things I'll be looking for dead code, unnecessary or weird public APIs. I'd recommend you to do a thorough file-by-file self review hunting for these kind of leftovers to get to the finish line faster! PS: I will also tune the tolerances myself where needed. |
That's great to hear! I've updated the tolerances, cleaned up diagnostic properties from the public API and done a cleanup of some dead code leftover from trying to support more pixel formats early on (before I learned about restrictions). I've also trimmed the test output names. |
| private void FillIndexedTriangles(DrawingMesh mesh) | ||
| { | ||
| for (int i = 0; i < mesh.Indices.Length; i += 3) | ||
| { | ||
| MeshVertex v0 = mesh.Vertices[mesh.Indices[i]]; | ||
| MeshVertex v1 = mesh.Vertices[mesh.Indices[i + 1]]; | ||
| MeshVertex v2 = mesh.Vertices[mesh.Indices[i + 2]]; | ||
|
|
||
| this.FillTriangle(v0, v1, v2); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `TriangleStrip` consumes one new vertex per triangle and flips the first two vertices on alternating triangles so the winding remains consistent: | ||
|
|
||
| ```csharp | ||
| private void FillTriangleStrip(DrawingMesh mesh) | ||
| { | ||
| for (int i = 0; i <= mesh.Indices.Length - 3; i++) | ||
| { | ||
| int i0 = mesh.Indices[i]; | ||
| int i1 = mesh.Indices[i + 1]; | ||
| int i2 = mesh.Indices[i + 2]; | ||
|
|
||
| if ((i & 1) != 0) | ||
| { | ||
| (i0, i1) = (i1, i0); | ||
| } | ||
|
|
||
| this.FillTriangle(mesh.Vertices[i0], mesh.Vertices[i1], mesh.Vertices[i2]); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Both modes should share one helper: | ||
|
|
||
| ```csharp | ||
| private void FillTriangle(MeshVertex v0, MeshVertex v1, MeshVertex v2) | ||
| { | ||
| PointF[] points = | ||
| [ | ||
| v0.Position, | ||
| v1.Position, | ||
| v2.Position | ||
| ]; | ||
|
|
||
| Color[] colors = | ||
| [ | ||
| v0.Color, | ||
| v1.Color, | ||
| v2.Color | ||
| ]; | ||
|
|
||
| this.Fill(new PathGradientBrush(points, colors), new Polygon(points)); | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Is this a spec for shader code generation? (Otherwise it would be pretty expensive.)
| Rectangle textRegion = Rectangle.Intersect( | ||
| new Rectangle(0, 0, defaultImage.Width, defaultImage.Height), | ||
| new Rectangle(8, 12, defaultImage.Width - 16, Math.Min(220, defaultImage.Height - 12))); | ||
| AssertBackendPairSimilarityInRegion(defaultImage, nativeSurfaceImage, textRegion, 0.0157F); |
There was a problem hiding this comment.
| AssertBackendPairSimilarityInRegion(defaultImage, nativeSurfaceImage, textRegion, 0.0157F); | |
| AssertBackendPairSimilarityInRegion(defaultImage, nativeSurfaceImage, textRegion, 0.02F); |
Last remaining failure with
Image difference is over threshold!
Report ImageFrame 0:
Total difference: 0.0167%







Prerequisites
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
IImageProcessingContextextension methods likeDrawLine(),DrawPolygon(),FillPolygon(),DrawBeziers(),DrawImage(),DrawText(), etc. — has been removed. These methods were individually simple, but had several architectural limitations:The new model:
DrawingCanvasAll 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 returnDrawingCanvas, so CPU and WebGPU entry points expose the same canvas-facing API.Via
Image.Mutate()(most common)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.SaveLayer(...)creates an isolated layer entry in the canvas timeline. The layer is closed byRestore()orRestoreTo(...)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:
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, andApply(...)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 commonImage.Mutate(...)path.IDrawingBackend— bring your own rendererRasterization 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.
CreateSceneDrawingCommandBatchinto a retained backend scene. This does not render to the target.RenderScene<TPixel>ReadRegion<TPixel>Apply(...).The library ships with two backend implementations:
DefaultDrawingBackend, the CPU backend built around a tiled fixed-point rasterizer, andWebGPUDrawingBackend, 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:The public
DrawingCanvasAPI stays backend-neutral. Backend-specific retained data is hidden behindDrawingBackendScene.Migration guide
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))Paint(...)block; commands are ordered through one canvas timelineOther 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.
DrawPolygonAll - Renders a 7200x4800px path of the state of Mississippi with a 2px stroke.
FillParis - Renders a 1096x1060px scene containing 50K fill paths.