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 307 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% 85%
=====================================
Files 101 107 +6
Lines 4529 8339 +3810
Branches 654 1066 +412
=====================================
+ Hits 3849 7168 +3319
- Misses 540 925 +385
- 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:
|
This means that it would be of key importance to validate the API with a simple GUI app that truly challenges the rendering engine! I just made this thing with Claude*. We need an equivalent C# app. It can of course do something else (as long as it does mass text rendering!), but we need to make sure we have an HTML5 reference app, and that its' functionality is kept in sync with our C# app's, so we can compare them.
We don't need it to be production ready, but if we find performance bottlenecks, we should identify where is the root cause: suboptimal backend implementation VS the API shape VS our library code. I can chime in into this work later, but not right now. *disclaimer: didn't spend time on deslopification |
@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 |
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.
|
|
@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. |
…ngBackendTests.cs Co-authored-by: Anton Firszov <antonfir@gmail.com>
…rp.Drawing into js/canvas-api
|
@antonfirsov I think we're in "perfect is the enemy of good" territory now. I'm going to merge this over the weekend unless you can find something showstopping. |







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.