Skip to content

Canvas: fix five Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData) - #1824

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

Canvas: fix five Canvas2D correctness bugs (ImageData, clip, drawImage, gradients, createImageData)#1824
bkaradzic-microsoft wants to merge 3 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-shared-fixes

Conversation

@bkaradzic-microsoft

Copy link
Copy Markdown
Member

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.data returned a fresh copy on every property access

GetData allocated a new typed array and memcpy'd into it on each read. So JS mutated one copy while putImageData and friends read a different, still-blank one — silently discarding every write and breaking the standard idiom:

const img = ctx.getImageData(0, 0, w, h);
img.data[i] = 255;          // writes to copy #1
ctx.putImageData(img, 0, 0); // reads copy #2 -> no-op

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 plain Uint8Array wraps 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() suppressed beginPath() for every subsequent draw

Clip() is implemented with nvgScissor — path-independent, and it never consumes the current path. Despite that, FillRect, ClearRect and all three DrawImage branches skipped nvgBeginPath whenever m_isClipped was 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 / drawImage neither read nor modify the current path, so beginPath is now unconditional. m_isClipped existed only to gate this, so it's gone.

This survived so long because most controls issue a single fillRect after clip() 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

BindFillStyle built the paint from the shape being filled, not the gradient:

// TODO: replace left/lop/width/height by context bounds
NVGpaint imagePaint = nvgImagePattern(*m_nvg, 0.f, 0.f, width + left, height, 0.f, ...);

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 to createLinearGradient was discarded entirely. 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 exactly the "pad" behavior the spec requires beyond the end stops.

Two related bugs in the ramp baking, fixed here too:

  • the sample buffer was uninitialized, so any sample outside the stop range read stack garbage;
  • 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 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 GUI test 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-arg drawImage at a non-zero offset (bug 3), draws it and its saturation square under one clip() (bug 2), and paints that square with two overlaid linear gradients, one of them vertical (bug 4).

…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 added 2 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
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.

4 participants