Skip to content

Canvas: fix 21 correctness and robustness bugs in the shared Canvas2D polyfill - #1824

Open
bkaradzic-microsoft wants to merge 12 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-shared-fixes
Open

Canvas: fix 21 correctness and robustness bugs in the shared Canvas2D polyfill#1824
bkaradzic-microsoft wants to merge 12 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-shared-fixes

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #1782.

21 Canvas2D 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.

Most are "the method threw not implemented but the caller was asking for nothing", or "the paint was built from the wrong geometry". Several have to land together: 1-4 are all required before the GUI ColorPicker renders at all, and 5-8 before a gradient-stroked GUI control renders at all.

Correctness

# Bug
1 ImageData.data returned a fresh copy on every access, so getImageData → mutate → putImageData silently no-op'd. It is also now the specified Uint8ClampedArray; a plain Uint8Array wrapped data[i] = v + 40 modulo 256, turning bright pixels dark
2 clip() suppressed beginPath() for every later draw, so each fill repainted the union of every rect since the clip
3 3-arg drawImage(img, dx, dy) anchored the pattern at (0,0) while drawing the rect at (dx,dy), so it drew nothing at any non-zero offset
4 Gradient paint was built from the shape being filled, not the gradient — every gradient came out horizontal, anchored at the canvas origin, with (x0,y0)→(x1,y1) discarded
5 strokeStyle accepted only a color string; a gradient threw TypeError: A string was expected and took the whole scene down (GUI Line, Button border)
6 Radial gradients discarded the inner circle, so every one came out concentric with r0 == 0. Now solves the real two-circle equation. The tangent case (leading coefficient vanishes → linear solve) needs its own branch and is not exotic — the repro's createRadialGradient(100,100,150, 250,100,300) lands there
7 Color stops were interpolated in straight alpha, so #ff0000ff → #00000000 faded through maroon to black. Now premultiplied as specified
8 fill() never bound the fill style, using whatever color nanovg happened to hold — never a gradient. Every path-filled control (Ellipse, Button background, Slider) filled solid white
9 createImageData was missing entirely
10 Path2D was registered only on _native, not as a global, so AbstractEngine.createCanvasPath2D threw ReferenceError on every engine except ThinNativeEngine
11 loadTTF failures surfaced as a bare Error: Invalid argument, naming neither method nor argument. Now descriptive, and any ArrayBuffer view is accepted — so the usual real cause, Tools.LoadFileAsync(url) without useArrayBuffer, is named directly
12 putImageData was an unconditional throw. Now writes through a transient nanovg image under nvgSave/nvgReset with NVG_COPY, so it correctly ignores transform, clip, globalAlpha, compositing, shadows and filters and replaces rather than blends
13 setLineDash threw — and GUI Line/MultiLine call it every render with _dash defaulting to [], so any scene with a GUI line died on an empty dash list asking for nothing. Empty now means "solid", which is what we already draw
14 The color regex matched components as 1-3 digit integers, so the very common rgba(0, 0, 0, 0.5) failed to parse. Now generic CSS numbers with optional %, plus the whitespace-separated and / alpha forms and hsl()/hsla()see the behaviour change below
15 All four shadow attributes threw from both getter and setter — and GUI resets shadowBlur/shadowOffset* to 0 right after drawing, so the throw hit the reset path too and any shadowed control failed outright. nanovg has no shadow primitive, but these are ordinary state attributes, so they are stored and reported
16 save()/restore() snapshotted only fillStyle/strokeStyle, but the wrapper mirrors everything it reprojects or that nanovg cannot report back — so lineCap, lineJoin, lineWidth, miterLimit, the dash list, filter, direction, letterSpacing, globalAlpha and the shadow attributes were never restored. The getters reported the post-save() value forever, and the shadow attributes, re-applied from the mirror on every draw, actually rendered wrong. All fifteen fields are now snapshotted
17 Coincident color stops were dropped — std::map::insert() discarded the second stop at an already-present offset, which is exactly how the spec encodes a hard transition, so a stripe pattern came out as a smooth fade. Now a std::multimap
18 setLineDash() cleared the list before validating, so setLineDash([-1]) after setLineDash([5,10]) left getLineDash() returning []

Two smaller gradient bugs were fixed alongside 4-8: the ramp sample buffer was uninitialized (samples outside the stop range read stack garbage), and gradientSpan divided by zero when two stops shared an offset.

Robustness

Three crash classes found by the Copilot review, all reachable from ordinary script rather than only from a misbehaving embedder:

# Bug
19 fillStyle = "rgb(999…999, 0, 0)", font = "18e999px Arial" and letterSpacing = "999…999px" made std::stof/std::stoi throw std::out_of_range. That is not a Napi::Error, so node-addon-api's wrapper did not catch it and it unwound out of the N-API boundary and terminated the process. Now strtof/strtol, with infinities folded to finite extremes so the existing clamps stay well defined (nvgHSLA takes fmodf of the hue, and fmodf(inf, 1) is NaN)
20 A gradient outliving its context: UpdateCache() dereferenced context.lock() three times unchecked, so the guard in Paint() ran after the crash
21 putImageData(img, 0, 0, 0, 0, -2147483648, 1) normalized negative dirty extents in int32_t, making dirtyX += dirtyWidth and -dirtyWidth signed overflow on caller-controlled input. Now int64_t, saturating back after clipping

19 is confirmed by experiment, not inspection: built without the fix, the unit test binary stops dead inside the Canvas2D suite with [Uncaught Error] Unknown failure and exit 1, taking the remaining ~20 tests with it. The pre-existing font guard covered only std::invalid_argument (the "normal" keyword), so out_of_range still went through.

One intentional behaviour change — please look at this closely

The parseColor unit test asserted rgba(16,32,48,64) → alpha 0x40, i.e. that alpha is a 0-255 channel like r/g/b. CSS Color defines alpha as 0-1 (or a percentage), so a browser clamps 64 to 1 and paints that colour opaque.

That reading was not a deliberate extension — the old regex matched all four components as 1-3 digit integers and ran std::stoi over them, so alpha simply inherited the channel treatment, and the test was written to describe what the implementation happened to do (it arrived with the impl in #1051). It is also the same root cause as bug 14: under a 0-255 alpha, rgba(0, 0, 0, 0.5) truncates to 0 and the colour vanishes. Alpha cannot be both, so the test now follows CSS, extended to cover fractional and percentage alpha, the / alpha form, whitespace-separated and percentage components, and hsl()/hsla(). All ten malformed-input cases still throw.

Deliberate limitations

  • Non-rectangular clip() is approximated. nanovg's scissor is rectangle-only and no stencil path is available, so the choice is between approximating and throwing; this keeps the rectangular case exact and degrades the other rather than failing the scene.
  • putImageData() clobbers the current path — as do fillRect, clearRect, drawImage and Path2D playback, none of which may touch it per spec. nanovg offers no local fix, since nvgSave/nvgRestore only push and pop NVGstate while nvgBeginPath clears ctx->ncommands and the path cache. A real fix means journaling the path wrapper-side and replaying it; self-contained, and better as a follow-up.

Validation

Full Playground validation sweep 304/304 PASS, no regressions; JavaScript.All unit tests 36/36.

Three previously-excluded tests are re-enabled and now pass inside the default 2.5% budget:

Test Before After Needs
GUI Gradient Linear (#XCPP9Y#17227) TypeError — never rendered 1.14% 5, then 8
GUI Gradient Radial (#4Z7EK3) 23.6% 0.62% 6 (tangent case)
GUI Gradient Linear with transparency (#PFK1Z5) 33.8% pixel-exact 7

The GUI test went 13.5% → 3.9% (allowed 4%) across bugs 1-4 — 13.5% → 8.2% (clip) → 6.4% (drawImage origin) → 3.9% (gradients) — because its ColorPicker builds its wheel via getImageData + mutate + putImageData (1), composites it with a 3-arg drawImage at a non-zero offset (3), draws it and its saturation square under one clip() (2), and paints that square with two overlaid linear gradients (4). Nine further tests newly pass across the same four fixes.

Bugs 10 and 11 are test-neutral and were verified directly, and new Canvas2D unit tests cover the contract that used to throw: both style round-trips, a CanvasGradient as fillStyle and as strokeStyle, a radial gradient from two independent circles, save/restore of a gradient strokeStyle and of all fifteen state fields, dash retention on a rejected argument, coincident stops, and three regressions for the out-of-range parse crashes.

One implementation note: DataView is read through its buffer/byteOffset/byteLength properties rather than Napi::DataView, because Chakra's Node-API backs napi_get_dataview_info with JsGetExternalData, which only succeeds for natively created DataViews — a JS-constructed one fails with napi_invalid_arg even though napi_is_dataview returns true. That shim inconsistency is a separate JsRuntimeHost issue; this change just avoids depending on it.

…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
Copilot AI lite review requested due to automatic review settings August 7, 2026 23:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.data a stable, spec-correct clamped typed array instead of returning a fresh copy each access.
  • Fix path handling under clip() and correct 3-arg drawImage() 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.

Comment thread Polyfills/Canvas/Source/ImageData.cpp
Comment thread Polyfills/Canvas/Source/ImageData.h
Comment thread Polyfills/Canvas/Source/Gradient.cpp
Comment thread Polyfills/Canvas/Source/Context.cpp
Comment thread Polyfills/Canvas/Source/Gradient.cpp
Copilot AI and others added 3 commits August 7, 2026 16:44
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
Path2D was registered only on the `_native` object, never as a global.
Browsers expose it as a global constructor and Babylon.js relies on that:
AbstractEngine.createCanvasPath2D does `return new Path2D(d)`. Only
ThinNativeEngine overrides that method to use `_native.Path2D`, so the
generic engine path -- and any portable browser code doing `new Path2D(...)`
directly -- failed on native with "ReferenceError: Path2D is not defined".

Register the constructor on the global object as well. The registration is
guarded so an existing global is never overwritten, matching how the Window
polyfill installs its globals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix five Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData) Canvas: fix six Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global) Aug 10, 2026
…errors

Canvas.loadTTF cast info[1] straight to Napi::ArrayBuffer. When a caller
passed anything else the napi cast threw a bare "Error: Invalid argument"
that named neither the method nor the offending argument, leaving no way
to tell which call failed or why.

In practice the cause is almost always a caller that fetched the font as
text rather than binary. Tools.LoadFileAsync's signature is
LoadFileAsync(url, useArrayBuffer); a text response decodes the font bytes
into an unusable string. The new message calls this out directly.

Adds GetFontDataArgument in Font.cpp. It validates arity and the font
name, accepts an ArrayBuffer or any ArrayBuffer view (typed array /
DataView) instead of only ArrayBuffer, rejects empty buffers, and
otherwise throws a descriptive Napi::TypeError.

The buffer validation stays inside the "not already loaded" guard so a
redundant re-registration of an already-loaded font keeps its existing
no-op behaviour.

DataView is read through its buffer/byteOffset/byteLength properties
rather than Napi::DataView on purpose: the Chakra Node-API implementation
backs napi_get_dataview_info with JsGetExternalData, which only succeeds
for DataViews created natively via napi_create_dataview. A DataView built
in JS fails with napi_invalid_arg even though napi_is_dataview reports
true. Property access behaves identically on V8 and Chakra.

Verified on Chakra and V8: every argument shape produces the same result,
and the Native Canvas validation test still passes unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix six Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global) Canvas: fix seven Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors) Aug 10, 2026
…ur parsing

Three more Canvas2D gaps, each of which aborted an entire scene rather than
degrading. All were already fixed on the Dawn branch and are shared bugs, so
they belong on master.

8. putImageData threw "not implemented"

The method was registered but its body was an unconditional throw, so the
standard getImageData -> mutate -> putImageData round trip was impossible.

It now writes the pixels through a transient nanovg image. putImageData is
specified to ignore the transform, clip region, globalAlpha, composite
operation, shadows and filters, and to replace the destination pixels rather
than blend with them, so the draw is wrapped in nvgSave/nvgReset and uses
NVG_COPY. The optional dirty rectangle is supported, including the spec's
negative-extent normalisation, and the CPU mirror that getImageData reads is
updated so a subsequent read observes the write.

9. setLineDash threw "not implemented"

nanovg cannot draw dashed strokes, but throwing was the wrong response:
Babylon GUI's Line and MultiLine controls call setLineDash(this._dash) on
every render and _dash defaults to [], so *any* scene containing a GUI line
died on an empty dash list that was asking for nothing.

An empty list now means "solid", which is what we already draw. A non-empty
pattern draws solid and warns once instead of failing the scene, and the list
is retained so the newly added getLineDash() round-trips as the spec requires.
Non-finite, negative and non-numeric entries are ignored per spec.

10. rgba() with a fractional alpha failed to parse

The colour regex matched components as 1-3 digit integers, so the extremely
common rgba(0, 0, 0, 0.5) matched nothing and fell through to the "Unable to
parse color" throw. Components are now matched as generic CSS numbers with an
optional % suffix, whitespace-separated forms and the "/ alpha" syntax are
accepted, and hsl()/hsla() -- which Babylon GUI's ColorPicker emits -- is
supported.

Verified on a Chakra/D3D11 build: the full CI validation set is 301/301 PASS
with no regressions. Direct API checks confirm rgba/hsl/percentage/slash-alpha
colours all parse, getLineDash round-trips [5,10] and ignores [-1],
putImageData round-trips pixels, honours a dirty rect, normalises negative
dirty extents, and ignores an active translate and globalAlpha.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix seven Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors) Canvas: fix ten Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing) Aug 10, 2026
bkaradzic and others added 2 commits August 10, 2026 16:03
The parseColor unit test asserted that rgba(16,32,48,64) yields an alpha of
0x40, i.e. that the fourth component is a 0-255 channel like r/g/b. CSS Color
defines alpha as a number in 0-1 (or a percentage), so every browser clamps 64
to 1 and paints that colour fully opaque.

The 0-255 reading was not a deliberate extension. The old regex matched all
four components as 1-3 digit integers and ran std::stoi over them, so alpha
simply inherited the channel treatment, and the test was written to describe
whatever the implementation happened to do.

That is the same root cause as the colour parsing fix in the previous commit:
under a 0-255 alpha the overwhelmingly common rgba(0, 0, 0, 0.5) truncates to
0 and disappears, which is exactly the bug being fixed. Alpha cannot be both.

So the test is updated to the CSS behaviour and extended to cover what the new
parser accepts: fractional alpha, percentage alpha, the "/ alpha" form,
whitespace-separated components, percentage channels, and hsl()/hsla(). The ten
malformed-input cases still throw.

Verified with the JavaScript.All unit test on a Chakra/D3D11 build: 24 passing,
including all ten ColorParsing rejection cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
All four shadow properties -- shadowColor, shadowBlur, shadowOffsetX and
shadowOffsetY -- threw "not implemented" from both their getter and their
setter.

nanovg has no shadow primitive, so shadows genuinely cannot be drawn, but
throwing was the wrong response. These are ordinary state attributes: reading
one, or writing the default, asks for nothing at all. Babylon GUI resets
shadowBlur/shadowOffsetX/shadowOffsetY to 0 immediately after drawing a
shadowed control, so the throw aborted the scene on the *reset* path as well as
on the request path, and any control carrying a drop shadow failed outright
rather than simply rendering without one.

The values are now stored and reported back, so the spec-required round trip
works and content that saves and restores canvas state keeps functioning.
Per spec, a negative or non-finite blur is ignored, a non-finite offset is
ignored, and an unparseable shadowColor leaves the previous value in place.
A shadow that is genuinely requested -- a non-zero blur or offset -- warns once
instead of failing the scene, matching how setLineDash handles a pattern it
cannot honor. Writing 0 is silent, because zero offset and zero blur draw no
shadow anyway.

Verified on a Chakra/D3D11 build. All eleven accesses that previously threw now
succeed, the defaults match the spec ("rgba(0, 0, 0, 0)" and 0), shadowBlur
round-trips, and the warning fires once and only for a real shadow request.
Full CI validation set: 301/301 PASS, no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix ten Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing) Canvas: fix eleven Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing, shadows) Aug 11, 2026
Fixes BabylonNative#1782. Three independent defects made GUI gradients
render incorrectly, or not at all, on the NanoVG canvas.

1. strokeStyle could only ever be a color string. `SetStrokeStyle`
   unconditionally cast the assigned value to a string, so any control
   that strokes with a gradient -- GUI Line, a Button's border -- threw
   "TypeError: A string was expected" and aborted the whole scene.
   `m_strokeStyle` is now the same std::variant<std::string,
   CanvasGradient*> that `m_fillStyle` already was, with a matching
   `BindStrokeStyle` bound from stroke(), strokeRect() and strokeText().

2. Radial gradients ignored the inner circle entirely. The ramp was
   baked as a disc centered in a square image with the focal point
   pinned at the center and the radius pinned to half the image, so
   (x0,y0,r0) were discarded and every gradient came out concentric.
   The field is now evaluated with the real two-circle equation over
   the bounding box that encloses both circles: for each texel, find
   the largest offset w with |p - lerp(c0,c1,w)| == lerp(r0,r1,w) and a
   non-negative radius. Every point on that box's border lies outside
   both circles, so it has already padded out to an end stop and
   NanoVG's clamp-to-edge sampling extends it correctly.

   The tangent-circle case (a == 0) has to be special-cased to a linear
   solve; it is not exotic -- createRadialGradient(100,100,150,
   250,100,300) from the repro playground lands there.

3. Color stops were interpolated in straight alpha. Canvas2D
   interpolates premultiplied, so a "#ff0000ff" -> "#00000000" ramp is
   supposed to stay red and only lose alpha, while straight
   interpolation dragged the RGB down to black. Both the linear and the
   radial paths now interpolate premultiplied and convert back to the
   straight alpha the baked image is sampled with.

While binding the new stroke paint it became clear that fill() never
bound the fill style at all -- it relied on whatever color NanoVG
happened to hold, which is never a gradient (assigning one only records
the pointer, since the paint has to be rebuilt per draw). Every
path-filled control -- Ellipse, Button background, Slider -- therefore
filled white. fill() now binds like fillRect() and fillText() already
did.

The three GUI gradient tests are re-enabled: "GUI Gradient Linear"
(previously a hard TypeError) now differs by 1.14%, "GUI Gradient
Radial" by 0.62% (was 15.3%), and "GUI Gradient Linear with
transparency" is pixel-exact (was 33.8%) -- all inside the default 2.5%
budget. Full Playground sweep: 304 pass, 0 regressions. Five Canvas2D
unit tests cover the strokeStyle contract and save/restore.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix eleven Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing, shadows) Canvas: fix fifteen Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing, shadows) Aug 11, 2026
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix fifteen Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData, Path2D global, loadTTF errors, putImageData, setLineDash, colour parsing, shadows) Canvas: fix fifteen Canvas2D correctness bugs in the shared polyfill Aug 11, 2026
@bkaradzic-microsoft
bkaradzic-microsoft requested a balanced review from Copilot August 11, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.

Suppressed comments (12)

Polyfills/Canvas/Source/Context.cpp:922

  • putImageData() must not modify the current path, but the implementation calls nvgBeginPath()/nvgRect() and then explicitly records that the path is now the upload rectangle. A path built before putImageData() is consequently lost, and a following stroke()/fill() targets the wrong geometry. Render the upload without replacing the user path, or preserve/replay the path around this internal draw.
        // The path now holds just this rect, so clear the non-rect flag: a stale `true` left over
        // from an earlier arc/curve would make a subsequent clip() take the emulated path branch.
        m_pathHasNonRect = false;

Polyfills/Canvas/Source/Context.h:122

  • These shadow attributes are also Canvas2D drawing state, but they are not included in SavedStyle; restore() only rewinds fill/stroke. A shadow value assigned after save() therefore survives restore(), contradicting the round-trip/state behavior described in the PR. Store and restore all four shadow fields.
        // Shadow attributes from shadowColor/shadowBlur/shadowOffsetX/shadowOffsetY.
        // Retained only so the getters round-trip; nanovg has no shadow primitive,
        // so nothing is ever drawn from them. Defaults are the spec's.
        std::string m_shadowColor{"rgba(0, 0, 0, 0)"};
        double m_shadowBlur{0.0};

Polyfills/Canvas/Source/Gradient.cpp:388

  • This still maps the old concentric texture onto (x1, y1, r1). RadialGradientStops() fixes its focal point at the texture center (fxp/fyp = 0) and never uses x0, y0, or r0, so non-concentric and nonzero-inner-radius gradients—including the tangent case named in the PR—remain incorrect. The baked field must evaluate the two-circle equation before this paint is mapped.
        // The radial ramp is baked into a square image whose inscribed circle is the r1
        // isoline, so map that image onto the bounding box of the outer circle.
        const float radius = std::max(r1, 1e-4f);
        return nvgImagePattern(*nvg, x1 - radius, y1 - radius, 2.f * radius, 2.f * radius, 0.f, cachedImage, 1.f);

Polyfills/Canvas/Source/Gradient.cpp:167

  • Zero-initializing the ramp does not implement the claimed premultiplied-alpha interpolation. gradientSpan() still interpolates raw r/g/b/a independently at lines 53-60, and lerpColor() does the same at lines 194-200, so transparent linear and radial stops still fade through dark straight-alpha colors. Premultiply RGB before interpolation and unpremultiply the baked sample afterward in both paths.
        // Zero-initialize: the spans below only cover the range the stops span, so any
        // sample left untouched would otherwise be read from uninitialized stack memory.
        uint32_t data[GRADIENT_SAMPLES_L]{};

Polyfills/Canvas/Source/Gradient.cpp:51

  • This guard cannot handle coincident color stops because colors is a std::map<float, NVGcolor> and AddColorStop() uses insert; the second stop at an identical offset is discarded before gradientSpan() runs. Hard transitions at duplicate offsets therefore remain wrong. Preserve duplicate stops with ordered sequence/multimap storage and process them in insertion order.
        // Coincident stops produce an empty span; the per-sample deltas below would divide
        // by zero and write NaNs into the ramp.
        if (e <= s)
        {
            return;

Polyfills/Canvas/Source/Context.cpp:159

  • The new binder is still not called by Context::Fill() (lines 266-281), which proceeds directly to nvgFill(). Assigning a gradient and then calling fill() therefore continues to use stale NanoVG paint, leaving the path-fill correctness fix described by the PR unimplemented. Bind the current fill style after any PlayPath2D() call and before nvgFill().
    void Context::BindFillStyle(const Napi::CallbackInfo& info)

Polyfills/Canvas/Source/Context.h:158

  • Only a fill-style binder is introduced. m_strokeStyle/SavedStyle::strokeStyle remain strings, SetStrokeStyle() still unconditionally casts to Napi::String, and stroke, strokeRect, and strokeText never bind a gradient paint. Thus the PR's core CanvasGradient stroke support still throws on assignment. Add the matching stroke variant/binder and invoke it for every stroke operation.
        void BindFillStyle(const Napi::CallbackInfo& info);

Polyfills/Canvas/Source/Context.cpp:448

  • Leaving the clip path current is not clipping: fillRect() appends its rectangle and fills the union of that rectangle and the clip path, while clearRect() and drawImage() call nvgBeginPath() and ignore this flag entirely. Non-rectangular clips therefore either paint the clip shape or are not applied. Use an actual clip mask/stencil implementation (or explicitly keep this unsupported) rather than treating the clip path as draw geometry.

This issue also appears on line 920 of the same file.

        // A non-rectangular clip path cannot be expressed as a scissor rectangle.
        // Emulate it by leaving the path current so the next fill draws it, and
        // leave any enclosing scissor untouched rather than clipping to a
        // rectangle this path never described.
        if (m_pathHasNonRect)
        {
            m_isClipped = true;
            return;

Polyfills/Canvas/Source/Context.cpp:1388

  • The comment says an invalid dash list retains the previous value, but m_lineDash is cleared before validation and cleared again here. For example, after [5, 10], setLineDash([-1]) incorrectly changes getLineDash() to []. Parse into a temporary vector and assign it only after every segment validates.
                    // Per spec, a list containing a non-finite or negative value
                    // is ignored entirely and the previous list is retained; a
                    // non-numeric entry cannot be interpreted, so ignore it too.
                    m_lineDash.clear();
                    return;

Polyfills/Canvas/Source/Context.cpp:1400

  • Canvas2D duplicates an odd-length dash sequence, so setLineDash([5]) must make getLineDash() return [5, 5]. The current code stores and returns only [5]. Duplicate the parsed sequence when its length is odd.
                m_lineDash.push_back(value);
            }
        }

Polyfills/Canvas/Source/Context.h:116

  • m_lineDash is new wrapper-side canvas state, but SavedStyle still contains only fill/stroke styles. Consequently save(); setLineDash(...); restore(); getLineDash() does not restore the saved pattern as Canvas2D requires. Include the dash vector in the state pushed and restored alongside these fields.

This issue also appears on line 118 of the same file.

        // Dash pattern from setLineDash. Retained only so getLineDash() round-trips;
        // strokes are always drawn solid (nanovg has no dashed stroke).
        std::vector<double> m_lineDash{};

Polyfills/Canvas/Source/Canvas.cpp:77

  • Buffer validation is still skipped when the font name already exists, because GetFontDataArgument() is only called inside this conditional. After one successful load, Canvas.loadTTF(name) or Canvas.loadTTF(name, "text") silently succeeds despite the PR's stated arity/type validation. Validate and extract argument 2 before the duplicate-name early-out, then discard the extracted bytes when retaining the existing font is intentional.
        if (fontsInfos.find(fontName) == fontsInfos.end())
        {
            fontsInfos[fontName] = GetFontDataArgument(info, 1, "Canvas.loadTTF");

Comment thread Polyfills/Canvas/Source/Gradient.cpp
Comment thread Polyfills/Canvas/Source/Context.cpp
Comment thread Polyfills/Canvas/Source/Colors.h Outdated
Follow-up to the Copilot review on BabylonJS#1824.

Colors.h, Font.cpp and Context.cpp all reached std::stof/std::stoi with a
value the regex admits but the target type cannot represent. The resulting
std::out_of_range is not a Napi::Error, so node-addon-api's callback wrapper
does not catch it: it unwinds out of the N-API callback and terminates the
process instead of surfacing as a JS exception. All three are reachable from
ordinary script:

  ctx.fillStyle = "rgb(999...999, 0, 0)"   // component of any length
  ctx.font = "18e999px Arial"              // the size regex accepts exponents
  ctx.letterSpacing = "999...999px"

Parsing now goes through strtof/strtol, which cannot throw: they saturate to
+/-HUGE_VALF on overflow. Non-finite results are folded back to finite
extremes so the existing clamps stay well defined, and a non-finite font size
or letter spacing is rejected rather than handed to nanovg (nvgHSLA takes
fmodf of the hue, which would be NaN for an infinity).

Gradient.cpp: UpdateCache() dereferenced context.lock() three times without
checking it, so the guard added to Paint() ran too late to help - the crash
had already happened inside UpdateCache(). The context is now locked once and
held for the whole bake, and LinearGradientStops/RadialGradientStops take it
by reference so they cannot observe it expiring midway. Dispose() locked twice
in a row, where the second lock could return empty; it now locks once.

Context.cpp: putImageData normalized negative dirty extents in int32_t, so a
dirtyWidth of INT32_MIN made both `dirtyX += dirtyWidth` and `-dirtyWidth`
signed overflow, which is undefined behavior on caller-controlled input. The
normalization and clipping now run in int64_t and saturate back to int32_t
after being clipped to the source bitmap.

Verified: the three parse tests kill the process without this change (exit 1,
"[Uncaught Error] Unknown failure", taking the remaining ~20 tests with it)
and pass with it. UnitTests 32 passing / exit 0, and the full Playground
comparison sweep is unchanged at 304 PASS / 0 fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

You're right, and thanks for catching it — that was a genuine miss on my part, not a stale view on your end.

canvas-shared-fixes was sitting two commits ahead of the remote locally, so 99ff0b8b (gradient semantics) and 7bd4ae14 (the putImageData / std::stof / null-context hardening) never made it to the PR. The branch is now pushed and the head is 7bd4ae14; CI is re-running.

The part worth flagging: as you say, the review threads were already resolved, so the PR read as clean and green while the code being described wasn't actually on it. That's the bad failure mode here — the green checkmark was real, it just wasn't covering the fixes under discussion. I've re-verified against the pushed tree that each resolved thread now corresponds to code that's actually there:

  • Colors.hstd::stof replaced with strtof, with the non-finite results folded back to finite extremes so the downstream clamps (and nvgHSLA's fmodf on hue) stay well defined.
  • Context.cpp putImageData — negative-extent normalization moved into int64_t, so negating INT32_MIN and summing two INT32_MINs are no longer signed overflow.
  • Gradient.cppcontext.lock() is locked once and checked for both a null shared_ptr and a null underlying context before any dereference, including on the UpdateCache() path that previously dereferenced it internally before the check.

I also confirmed the branch still merges cleanly onto current master — config.json is touched by both this branch and #1830, but it auto-merges without conflict.

Sorry for the round trip. Will wait for CI to come back green before asking for another look.

@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

CI is back green on 7bd4ae14 — 34/34, no failures. Ready for another look whenever you have time, @bghgary.

@matthargett

Copy link
Copy Markdown

Thanks for closing these conformance gaps! If we are consistently beating the 2.5% threshold of incorrect pixels, I'd recommend lowering it to tighten things up a bit now that so much of the screenshot testing stability and accuracy has made such huge strides.

@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Thanks! I agree with the direction, so I pulled the actual numbers rather than guess at the headroom.

Data is from this PR's green CI run (7bd4ae14), scraped from the Pixel difference: lines the harness logs per test. Validation runs on the Win32_x64_*_D3D11 jobs, and 254 of the comparisons use the default 2.5% (the other 12 tests in config.json already carry explicit higher errorRatio overrides, 4%–35%; nothing currently overrides downward).

Distribution on the 2.5% default

p50 p75 p90 p95 p99 max
0.352% 0.709% 1.134% 1.428% 1.846% 2.249%

So the median has a ~7x margin, but the worst test sits at 2.249% — only 0.25pp under the limit. Lowering the global default is therefore not quite free:

new default tests that would start failing
2.0% 2
1.5% 8
1.0% 33
0.5% 94

The good news is that these numbers are extremely stable, which is really the property that matters for tightening a threshold. Across two independent CI runs on different commits (bd3e3e277bd4ae14): p50 0.349% → 0.352%, p90 1.116% → 1.134%, p99 identical at 1.846%, max identical at 2.249%. And within a single run the three JS-engine jobs (Chakra / QuickJS / V8) produce byte-identical distributions — same n, same p50/p90/max, same counts over every candidate threshold. The rendering is deterministic; the 2.5% isn't absorbing run-to-run noise, it's absorbing a small number of genuinely-large stable diffs.

That means a tightening is low-risk and won't introduce flakiness — it just has to be paired with per-test overrides for the tail, exactly the mechanism config.json already uses in the other direction. Concretely I'd suggest default 1.5% plus explicit overrides on the 8 tests above it, which tightens 246 of 254 tests by 40% while leaving the known-bad ones documented and visible instead of hidden under a blanket allowance.

Two caveats worth stating: this is Win32 D3D11 only (D3D12 CI doesn't run the pixel comparison today — relevant to #1671), and the per-channel threshold of 25 is arguably the blunter knob of the two, since it's what lets a pixel be off by up to 25/255 per channel before it's even counted.

I'd rather not fold this into this PR though — it's repo-wide test policy and would churn config.json for 8 unrelated tests, whereas this PR is scoped to the Canvas polyfill. Happy to do it as a follow-up PR right after this lands, with the same measurements attached. Sound good?

… stops

nvgRestore() rewinds nanovg's own copy of the drawing state, but the
Context wrapper keeps C++ mirrors of the attributes it has to reproject
(shadows, filter, direction, dash) or that nanovg cannot report back
(lineCap, lineJoin, letterSpacing). SavedStyle only snapshotted
fillStyle and strokeStyle, so every other attribute survived restore()
with its post-save() value: the getters reported the wrong value, and
the shadow attributes -- which are re-applied from the mirror on each
draw -- actually rendered wrong. Snapshot all fifteen fields instead.

Gradient stored its stops in a std::map keyed by offset, so
insert() silently dropped a second stop at an offset that was already
present. Two stops at one offset is how the canvas spec encodes a hard
transition, so a stripe pattern came out as a smooth fade. Switch to
std::multimap; both consumers only iterate in sorted order into a
vector, and the existing zero-width-span guard turns the resulting
duplicate offset into the required hard edge.

Adds three unit tests. globalAlpha is round-tripped but not asserted,
as it is declared with a null getter and has no observable value.
m_lineDash was cleared before the segments were validated, so a list
containing a negative, non-finite or non-numeric entry -- which the spec
says must be ignored entirely, leaving the previous list in place --
wiped the previous list instead: after setLineDash([5, 10]),
setLineDash([-1]) made getLineDash() return []. Parse into a temporary
and commit it only once every segment validates.
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Pushed two more commits (8bbf33bc, bd40ea28) fixing three further issues, and triaged the twelve comments in the collapsed Suppressed comments block of the Copilot review — those don't create review threads, so they weren't visible in the resolved/unresolved list and I'd missed them until now.

Most of that block was generated against bd3e3e27, before the last two pushes, so a good part of it describes code that is no longer there. Breakdown:

Fixed in this push

SavedStyle didn't include the shadow attributes. Correct, and worse than reported. nvgRestore() rewinds nanovg's copy of the state, but the wrapper keeps its own C++ mirrors of everything it has to reproject or that nanovg can't report back, and SavedStyle only snapshotted fillStyle/strokeStyle. So lineCap, lineJoin, lineWidth, miterLimit, the dash list, filter, direction, letterSpacing and globalAlpha had the same bug — the getters reported the post-save() value forever after, and the shadow attributes, which are re-applied from the mirror on every draw, actually rendered wrong. SavedStyle now snapshots all fifteen fields.

Coincident color stops were dropped. Correct — std::map + insert() silently discarded the second stop at an offset already present, which is exactly how the spec encodes a hard transition, so a stripe pattern came out as a smooth fade. Now a std::multimap. Both consumers only iterate in sorted order into a vector, so it's a drop-in, and the existing zero-width-span guard at Gradient.cpp:51 turns the duplicate offset into the required hard edge.

setLineDash() didn't retain the previous list on a rejected argument. Correct, and the code contradicted its own comment: m_lineDash was cleared before validation, so after setLineDash([5, 10]), setLineDash([-1]) left getLineDash() returning [] instead of [5, 10]. Now parsed into a temporary and committed only once every segment validates.

Three unit tests cover the state round-trip and the dash retention; a fourth covers the duplicate stops. 36/36 pass locally.

Already fixed in 99ff0b8b/7bd4ae14 — these were generated against the older head

  • "Fill() never calls the new binder" / "only a fill-style binder is introduced; stroke still throws" — both BindFillStyle and BindStrokeStyle exist and are called from all six draw sites (Context.cpp:231, 329, 535, 626, 727, 1521), and m_fillStyle/m_strokeStyle are both std::variant<std::string, CanvasGradient*>, not strings.
  • "premultiplied-alpha interpolation isn't actually implemented" — it is, on both paths: gradientSpan() premultiplies at Gradient.cpp:75-76 and unpremultiplies the baked sample at :88, and lerpColor() does the same at :226-227 and :232.
  • "the radial paint still ignores x0, y0, r0" — it doesn't; RadialGradientStops() calls solveRadialOffset() (:362), which solves the two-circle equation over all six parameters. The image is still mapped onto the outer circle's bounding box, but that's the correct place to put it now that the ramp is baked per-pixel rather than being a concentric texture.

Known limitations, unchanged and deliberate

Non-rectangular clip(). Flagged as "not clipping" — that's fair, and it's called out as such in the PR description. nanovg's scissor is rectangle-only and there's no stencil path available here, so the choice is between an approximation and throwing. This keeps the rectangular case exact and degrades the non-rectangular one instead of failing the scene.

putImageData() clobbers the current path. Correct, and I'd rather not fix it in this PR. It isn't specific to putImageDatafillRect(), clearRect(), drawImage() and Path2D playback all call nvgBeginPath(), and per spec none of them may touch the current path. nanovg offers no way to fix it locally: nvgSave/nvgRestore only push and pop NVGstate, while nvgBeginPath clears ctx->ncommands and the path cache, which aren't part of that state. A real fix means journaling the current path wrapper-side and replaying it after each of those operations — the Path2DCommand machinery in Path2D.h is the obvious basis for it, but it means routing every path method on Context through the journal. That's a self-contained change and I'd prefer it as a follow-up rather than growing this PR further; happy to open an issue to track it.

CI was 34/34 green on the previous head; I'll confirm it stays green on bd40ea28.

@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: fix fifteen Canvas2D correctness bugs in the shared polyfill Canvas: fix 21 correctness and robustness bugs in the shared Canvas2D polyfill Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bgfx Canvas gradients do not preserve Canvas 2D stroke and geometry semantics

5 participants