Skip to content

Canvas: implement getImageData and fix drawImage crash on plain ImageBitmap - #1800

Open
bkaradzic-microsoft wants to merge 5 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/canvas-pixel-readback
Open

Canvas: implement getImageData and fix drawImage crash on plain ImageBitmap#1800
bkaradzic-microsoft wants to merge 5 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/canvas-pixel-readback

Conversation

@bkaradzic-microsoft

Copy link
Copy Markdown
Member

Two Canvas 2D fixes that together make the drawImagegetImageData round trip work, plus input validation for the new entry points.

1. getImageData returned all zeros

ImageData::GetData was a stub:

// return a well size array with 0
// TODO: Get datas from context/canvas

It allocated a buffer and memset it to 0, so every getImageData() call read back a fully transparent image no matter what had been drawn. Context::GetImageData also discarded the source x/y arguments (// TODO: support source x and y).

BN's canvas is GPU-backed (nanovg), and a framebuffer readback is a poor fit for the data-texture callers that rely on getImageData: the surface is premultiplied and may be resampled, so the bytes that come back are not the bytes that went in. Instead this keeps a CPU-side RGBA8 mirrordrawImage copies the decoded image's exact pixels (Image::GetPixels → bimg m_data) into Context::m_cpuPixels, and getImageData reads that region back out. BlitPixelsToCpu covers all three drawImage arities with nearest-neighbour sampling, which is exact for the 1:1 draws these callers use. The mirror is reallocated and cleared on canvas resize.

Also fixes a pre-existing ImageData constructor bug that read the height from info[1] instead of info[2], so a non-square ImageData reported its width as its height.

2. drawImage access violation on a plain ImageBitmap

Babylon Native sets forceBitmapOverHTMLImageElement, which routes LoadImage through NativeEngine::CreateImageBitmap. That returns a plain JS object {data, width, height, format}not a wrapped NativeCanvasImage. Context::DrawImage called NativeCanvasImage::Unwrap on whatever it was handed, so napi_unwrap dereferenced a never-wrapped object and crashed with an access violation (0xC0000005).

DrawImage now detects the plain ImageBitmap by its own data typed array, converts its pixels to RGBA8 via bimg::imageConvert (memcpy fast path when the source is already RGBA8), creates a transient nanovg image, and shares the arity-3/5/9 draw plus CPU-mirror blit with the existing path through a new DrawImageCommon helper. The NativeCanvasImage path is behaviour-identical.

3. Input validation

Both new paths take sizes straight from JS and hand them to bimg and to raw pointer arithmetic, so the third commit validates them at the boundary:

  • drawImage rejects an out-of-range bimg texture format, dimensions whose pixel count would overflow the destination allocation, and a data array shorter than width*height for its format. bimg::imageConvert is given neither buffer's real length, so without these it would read past the end of the source.
  • getImageData requires its four arguments and rejects a region whose byte size would overflow size_t before ImageData tries to allocate it.
  • BlitPixelsToCpu clamps its iteration range to the destination rect's intersection with the canvas up front. The destination size is caller-controlled and arrives unsigned, so a negative width wraps to ~4e9; the previous per-pixel bounds test still walked the whole rect and could stall the JS thread for billions of no-op iterations.

Range checks are computed in uint64_t so they stay meaningful where size_t is 32-bit (Android armeabi-v7a), and the new arithmetic uses fixed-width types rather than long, whose width differs between MSVC and Apple clang.

Validation

  • "Displacement map" (idx 200)Mesh.applyDisplacementMap is exactly this drawImagegetImageData round trip. It crashes on master; with this change it renders at 0.155% pixel difference against a 2.5% limit. It is still excludeFromAutomaticTesting in config.json, so reproduce with --include-excluded --test-index=200. Un-excluding it is deliberately left to a separate test-enablement PR.
  • Full validation suite: ran=295 passed=295 failed=0 — no regressions.
Why 295 and not 300

The 5 tests at indices 53–57 (Scissor test, Scissor test with 0.9 hardware scaling, … with 1.5 hardware scaling, … with negative x and y, … with out of bounds width and height) are excluded from that run because they crash on unmodified master, not because of anything here:

--- BN: CRASH ---
Unknown signal? Exception Code 87a
  7: bgfx/src/renderer_d3d11.cpp  6173  bgfx::d3d11::RendererContextD3D11::submit
  8: bgfx/src/bgfx.cpp            2917  bgfx::Context::renderFrame
 13: Core/Graphics/Source/DeviceImpl.cpp  539  Babylon::Graphics::DeviceImpl::Frame

Exit code 3, Win32 x64 D3D11 Debug. Verified pre-existing by reverting Polyfills/Canvas to master and rebuilding — identical crash at the same test. (0x87a is the DXGI 0x887A… device-removed family.) The duplicate Scissor test at idx 311 passes. Worth noting because it truncates any full-suite run at 52 tests; happy to file it separately.

Not included

My working branch also un-excluded idx 106 "Gaussian Splatting Part Test" alongside this change. That does not hold on stock upstream, so it is left out: Babylon's SOG loader unpacks the .sog container and materialises its WebP images through URL.createObjectURL, and upstream JsRuntimeHost has no blob-URL support, so the load fails before any canvas work happens. That un-exclusion needs a JsRuntimeHost change first.

There are no config.json changes in this PR — it is native-only and enables no tests.

bkaradzic-microsoft and others added 3 commits July 28, 2026 16:50
ImageData::GetData was a stub returning all zeros ("TODO: Get datas from
context/canvas"), so every getImageData() call read back a fully transparent
image regardless of what had been drawn. Context::GetImageData also discarded
the source x/y arguments outright.

BN's canvas is GPU-backed (nanovg), and a framebuffer readback is a poor fit
for the data-texture use cases that rely on getImageData: the surface is
premultiplied and may be resampled, so the bytes that come back are not the
bytes that went in. Instead keep a CPU-side RGBA8 mirror. drawImage copies the
decoded image's exact pixels (Image::GetPixels -> bimg m_data) into
Context::m_cpuPixels, and getImageData reads that region back out.
BlitPixelsToCpu handles all three drawImage arities with nearest-neighbour
sampling, which is exact for the 1:1 draws these callers use. The mirror is
reallocated and cleared whenever the canvas is resized.

Also fixes a pre-existing ImageData constructor bug that read the height from
info[1] instead of info[2], so a non-square ImageData reported its width as
its height.

getImageData backs Mesh.applyDisplacementMap and Babylon's SOG /
Gaussian-Splatting loader, neither of which could work against a zero-filled
readback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Babylon Native sets forceBitmapOverHTMLImageElement, which routes LoadImage
through NativeEngine::CreateImageBitmap. That returns a plain JS object
{data, width, height, format} rather than a wrapped NativeCanvasImage, but
Context::DrawImage called NativeCanvasImage::Unwrap on whatever it was handed,
so napi_unwrap dereferenced a never-wrapped object and crashed with an access
violation (0xC0000005).

DrawImage now detects the plain ImageBitmap by its own `data` typed array,
converts its pixels to RGBA8 via bimg::imageConvert (memcpy fast path when the
source is already RGBA8), creates a transient nanovg image, and shares the
arity-3/5/9 draw plus CPU-mirror blit with the existing path through a new
DrawImageCommon helper. The NativeCanvasImage path is behaviour-identical.

This is the drawImage -> getImageData round trip used by
Mesh.applyDisplacementMap. The "Displacement map" validation test crashed
before this change; it now renders at a 0.155% pixel difference against a
2.5% limit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
The new ImageBitmap path and the CPU mirror take sizes straight from JS and
hand them to bimg and to raw pointer arithmetic, so validate them at the
boundary:

- drawImage rejects an out-of-range bimg texture format, dimensions whose
  pixel count would overflow the destination allocation, and a `data` array
  shorter than width*height for its format. bimg::imageConvert is given
  neither buffer's real length, so without these it would read past the end
  of the source.
- getImageData requires its four arguments and rejects a region whose byte
  size would overflow size_t before ImageData tries to allocate it.
- BlitPixelsToCpu clamps its iteration range to the destination rect's
  intersection with the canvas up front. The destination size is caller
  controlled and arrives unsigned, so a negative width wraps to ~4e9; the
  previous per-pixel bounds test still walked the whole rect and could stall
  the JS thread for billions of no-op iterations.

Range checks are computed in uint64_t so they stay meaningful where size_t is
32-bit (Android armeabi-v7a), and the new arithmetic uses fixed-width types
rather than long, whose width differs between MSVC and Apple clang.

Also drops BlitImageToCpu, which became dead code once DrawImageCommon began
calling BlitPixelsToCpu directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Copilot AI review requested due to automatic review settings July 29, 2026 00:26

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

This PR fixes Canvas 2D interoperability in Babylon Native by making getImageData() return meaningful pixel data (via a CPU-side RGBA8 mirror populated from drawImage) and by preventing a crash when drawImage is called with Babylon’s “plain object” ImageBitmap ({data,width,height,format}) produced by NativeEngine.createImageBitmap.

Changes:

  • Implement getImageData() by copying from a CPU-side pixel mirror and fix ImageData’s constructor argument indexing for non-square sizes.
  • Update drawImage() to safely handle plain ImageBitmap objects (convert to RGBA8, create transient NVG image, and share common draw/blit path).
  • Add boundary validation to reduce the risk of overflow/OOB during pixel conversion and image-data allocation.

Reviewed changes

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

Show a summary per file
File Description
Polyfills/Canvas/Source/ImageData.h Extends ImageData to carry a pixel buffer and accept sx/sy for region reads.
Polyfills/Canvas/Source/ImageData.cpp Populates ImageData from Context::ReadPixels, fixes height parsing bug, and returns real pixel data instead of zeros.
Polyfills/Canvas/Source/Image.h Adds GetPixels() API to expose decoded RGBA8 bytes for CPU mirroring.
Polyfills/Canvas/Source/Image.cpp Implements GetPixels() backed by the decoded bimg::ImageContainer.
Polyfills/Canvas/Source/Context.h Introduces CPU-side mirror storage and helper methods (ReadPixels, BlitPixelsToCpu, DrawImageCommon).
Polyfills/Canvas/Source/Context.cpp Implements CPU mirror maintenance/readback, adds ImageBitmap draw path, and validates getImageData inputs.

Comment thread Polyfills/Canvas/Source/Context.cpp Outdated
Comment thread Polyfills/Canvas/Source/Context.cpp Outdated
Comment thread Polyfills/Canvas/Source/Context.cpp Outdated
Three fixes from code review on the validation added in the previous
commit:

- The ImageBitmap buffer-size check computed width * height * bpp / 8,
  which is wrong for block-compressed formats. Those round up to whole
  blocks and have a minimum block count, so a 1x1 BC1 image still
  occupies one 8-byte block while the naive math accepts a single byte
  and lets imageConvert read past the end of the buffer. Ask
  bimg::imageGetSize for the real size instead.

- ReadPixels did not resynchronize the CPU mirror before reading. A
  canvas resize followed by getImageData with no intervening drawImage
  read the old buffer with the new dimensions and returned stale pixels.
  Call EnsureCpuBuffer first; it reallocates and zero-fills on a size
  change. ReadPixels is no longer const as a result.

- The 5-argument and 9-argument drawImage overloads read their extents
  with Uint32Value, so a negative width or height wrapped to roughly
  4e9. That queued an enormous nvgRect and made the CPU blit iterate for
  billions of pixels. Parse them with Int32Value and return early on a
  non-positive extent, which draws nothing, matching the browser.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae

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 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread Polyfills/Canvas/Source/Context.cpp Outdated
Comment thread Polyfills/Canvas/Source/Context.cpp
…tents

Both issues were introduced by the input validation added in dbb705b and
found in review.

drawImage: bimg's imageGetSize takes uint16_t extents, so an ImageBitmap
larger than 65535 in either dimension was silently truncated before the
required-size check. A 65537x1 RGBA8 bitmap measured as 1x1, so a 4-byte
buffer satisfied the check and the subsequent RGBA8 memcpy read 262148
bytes from it. Reject dimensions bimg cannot describe.

getImageData: the extents were read with Uint32Value, so a width of -1
arrived as 4294967295. The size_t overflow guard does not catch that on
64-bit, so ImageData went on to attempt a ~17 GB allocation. Parse the
extents as signed and, matching the browser, fold a negative extent into
the origin so the normalized region is returned. INT32_MIN has no positive
counterpart and is rejected.

Verified with negative controls: with each fix reverted the corresponding
assertion fails, the oversized bitmap crashing the process and the negative
width stalling for 9.6 s before dying.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
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.

3 participants