Canvas: implement getImageData and fix drawImage crash on plain ImageBitmap - #1800
Open
bkaradzic-microsoft wants to merge 5 commits into
Open
Canvas: implement getImageData and fix drawImage crash on plain ImageBitmap#1800bkaradzic-microsoft wants to merge 5 commits into
bkaradzic-microsoft wants to merge 5 commits into
Conversation
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
Contributor
There was a problem hiding this comment.
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 fixImageData’s constructor argument indexing for non-square sizes. - Update
drawImage()to safely handle plainImageBitmapobjects (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. |
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
…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
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.
Two Canvas 2D fixes that together make the
drawImage→getImageDataround trip work, plus input validation for the new entry points.1.
getImageDatareturned all zerosImageData::GetDatawas a stub:It allocated a buffer and
memsetit to 0, so everygetImageData()call read back a fully transparent image no matter what had been drawn.Context::GetImageDataalso 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 mirror —drawImagecopies the decoded image's exact pixels (Image::GetPixels→ bimgm_data) intoContext::m_cpuPixels, andgetImageDatareads that region back out.BlitPixelsToCpucovers all threedrawImagearities 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
ImageDataconstructor bug that read the height frominfo[1]instead ofinfo[2], so a non-squareImageDatareported its width as its height.2.
drawImageaccess violation on a plainImageBitmapBabylon Native sets
forceBitmapOverHTMLImageElement, which routesLoadImagethroughNativeEngine::CreateImageBitmap. That returns a plain JS object{data, width, height, format}— not a wrappedNativeCanvasImage.Context::DrawImagecalledNativeCanvasImage::Unwrapon whatever it was handed, sonapi_unwrapdereferenced a never-wrapped object and crashed with an access violation (0xC0000005).DrawImagenow detects the plainImageBitmapby its owndatatyped array, converts its pixels to RGBA8 viabimg::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 newDrawImageCommonhelper. TheNativeCanvasImagepath 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:
drawImagerejects an out-of-range bimg texture format, dimensions whose pixel count would overflow the destination allocation, and adataarray shorter thanwidth*heightfor its format.bimg::imageConvertis given neither buffer's real length, so without these it would read past the end of the source.getImageDatarequires its four arguments and rejects a region whose byte size would overflowsize_tbeforeImageDatatries to allocate it.BlitPixelsToCpuclamps 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_tso they stay meaningful wheresize_tis 32-bit (Android armeabi-v7a), and the new arithmetic uses fixed-width types rather thanlong, whose width differs between MSVC and Apple clang.Validation
Mesh.applyDisplacementMapis exactly thisdrawImage→getImageDataround trip. It crashes on master; with this change it renders at 0.155% pixel difference against a 2.5% limit. It is stillexcludeFromAutomaticTestinginconfig.json, so reproduce with--include-excluded --test-index=200. Un-excluding it is deliberately left to a separate test-enablement PR.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:Exit code 3, Win32 x64 D3D11 Debug. Verified pre-existing by reverting
Polyfills/Canvasto master and rebuilding — identical crash at the same test. (0x87ais the DXGI0x887A…device-removed family.) The duplicateScissor testat 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
.sogcontainer and materialises its WebP images throughURL.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.jsonchanges in this PR — it is native-only and enables no tests.