Canvas: fix five Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData) - #1824
Open
bkaradzic-microsoft wants to merge 3 commits into
Open
Conversation
…e, gradients, createImageData) These are backend-agnostic nanovg/polyfill bugs found while bringing up a WebGPU canvas backend; they affect the existing bgfx path identically. 1. `ImageData.data` returned a fresh copy on every property access `GetData` allocated a new typed array and memcpy'd into it each time it was read, so JS mutated one copy while `putImageData`/consumers read another. That silently discarded every write and broke the standard `getImageData -> mutate -> putImageData` idiom. The backing array is now allocated once in the constructor, read into directly, held in a `Napi::Reference`, and returned as the same live object. It is also now a `Uint8ClampedArray` as the spec requires: a plain `Uint8Array` wraps out-of-range writes modulo 256, so saturating arithmetic in JS (`data[i] = v + 40`) silently darkened pixels. 2. `clip()` suppressed `beginPath()` for later draws `Clip()` is implemented with `nvgScissor`, which is path-independent and never consumes the current path. Despite that, `FillRect`, `ClearRect` and all three `DrawImage` branches skipped `nvgBeginPath` whenever a clip was active. Every draw after a `clip()` therefore appended to one ever-growing path, and each fill repainted the union of every rect added since the clip using the newest paint. Per spec these three operations neither read nor modify the current path, so `beginPath` is now unconditional. The `m_isClipped` flag existed only to gate this and is removed. This mostly went unnoticed because most controls issue a single `fillRect` after `clip()` that coincides with the clip rect. It only shows up when several differently-sized rects or images are drawn under one clip - e.g. a GUI ColorPicker, whose saturation gradient smeared over its own colour wheel. 3. 3-argument `drawImage(img, dx, dy)` anchored the pattern at (0,0) The 5- and 9-argument branches correctly build the image pattern at `(dx,dy)`, but the 3-argument branch used `(0,0)` while still drawing the rect at `(dx,dy)`. The rect then sampled outside the pattern extent and clamped to the edge texels, so the call silently drew nothing for any offset other than `(0,0)`. 4. Gradients ignored their own geometry `BindFillStyle` built the paint from the *shape being filled* (`nvgImagePattern(0, 0, width + left, height, 0, ...)`) instead of the gradient's `(x0,y0)->(x1,y1)`. Every gradient was forced horizontal, anchored at the canvas origin and stretched to the wrong length, so vertical gradients rendered horizontally and all stop positions were wrong. `CanvasGradient::Paint()` now orients the pattern along the gradient vector via `atan2` and spans exactly that distance; radial gradients map the baked ramp onto the outer circle's bounding box. Sampling outside the extent clamps to the edge texel, which is the "pad" behavior the spec requires beyond the end stops. Also fixed in the ramp baking: the sample buffer was uninitialized (samples outside the stop range read stack garbage) and `gradientSpan` divided by zero when two stops shared an offset. 5. `createImageData` was missing Neither context implemented it, so `ctx.createImageData(...)` threw `is not a function`. Implemented for both overloads - `(width, height)` and `(imagedata)` - returning transparent black, taking the magnitude of negative extents, and rejecting zero and overflowing sizes. Validated against the Playground validation suite: 625 -> 630 passing, 53 -> 47 failing. The GUI test's pixel difference went from 13.5% to 3.9% (allowed 4%), with the ColorPicker colour wheel and saturation square now matching the reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Contributor
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Fixes multiple Canvas2D correctness issues in the shared canvas polyfill (ImageData semantics, clipping/path behavior, drawImage anchoring, gradient geometry, and missing createImageData).
Changes:
- Make
ImageData.dataa stable, spec-correct clamped typed array instead of returning a fresh copy each access. - Fix path handling under
clip()and correct 3-argdrawImage()pattern anchoring. - Implement gradient paints based on gradient geometry (plus ramp baking fixes) and add
createImageData()overloads.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Polyfills/Canvas/Source/ImageData.h | Store and reuse a persistent JS typed array for ImageData.data. |
| Polyfills/Canvas/Source/ImageData.cpp | Allocate clamped backing array once, read pixels into it, and return it unchanged. |
| Polyfills/Canvas/Source/Gradient.h | Add CanvasGradient::Paint() API to produce geometry-correct NanoVG paint. |
| Polyfills/Canvas/Source/Gradient.cpp | Fix ramp baking edge cases and implement paint generation for linear/radial gradients. |
| Polyfills/Canvas/Source/Context.h | Add createImageData() and simplify BindFillStyle signature. |
| Polyfills/Canvas/Source/Context.cpp | Register/implement createImageData(), fix clip/path behavior, fix drawImage anchoring, use gradient->Paint(). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Clip() can only express a rectangle (nvgScissor), so the previous m_isClipped suppression of beginPath() was load-bearing for non-rectangular clip paths: it left the clip path current so the following fill would render it. Removing it outright regressed the 'Dynamic Texture context clip' validation test from a speech bubble to a solid white square. Instead, track whether the current path contains anything a scissor cannot express and only fall back to that emulation then. Rectangular clips -- what Babylon GUI uses -- now correctly begin a fresh path per fillRect, which is what stopped a GUI ColorPicker from smearing its saturation gradient over its own colour wheel. Also from review: guard the weak context lock in CanvasGradient::Paint and reject non-ImageData objects in createImageData(imagedata). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five Canvas2D correctness bugs in the shared canvas polyfill. All are backend-agnostic (nanovg / polyfill level), so they affect the existing bgfx path exactly as found. I hit them while bringing up a WebGPU canvas backend, but nothing here is WebGPU-specific.
Each fix is a separate commit-sized concern but they were found and validated together, and three of them have to land together to make the GUI ColorPicker render at all.
1.
ImageData.datareturned a fresh copy on every property accessGetDataallocated a new typed array and memcpy'd into it on each read. So JS mutated one copy whileputImageDataand friends read a different, still-blank one — silently discarding every write and breaking the standard idiom:The backing array is now allocated once in the constructor, read into directly, held in a
Napi::Reference, and the same live object is returned every time.It is also now a
Uint8ClampedArray, as the spec requires. A plainUint8Arraywraps out-of-range writes modulo 256, so ordinary saturating arithmetic (data[i] = v + 40) silently produced a dark pixel instead of a bright one.2.
clip()suppressedbeginPath()for every subsequent drawClip()is implemented withnvgScissor— path-independent, and it never consumes the current path. Despite that,FillRect,ClearRectand all threeDrawImagebranches skippednvgBeginPathwheneverm_isClippedwas set.Consequence: after any
clip(), every later draw appended to one ever-growing path, and each fill repainted the union of every rect added since the clip using the newest paint.Per spec
fillRect/clearRect/drawImageneither read nor modify the current path, sobeginPathis now unconditional.m_isClippedexisted only to gate this, so it's gone.This survived so long because most controls issue a single
fillRectafterclip()that happens to coincide with the clip rect, making the over-fill invisible. It only manifests when several differently-sized rects or images are drawn under one clip.3. 3-argument
drawImage(img, dx, dy)anchored the pattern at(0,0)The 5- and 9-argument branches correctly build the image pattern at
(dx,dy). The 3-argument branch used(0,0)while still drawing the rect at(dx,dy), so the rect sampled outside the pattern extent and clamped to the (transparent) edge texels.Net effect:
drawImage(img, x, y)silently drew nothing for any offset other than(0,0).4. Gradients ignored their own geometry
BindFillStylebuilt the paint from the shape being filled, not the gradient:So every gradient was forced horizontal, anchored at the canvas origin, and stretched to the fill rect's width — the
(x0,y0)->(x1,y1)passed tocreateLinearGradientwas discarded entirely. Vertical gradients rendered horizontally and all stop positions were wrong.CanvasGradient::Paint()now orients the pattern along the gradient vector viaatan2and spans exactly that distance. Radial gradients map the baked ramp onto the outer circle's bounding box. Sampling outside the extent clamps to the edge texel, which is exactly the "pad" behavior the spec requires beyond the end stops.Two related bugs in the ramp baking, fixed here too:
gradientSpandivided by zero when two stops shared an offset.5.
createImageDatawas missingNeither context implemented it, so
ctx.createImageData(...)threwis not a function. Implemented for both overloads —(width, height)and(imagedata)— returning transparent black, taking the magnitude of negative extents, and rejecting zero and size_t-overflowing sizes.Validation
Playground validation suite: 625 -> 630 passing, 53 -> 47 failing.
Newly passing:
RH billboard,Load GUI snippet with unicode,Parse GUI json with unicode,Synchronous Effect,Vertex Pulling - Normals UVs Colors Tangents,Vertex Pulling - UV Channels 1-6,needDepthPrePass extended material families,GUI,Particles - Flowmaps,Particles - Flowmaps 2.The
GUItest went from 13.5% -> 3.9% pixel difference (allowed 4%) across these fixes: 13.5% (baseline) -> 8.2% (clip) -> 6.4% (drawImage origin) -> 3.9% (gradients). Its ColorPicker colour wheel and saturation square now match the reference.Bugs 1, 2, 3 and 4 all had to be fixed before the ColorPicker rendered: it builds its wheel via
createCanvas+getImageData+ mutate +putImageData(bug 1), composites it with a 3-argdrawImageat a non-zero offset (bug 3), draws it and its saturation square under oneclip()(bug 2), and paints that square with two overlaid linear gradients, one of them vertical (bug 4).