From 5dc0edc84eb2ee3b590beaa94e9e101f1154bd0a Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 27 Aug 2026 17:04:49 -0700 Subject: [PATCH 01/16] Canvas: text metrics, multi-GUI filters, drawImage(canvas), toDataURL Independent of instance-branding (#1844 / type tags): - fontstash: CSS/canvas font-size as em units; MeasureText advance + ink bounds - MeasureText binds the same face FillText uses - nanovg_filterstack: refcount shared blur programs (multi-ADT lifetime) - drawImage(canvas) via Canvas InstanceOf + CPU pixel mirror (no NativeInstanceRegistry) - toDataURL("image/png") via bimg_encode; link bimg_encode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../JavaScript/dist/tests.javaScript.all.js | 16 +++++ .../JavaScript/src/tests.javaScript.all.ts | 18 ++++- Polyfills/Canvas/CMakeLists.txt | 3 +- Polyfills/Canvas/Source/Canvas.cpp | 56 +++++++++++++++ Polyfills/Canvas/Source/Canvas.h | 15 ++-- Polyfills/Canvas/Source/Context.cpp | 45 +++++++++--- Polyfills/Canvas/Source/MeasureText.cpp | 16 ++--- Polyfills/Canvas/Source/nanovg/fontstash.h | 69 +++++++++++++++---- .../Source/nanovg/nanovg_filterstack.cpp | 43 +++++++++--- .../Canvas/Source/nanovg/nanovg_filterstack.h | 22 +++--- 10 files changed, 244 insertions(+), 59 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 34c516c69f..bfabd4d653 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28726,6 +28726,22 @@ describe("Canvas2D", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(data.width).to.equal(4); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(data.height).to.equal(3); }); + + it("accepts another Canvas as drawImage source and supports toDataURL png", function () { + var srcCanvas = new _native.Canvas(); + srcCanvas.width = 32; + srcCanvas.height = 32; + var srcCtx = srcCanvas.getContext("2d"); + srcCtx.fillStyle = "#ff0000"; + srcCtx.fillRect(0, 0, 32, 32); + + var dstCtx = createContext(); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {dstCtx.drawImage(srcCanvas, 0, 0);}).to.not.throw(); + + var url = srcCanvas.toDataURL("image/png"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.indexOf("data:image/png;base64,")).to.equal(0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.length).to.be.greaterThan(32); + }); }); function createSceneAndWait(callback, done) { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index aef949501f..9c53665876 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -495,7 +495,23 @@ describe("Canvas2D", function () { expect(data.width).to.equal(4); expect(data.height).to.equal(3); }); -}); + + it("accepts another Canvas as drawImage source and supports toDataURL png", function () { + const srcCanvas = new _native.Canvas(); + srcCanvas.width = 32; + srcCanvas.height = 32; + const srcCtx = srcCanvas.getContext("2d"); + srcCtx.fillStyle = "#ff0000"; + srcCtx.fillRect(0, 0, 32, 32); + + const dstCtx = createContext(); + expect(function () { dstCtx.drawImage(srcCanvas, 0, 0); }).to.not.throw(); + + const url = srcCanvas.toDataURL("image/png"); + expect(url.indexOf("data:image/png;base64,")).to.equal(0); + expect(url.length).to.be.greaterThan(32); + }); + }); function createSceneAndWait(callback: (engine: NativeEngine, scene: Scene) => void, done: () => void) { const engine = new NativeEngine(); diff --git a/Polyfills/Canvas/CMakeLists.txt b/Polyfills/Canvas/CMakeLists.txt index 23058b6dba..af127dc730 100644 --- a/Polyfills/Canvas/CMakeLists.txt +++ b/Polyfills/Canvas/CMakeLists.txt @@ -57,7 +57,8 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) target_compile_definitions(Canvas PRIVATE BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) target_link_libraries(Canvas - PRIVATE bimg) + PRIVATE bimg + PRIVATE bimg_encode) endif() if(SHADERC_PATH AND NOT IOS AND NOT VISIONOS AND NOT ANDROID) diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index b0850cfdcf..c786ca8c7a 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -6,9 +6,17 @@ #include #include #include +#include +#include #include "Colors.h" #include "Gradient.h" #include "Font.h" +#include +#ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES +#include +#include +#include +#endif namespace { @@ -33,6 +41,7 @@ namespace Babylon::Polyfills::Internal InstanceAccessor("height", &NativeCanvas::GetHeight, &NativeCanvas::SetHeight), InstanceMethod("getContext", &NativeCanvas::GetContext), InstanceMethod("getCanvasTexture", &NativeCanvas::GetCanvasTexture), + InstanceMethod("toDataURL", &NativeCanvas::ToDataURL), InstanceMethod("dispose", &NativeCanvas::Dispose), InstanceMethod("remove", &NativeCanvas::Remove), StaticMethod("parseColor", &NativeCanvas::ParseColor)}); @@ -49,6 +58,7 @@ namespace Babylon::Polyfills::Internal NativeCanvas::~NativeCanvas() { + m_context = nullptr; Dispose(); } @@ -103,6 +113,10 @@ namespace Babylon::Polyfills::Internal { context = Context::CreateInstance(info.Env(), info.This()); thisObj.Set(contextPropertyName, context); + if (context.IsObject()) + { + m_context = Context::Unwrap(context.As()); + } } return context; @@ -229,6 +243,48 @@ namespace Babylon::Polyfills::Internal return Napi::Pointer::Create(info.Env(), m_texture.get()); } + + Napi::Value NativeCanvas::ToDataURL(const Napi::CallbackInfo& info) + { + // toDataURL([type]) — only image/png is supported; other types throw. + std::string type{"image/png"}; + if (info.Length() >= 1 && info[0].IsString()) + { + type = info[0].As().Utf8Value(); + } + if (type != "image/png" && type != "image/PNG") + { + throw Napi::TypeError::New(info.Env(), "Canvas.toDataURL: only image/png is supported on Native."); + } + + const uint32_t width = m_width; + const uint32_t height = m_height; + std::vector rgba(static_cast(width) * height * 4, 0); + if (m_context != nullptr && width > 0 && height > 0) + { + m_context->ReadPixels(0, 0, width, height, rgba.data()); + } + +#ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES + throw Napi::Error::New(info.Env(), "Canvas.toDataURL: image encoding is disabled in this build."); +#else + bx::MemoryBlock memoryBlock{&Graphics::DeviceContext::GetDefaultAllocator()}; + bx::MemoryWriter writer{&memoryBlock}; + bx::Error err{}; + bimg::imageWritePng(&writer, width, height, width * 4, rgba.data(), bimg::TextureFormat::RGBA8, false, &err); + if (!err.isOk() || memoryBlock.getSize() == 0) + { + throw Napi::Error::New(info.Env(), "Canvas.toDataURL: PNG encode failed."); + } + + // more(0) returns the buffer start without growing (see bx::MemoryBlock). + const char* pngBytes = static_cast(memoryBlock.more(0)); + const size_t pngSize = memoryBlock.getSize(); + std::string encoded; + bn::encode_b64(pngBytes, pngBytes + pngSize, std::back_inserter(encoded)); + return Napi::String::New(info.Env(), "data:image/png;base64," + encoded); +#endif + } Napi::Value NativeCanvas::ParseColor(const Napi::CallbackInfo& info) { const auto colorString = info[0].As().Utf8Value(); diff --git a/Polyfills/Canvas/Source/Canvas.h b/Polyfills/Canvas/Source/Canvas.h index e76032c1de..a21c617c13 100644 --- a/Polyfills/Canvas/Source/Canvas.h +++ b/Polyfills/Canvas/Source/Canvas.h @@ -54,6 +54,8 @@ namespace Babylon::Polyfills namespace Babylon::Polyfills::Internal { + class Context; + class NativeCanvas final : public Napi::ObjectWrap, Polyfills::Canvas::Impl::MonitoredResource { public: @@ -65,16 +67,17 @@ namespace Babylon::Polyfills::Internal uint32_t GetWidth() const { return m_width; } uint32_t GetHeight() const { return m_height; } + Context* GetBoundContext() const { return m_context; } + void SetBoundContext(Context* context) { m_context = context; } + static inline std::map> fontsInfos; bool UpdateRenderTarget(); Babylon::Graphics::FrameBuffer& GetFrameBuffer() { return *m_frameBuffer; } FrameBufferPool m_frameBufferPool; - // View id reserved by Context::Flush (right after this canvas' draws) for the - // canvas->texture blit issued from NativeEngine::CopyTexture, plus the view-id - // generation it was reserved in so a mid-frame flush can invalidate it. See Context::Flush. - void SetBlitViewId(bgfx::ViewId viewId, uint32_t generation) + // Blit view reserved by Context::Flush for CopyTexture ordering. + void SetBlitViewId(bgfx::ViewId viewId, uint32_t generation) { m_blitViewId = viewId; m_blitViewIdGeneration = generation; @@ -92,10 +95,9 @@ namespace Babylon::Polyfills::Internal Napi::Value GetHeight(const Napi::CallbackInfo&); void SetHeight(const Napi::CallbackInfo&, const Napi::Value& value); Napi::Value GetCanvasTexture(const Napi::CallbackInfo& info); + Napi::Value ToDataURL(const Napi::CallbackInfo& info); static void LoadTTF(const Napi::CallbackInfo& info); static Napi::Value LoadTTFAsync(const Napi::CallbackInfo& info); - // Both entry points share this; each passes its own name so the diagnostics name the - // method the caller actually invoked rather than the one it happens to delegate to. static void LoadTTFCore(const Napi::CallbackInfo& info, const char* methodName); static Napi::Value ParseColor(const Napi::CallbackInfo& info); void Remove(const Napi::CallbackInfo& info); @@ -103,6 +105,7 @@ namespace Babylon::Polyfills::Internal void Dispose(); Napi::ObjectReference m_contextObject{}; + Context* m_context{nullptr}; uint16_t m_width{1}; uint16_t m_height{1}; diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 6b35e18009..5682168b42 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -143,6 +143,10 @@ namespace Babylon::Polyfills::Internal Context::~Context() { + if (m_canvas != nullptr && m_canvas->GetBoundContext() == this) + { + m_canvas->SetBoundContext(nullptr); + } Dispose(); m_cancellationSource->cancel(); } @@ -673,17 +677,10 @@ namespace Babylon::Polyfills::Internal { std::string text{info[0].As()}; - // If the JS-requested font family hasn't been loaded, return Arial-equivalent metrics - // instead of measuring with whatever fallback font is bound. Browsers use the system - // Arial for "Arial"/"sans-serif"/etc.; here we have e.g. droidsans only, which is - // ~1.7x wider per em. Returning droidsans widths makes Babylon helpers like - // DynamicTexture.drawText center text via t = (canvas - measureText.width)/2 to a - // negative x and clip the text off-canvas. Arial-ish synthesised metrics keep the - // centering on-canvas, while the actual FillText still substitutes our loaded font. - const bool familyAvailable = !m_state.font.Familiy().empty() - && m_fonts.find(m_state.font.Familiy()) != m_fonts.end(); - - if (!familyAvailable && m_state.font.Size() > 0.f) + // Measure against the same face FillText will bind. + const bool fontBound = SetFontFaceId(); + // No face available: synthesize Arial-ish metrics so callers still get a finite width. + if (!fontBound && m_state.font.Size() > 0.f) { // Approximate Arial proportional metrics: average advance ~ 0.55 em. const float fontSize = m_state.font.Size(); @@ -1211,6 +1208,32 @@ namespace Babylon::Polyfills::Internal #endif } + // drawImage(canvas): InstanceOf gate then Unwrap (real canvases only; deliberate + // prototype spoofs are out of scope — see #1844 / JsRuntimeHost type tags). + const auto canvasCtorVal = JsRuntime::NativeObject::GetFromJavaScript(info.Env()).Get("Canvas"); + if (canvasCtorVal.IsFunction() && imageObj.InstanceOf(canvasCtorVal.As())) + { + NativeCanvas* const srcCanvas = NativeCanvas::Unwrap(imageObj); + const uint32_t width = srcCanvas->GetWidth(); + const uint32_t height = srcCanvas->GetHeight(); + if (width == 0 || height == 0) + { + return; + } + + Context* const srcContext = srcCanvas->GetBoundContext(); + std::vector rgba(static_cast(width) * height * 4, 0); + if (srcContext != nullptr) + { + srcContext->ReadPixels(0, 0, width, height, rgba.data()); + } + + const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); + DrawImageCommon(info, imageIndex, rgba.data(), width, height); + nvgDeleteImage(*m_nvg, imageIndex); + return; + } + const NativeCanvasImage* canvasImage = NativeCanvasImage::Unwrap(imageObj); int imageIndex{-1}; diff --git a/Polyfills/Canvas/Source/MeasureText.cpp b/Polyfills/Canvas/Source/MeasureText.cpp index 1f2ec47683..ec08594cb4 100644 --- a/Polyfills/Canvas/Source/MeasureText.cpp +++ b/Polyfills/Canvas/Source/MeasureText.cpp @@ -19,20 +19,18 @@ namespace Babylon::Polyfills::Internal { Napi::Value MeasureText::CreateInstance(Napi::Env env, Context* context, const std::string& text) { - // IMPORTANT: zero-initialize. nvgTextBounds / nvgTextMetrics do not always fill these - // slots (e.g. when called before the text atlas has been populated), and uninitialized - // stack values turn into garbage widths the size of millions of pixels, which Babylon - // GUI then propagates into a wildly negative fillText x coordinate, putting text off- - // screen. - float bounds[4] = {0, 0, 0, 0}; - nvgTextBounds(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); + // Zero-init: nvgTextBounds may leave slots untouched when the atlas is empty. + float bounds[4] = {0, 0, 0, 0}; + // width = advance (not ink box). + const float advance = nvgTextBounds(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); float textMetrics[3] = {0, 0, 0}; nvgTextMetrics(context->GetNVGContext(), &textMetrics[0], &textMetrics[1], &textMetrics[2]); auto obj{Napi::Object::New(env)}; - obj.Set("width", Napi::Value::From(env, bounds[2] - bounds[0])); + obj.Set("width", Napi::Value::From(env, advance)); obj.Set("height", Napi::Value::From(env, bounds[3] - bounds[1])); - obj.Set("actualBoundingBoxLeft", Napi::Value::From(env, bounds[0])); + // actualBoundingBoxLeft is positive left of the alignment point. + obj.Set("actualBoundingBoxLeft", Napi::Value::From(env, -bounds[0])); obj.Set("actualBoundingBoxRight", Napi::Value::From(env, bounds[2])); obj.Set("fontBoundingBoxAscent", Napi::Value::From(env, textMetrics[0])); obj.Set("fontBoundingBoxDescent", Napi::Value::From(env, -textMetrics[1])); diff --git a/Polyfills/Canvas/Source/nanovg/fontstash.h b/Polyfills/Canvas/Source/nanovg/fontstash.h index a668496117..1afd1f01f4 100644 --- a/Polyfills/Canvas/Source/nanovg/fontstash.h +++ b/Polyfills/Canvas/Source/nanovg/fontstash.h @@ -196,9 +196,15 @@ void fons__tt_getFontVMetrics(FONSttFontImpl *font, int *ascent, int *descent, i *lineGap = font->font->height - (*ascent - *descent); } +// CSS/canvas font-size is em size (map em square → pixels), not ascent+descent. +float fons__tt_getEmUnits(FONSttFontImpl *font) +{ + return (float)font->font->units_per_EM; +} + float fons__tt_getPixelHeightScale(FONSttFontImpl *font, float size) { - return size / (font->font->ascender - font->font->descender); + return size / fons__tt_getEmUnits(font); } int fons__tt_getGlyphIndex(FONSttFontImpl *font, int codepoint) @@ -304,9 +310,16 @@ void fons__tt_getFontVMetrics(FONSttFontImpl *font, int *ascent, int *descent, i stbtt_GetFontVMetrics(&font->font, ascent, descent, lineGap); } +// CSS/canvas font-size is em size (map em square → pixels). +float fons__tt_getEmUnits(FONSttFontImpl *font) +{ + const float scale = stbtt_ScaleForMappingEmToPixels(&font->font, 1.0f); + return scale > 0.0f ? 1.0f / scale : 0.0f; +} + float fons__tt_getPixelHeightScale(FONSttFontImpl *font, float size) { - return stbtt_ScaleForPixelHeight(&font->font, size); + return stbtt_ScaleForMappingEmToPixels(&font->font, size); } int fons__tt_getGlyphIndex(FONSttFontImpl *font, int codepoint) @@ -951,6 +964,7 @@ int fonsAddFont(FONScontext* stash, const char* name, const char* path) int fonsAddFontMem(FONScontext* stash, const char* name, unsigned char* data, int dataSize, int freeData) { int i, ascent, descent, fh, lineGap; + float em; FONSfont* font; int idx = fons__allocFont(stash); @@ -977,11 +991,14 @@ int fonsAddFontMem(FONScontext* stash, const char* name, unsigned char* data, in // Store normalized line height. The real line height is got // by multiplying the lineh by font size. - fons__tt_getFontVMetrics( &font->font, &ascent, &descent, &lineGap); + // Normalize metrics against em square (matches pixel height scale). + fons__tt_getFontVMetrics( &font->font, &ascent, &descent, &lineGap); fh = ascent - descent; - font->ascender = (float)ascent / (float)fh; - font->descender = (float)descent / (float)fh; - font->lineh = (float)(fh + lineGap) / (float)fh; + em = fons__tt_getEmUnits(&font->font); + if (em <= 0.0f) em = (float)fh; + font->ascender = (float)ascent / em; + font->descender = (float)descent / em; + font->lineh = (float)(fh + lineGap) / em; return idx; @@ -1511,6 +1528,13 @@ void fonsDrawDebug(FONScontext* stash, float x, float y) fons__flush(stash); } +// Atlas SDF padding; subtracted from reported ink bounds. +#ifdef FONS_SDF_PADDING +#define FONS_TEXT_BOUNDS_PADDING ((float)FONS_SDF_PADDING) +#else +#define FONS_TEXT_BOUNDS_PADDING 0.0f +#endif + float fonsTextBounds(FONScontext* stash, float x, float y, const char* str, const char* end, @@ -1528,6 +1552,7 @@ float fonsTextBounds(FONScontext* stash, FONSfont* font; float startx, advance; float minx, miny, maxx, maxy; + int hasGlyph = 0; if (stash == NULL) return 0; if (state->font < 0 || state->font >= stash->nfonts) return 0; @@ -1539,7 +1564,8 @@ float fonsTextBounds(FONScontext* stash, // Align vertically. y += fons__getVertAlign(stash, font, state->align, isize); - minx = maxx = x; + // Init bounds from pen only as empty-run fallback; extents come from glyphs (LSB). + minx = maxx = x; miny = maxy = y; startx = x; @@ -1551,16 +1577,33 @@ float fonsTextBounds(FONScontext* stash, continue; glyph = fons__getGlyph(stash, font, codepoint, isize, iblur, FONS_GLYPH_BITMAP_OPTIONAL); if (glyph != NULL) { + float qx0, qx1, qy0, qy1; fons__getQuad(stash, font, prevGlyphIndex, glyph, scale, state->spacing, &x, &y, &q); - if (q.x0 < minx) minx = q.x0; - if (q.x1 > maxx) maxx = q.x1; + // Drop atlas SDF padding from ink extents. + qx0 = q.x0 + FONS_TEXT_BOUNDS_PADDING; + qx1 = q.x1 - FONS_TEXT_BOUNDS_PADDING; + if (qx1 < qx0) qx0 = qx1 = (q.x0 + q.x1) * 0.5f; + if (!hasGlyph) { minx = qx0; maxx = qx1; } + if (qx0 < minx) minx = qx0; + if (qx1 > maxx) maxx = qx1; if (stash->params.flags & FONS_ZERO_TOPLEFT) { - if (q.y0 < miny) miny = q.y0; - if (q.y1 > maxy) maxy = q.y1; + // q.y0 is the top edge and q.y1 the bottom edge. + qy0 = q.y0 + FONS_TEXT_BOUNDS_PADDING; + qy1 = q.y1 - FONS_TEXT_BOUNDS_PADDING; + if (qy1 < qy0) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; + if (!hasGlyph) { miny = qy0; maxy = qy1; } + if (qy0 < miny) miny = qy0; + if (qy1 > maxy) maxy = qy1; } else { - if (q.y1 < miny) miny = q.y1; - if (q.y0 > maxy) maxy = q.y0; + // y grows upwards: q.y0 is the top edge and q.y1 the bottom edge. + qy0 = q.y0 - FONS_TEXT_BOUNDS_PADDING; + qy1 = q.y1 + FONS_TEXT_BOUNDS_PADDING; + if (qy0 < qy1) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; + if (!hasGlyph) { miny = qy1; maxy = qy0; } + if (qy1 < miny) miny = qy1; + if (qy0 > maxy) maxy = qy0; } + hasGlyph = 1; } prevGlyphIndex = glyph != NULL ? glyph->index : -1; } diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp index b9859b50b8..0727795ff0 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp @@ -48,12 +48,16 @@ nanovg_filterstack::nanovg_filterstack() void nanovg_filterstack::InitBgfx() { + if (s_bgfxRefCount++ > 0) + { + return; + } + m_uniforms.u_strength = bgfx::createUniform("u_strength", bgfx::UniformType::Vec4); m_uniforms.u_direction = bgfx::createUniform("u_direction", bgfx::UniformType::Vec4); m_uniforms.u_weights = bgfx::createUniform("u_weights", bgfx::UniformType::Vec4, BLUR_UNIFORM_SIZE); - // create shaders used by the different elements - bgfx::RendererType::Enum type = bgfx::getRendererType(); + bgfx::RendererType::Enum type = bgfx::getRendererType(); s_gaussBlurProg = bgfx::createProgram( bgfx::createEmbeddedShader(s_embeddedShadersFilterStack, type, "vs_fspass") , bgfx::createEmbeddedShader(s_embeddedShadersFilterStack, type, "fs_gaussblur") @@ -67,17 +71,40 @@ void nanovg_filterstack::InitBgfx() void nanovg_filterstack::DisposeBgfx() { - // check if uniforms + programs are valid before destroying - if (m_uniforms.u_strength.idx != bgfx::kInvalidHandle) + if (s_bgfxRefCount <= 0) + { + return; + } + if (--s_bgfxRefCount > 0) + { + return; + } + + if (bgfx::isValid(m_uniforms.u_strength)) + { bgfx::destroy(m_uniforms.u_strength); - if (m_uniforms.u_direction.idx != bgfx::kInvalidHandle) + m_uniforms.u_strength = BGFX_INVALID_HANDLE; + } + if (bgfx::isValid(m_uniforms.u_direction)) + { bgfx::destroy(m_uniforms.u_direction); - if (m_uniforms.u_weights.idx != bgfx::kInvalidHandle) + m_uniforms.u_direction = BGFX_INVALID_HANDLE; + } + if (bgfx::isValid(m_uniforms.u_weights)) + { bgfx::destroy(m_uniforms.u_weights); - if (s_gaussBlurProg.idx != bgfx::kInvalidHandle) + m_uniforms.u_weights = BGFX_INVALID_HANDLE; + } + if (bgfx::isValid(s_gaussBlurProg)) + { bgfx::destroy(s_gaussBlurProg); - if (s_boxBlurProg.idx != bgfx::kInvalidHandle) + s_gaussBlurProg = BGFX_INVALID_HANDLE; + } + if (bgfx::isValid(s_boxBlurProg)) + { bgfx::destroy(s_boxBlurProg); + s_boxBlurProg = BGFX_INVALID_HANDLE; + } } bool nanovg_filterstack::ValidString(const std::string& string) diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h index 5760e60e87..66b25bb061 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h @@ -10,16 +10,21 @@ class nanovg_filterstack public: nanovg_filterstack(); + // Shared bgfx blur resources; Init/Dispose are refcounted across canvases. static void InitBgfx(); static void DisposeBgfx(); - inline static bgfx::ProgramHandle s_gaussBlurProg; - inline static bgfx::ProgramHandle s_boxBlurProg; - inline struct Uniforms + + struct Uniforms { - bgfx::UniformHandle u_strength; - bgfx::UniformHandle u_direction; - bgfx::UniformHandle u_weights; - } static m_uniforms; + bgfx::UniformHandle u_strength = BGFX_INVALID_HANDLE; + bgfx::UniformHandle u_direction = BGFX_INVALID_HANDLE; + bgfx::UniformHandle u_weights = BGFX_INVALID_HANDLE; + }; + + inline static bgfx::ProgramHandle s_gaussBlurProg = BGFX_INVALID_HANDLE; + inline static bgfx::ProgramHandle s_boxBlurProg = BGFX_INVALID_HANDLE; + inline static int s_bgfxRefCount = 0; + inline static Uniforms m_uniforms{}; void AddSepia(float strength) {} void AddContrast(float strength) {} @@ -37,9 +42,6 @@ class nanovg_filterstack ); void Render(std::function element); - // Returns true if this stack has any filter elements (blur/sepia/etc.) that - // require intermediate pool framebuffers. When false, draws render straight - // into the final (canvas) framebuffer and can share a single bgfx view. bool HasFilters() const { return stackElementCount > 0; } void ParseString(const std::string& string); From 2938fe633f03ef99b86686cc412a32896b28fee7 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 27 Aug 2026 17:23:13 -0700 Subject: [PATCH 02/16] Canvas: fix GCC filterstack nested Uniforms default-init GCC rejects default member initializers on a nested type used as an inline static member of the enclosing class. Match master: plain UniformHandle fields + value-init the static Uniforms instance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h index 66b25bb061..8a330d3993 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h @@ -14,15 +14,17 @@ class nanovg_filterstack static void InitBgfx(); static void DisposeBgfx(); + // No default member initializers here: GCC rejects them when the nested type is + // used as an inline static member of the enclosing class (complete-type rule). struct Uniforms { - bgfx::UniformHandle u_strength = BGFX_INVALID_HANDLE; - bgfx::UniformHandle u_direction = BGFX_INVALID_HANDLE; - bgfx::UniformHandle u_weights = BGFX_INVALID_HANDLE; + bgfx::UniformHandle u_strength; + bgfx::UniformHandle u_direction; + bgfx::UniformHandle u_weights; }; - inline static bgfx::ProgramHandle s_gaussBlurProg = BGFX_INVALID_HANDLE; - inline static bgfx::ProgramHandle s_boxBlurProg = BGFX_INVALID_HANDLE; + inline static bgfx::ProgramHandle s_gaussBlurProg; + inline static bgfx::ProgramHandle s_boxBlurProg; inline static int s_bgfxRefCount = 0; inline static Uniforms m_uniforms{}; From 6deda95e2c03f100200e39536af00501f4aaf9f8 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 27 Aug 2026 17:49:00 -0700 Subject: [PATCH 03/16] Canvas: GPU framebuffer readback for toDataURL and drawImage(canvas) toDataURL and drawImage(canvas) previously read only the CPU pixel mirror (populated by putImageData/drawImage of bitmaps), so NanoVG draws such as fillRect/text/paths were missing from the result. CaptureRGBA now flushes pending NanoVG commands, blits the canvas RT to a READ_BACK texture, and blocks on DeviceContext::ReadTextureAsync. Mid-frame flushes complete readTexture requests so the wait works under an active FrameCompletionScope (RAF / unit-test open frame) without deadlocking. Also: - ForceMidFrameFlush API; process readTexture queue in PerformMidFrameViewFlush - Bilateral Canvas/Context detach on destroy (JS cycle finalizer order) - nvgCreateImageFromHandle helper for external bgfx textures (NODELETE) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../Babylon/Graphics/DeviceContext.h | 4 + Core/Graphics/Source/DeviceContext.cpp | 5 + Core/Graphics/Source/DeviceImpl.cpp | 88 +++--- Core/Graphics/Source/DeviceImpl.h | 6 + Polyfills/Canvas/Source/Canvas.cpp | 18 +- Polyfills/Canvas/Source/Canvas.h | 1 + Polyfills/Canvas/Source/Context.cpp | 282 ++++++++++++++---- Polyfills/Canvas/Source/Context.h | 14 +- .../Canvas/Source/nanovg/nanovg_babylon.cpp | 23 ++ .../Canvas/Source/nanovg/nanovg_babylon.h | 4 + 10 files changed, 349 insertions(+), 96 deletions(-) diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h index ba2f0da10a..83e56392cf 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h @@ -129,6 +129,10 @@ namespace Babylon::Graphics // continue within the same logical frame. Call at draw/clear op boundaries. void FlushViewsIfNeeded(); + // Force a mid-frame flush when a FrameCompletionScope is held so pending + // bgfx::readTexture requests can complete (Canvas toDataURL / drawImage). + void ForceMidFrameFlush(); + // TODO: find a different way to get the texture info for frame capture void AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format); void RemoveTexture(bgfx::TextureHandle handle); diff --git a/Core/Graphics/Source/DeviceContext.cpp b/Core/Graphics/Source/DeviceContext.cpp index cfd77781d3..4b16299437 100644 --- a/Core/Graphics/Source/DeviceContext.cpp +++ b/Core/Graphics/Source/DeviceContext.cpp @@ -147,6 +147,11 @@ namespace Babylon::Graphics m_graphicsImpl.FlushViewsIfNeeded(); } + void DeviceContext::ForceMidFrameFlush() + { + m_graphicsImpl.ForceMidFrameFlush(); + } + void DeviceContext::AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format) { std::scoped_lock lock{m_textureHandleToInfoMutex}; diff --git a/Core/Graphics/Source/DeviceImpl.cpp b/Core/Graphics/Source/DeviceImpl.cpp index 7e5c54cb7f..4ebbcc17ce 100644 --- a/Core/Graphics/Source/DeviceImpl.cpp +++ b/Core/Graphics/Source/DeviceImpl.cpp @@ -588,37 +588,42 @@ namespace Babylon::Graphics return; } - // The flush advances a bgfx frame, which must happen on the render (bgfx API) - // thread. This method is only expected to be called from the JS thread while - // the render thread is parked in FinishRenderingCurrentFrame. If we're on the - // render thread (or affinity is unset), there is nothing safe to do here. - if (m_renderThreadAffinity.check()) - { - return; + ForceMidFrameFlush(); } - std::unique_lock lock{m_frameSyncMutex}; - - // The mid-frame flush advances a bgfx frame on the render thread, which - // can only be serviced while the render thread is parked in - // FinishRenderingCurrentFrame waiting for frame scopes to drain. That is - // only guaranteed while at least one FrameCompletionScope is active (the - // normal requestAnimationFrame render path). When a snippet drives frames - // manually (e.g. setInterval + engine.beginFrame/scene.render/ - // engine.endFrame) there is no frame scope, the render thread is not - // parked to service the request, and parking the JS thread on - // m_flushCompleteCV would deadlock. Skip the flush in that case; the hard - // cap in AcquireNewViewId remains as a backstop. Likewise skip if the gate - // is currently closed (bgfx::frame() in progress). - if (m_frameBlocked || m_pendingFrameScopes == 0) + void DeviceImpl::ForceMidFrameFlush() { - return; - } + // The flush advances a bgfx frame, which must happen on the render (bgfx API) + // thread. This method is only expected to be called from the JS thread while + // the render thread is parked in FinishRenderingCurrentFrame. If we're on the + // render thread (or affinity is unset), there is nothing safe to do here. + if (m_renderThreadAffinity.check()) + { + return; + } - m_flushRequested = true; - m_frameSyncCV.notify_all(); - m_flushCompleteCV.wait(lock, [this] { return !m_flushRequested; }); - } + std::unique_lock lock{m_frameSyncMutex}; + + // The mid-frame flush advances a bgfx frame on the render thread, which + // can only be serviced while the render thread is parked in + // FinishRenderingCurrentFrame waiting for frame scopes to drain. That is + // only guaranteed while at least one FrameCompletionScope is active (the + // normal requestAnimationFrame render path). When a snippet drives frames + // manually (e.g. setInterval + engine.beginFrame/scene.render/ + // engine.endFrame) there is no frame scope, the render thread is not + // parked to service the request, and parking the JS thread on + // m_flushCompleteCV would deadlock. Skip the flush in that case; the hard + // cap in AcquireNewViewId remains as a backstop. Likewise skip if the gate + // is currently closed (bgfx::frame() in progress). + if (m_frameBlocked || m_pendingFrameScopes == 0) + { + return; + } + + m_flushRequested = true; + m_frameSyncCV.notify_all(); + m_flushCompleteCV.wait(lock, [this] { return !m_flushRequested; }); + } // Called on the render thread from FinishRenderingCurrentFrame while holding // m_frameSyncMutex, with the requesting JS thread parked in FlushViewsIfNeeded @@ -640,18 +645,27 @@ namespace Babylon::Graphics // bgfx::frame() would flip a half-drawn backbuffer to the screen partway through the // logical frame; bgfx remembers the flush in m_flushPrevFrame so the next real frame // still flips exactly once. - bgfx::frame(BGFX_FRAME_FLUSH); - m_nextViewId.store(0); - m_midFrameFlushCount.fetch_add(1); + // Same completion path as Frame(): readTexture requests become ready once the + // returned frame number catches the request. Without this, a mid-frame flush + // could never unblock a JS-thread ReadTexture wait that forced the flush. + const uint32_t frameNumber{bgfx::frame(BGFX_FRAME_FLUSH)}; + while (!m_readTextureRequests.empty() && m_readTextureRequests.front().first <= frameNumber) + { + m_readTextureRequests.front().second.complete(); + m_readTextureRequests.pop(); + } - // Publish a new generation so holders of cached view ids (FrameBuffer's m_viewId, the - // Canvas blit reservation) can detect that their id predates the reset and re-acquire. - // Without this a cached high id would sort *after* every id handed out from the reset - // counter, inverting submission order relative to the JS-side draw order. - m_viewIdGeneration.fetch_add(1); + m_nextViewId.store(0); + m_midFrameFlushCount.fetch_add(1); - m_frameEncoder = bgfx::begin(true); - } + // Publish a new generation so holders of cached view ids (FrameBuffer's m_viewId, the + // Canvas blit reservation) can detect that their id predates the reset and re-acquire. + // Without this a cached high id would sort *after* every id handed out from the reset + // counter, inverting submission order relative to the JS-side draw order. + m_viewIdGeneration.fetch_add(1); + + m_frameEncoder = bgfx::begin(true); + } void DeviceImpl::UpdateBgfxState() { diff --git a/Core/Graphics/Source/DeviceImpl.h b/Core/Graphics/Source/DeviceImpl.h index 161162dea6..f5fa8dc939 100644 --- a/Core/Graphics/Source/DeviceImpl.h +++ b/Core/Graphics/Source/DeviceImpl.h @@ -104,6 +104,12 @@ namespace Babylon::Graphics // draw/clear operation boundaries where no encoder work is pending. void FlushViewsIfNeeded(); + // Unconditionally request a mid-frame bgfx flush when a FrameCompletionScope + // is active (same handshake as FlushViewsIfNeeded). Used by Canvas GPU + // readback so bgfx::readTexture can complete without waiting for the end of + // the logical frame. No-ops when the render thread cannot service it. + void ForceMidFrameFlush(); + // Frame completion scope support void IncrementPendingFrameScopes(); void DecrementPendingFrameScopes(); diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index c786ca8c7a..22f0ca153e 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -58,7 +58,13 @@ namespace Babylon::Polyfills::Internal NativeCanvas::~NativeCanvas() { - m_context = nullptr; + // Canvas and Context form a JS cycle; finalizer order is not guaranteed. + // Clear the reverse pointer first so Context::~Context cannot touch us. + if (m_context != nullptr) + { + m_context->DetachCanvas(); + m_context = nullptr; + } Dispose(); } @@ -262,7 +268,15 @@ namespace Babylon::Polyfills::Internal std::vector rgba(static_cast(width) * height * 4, 0); if (m_context != nullptr && width > 0 && height > 0) { - m_context->ReadPixels(0, 0, width, height, rgba.data()); + try + { + // GPU readback after flush so NanoVG content (fillRect/text/paths) is included. + rgba = m_context->CaptureRGBA(); + } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } } #ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES diff --git a/Polyfills/Canvas/Source/Canvas.h b/Polyfills/Canvas/Source/Canvas.h index a21c617c13..ee1ed61a5a 100644 --- a/Polyfills/Canvas/Source/Canvas.h +++ b/Polyfills/Canvas/Source/Canvas.h @@ -73,6 +73,7 @@ namespace Babylon::Polyfills::Internal static inline std::map> fontsInfos; bool UpdateRenderTarget(); + bool HasFrameBuffer() const { return m_frameBuffer != nullptr; } Babylon::Graphics::FrameBuffer& GetFrameBuffer() { return *m_frameBuffer; } FrameBufferPool m_frameBufferPool; diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 5682168b42..d4b0cd8816 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -1,13 +1,18 @@ #include +#include #include #include #include +#include #include +#include #include #include #include +#include #include #include +#include #ifdef __GNUC__ #pragma GCC diagnostic push @@ -147,9 +152,15 @@ namespace Babylon::Polyfills::Internal { m_canvas->SetBoundContext(nullptr); } - Dispose(); - m_cancellationSource->cancel(); - } + m_canvas = nullptr; + Dispose(); + m_cancellationSource->cancel(); + } + + void Context::DetachCanvas() + { + m_canvas = nullptr; + } void Context::Dispose(const Napi::CallbackInfo&) { @@ -761,6 +772,23 @@ namespace Babylon::Polyfills::Internal void Context::Flush(const Napi::CallbackInfo& info) { + try + { + FlushCore(); + } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } + } + + void Context::FlushCore() + { + if (m_canvas == nullptr || m_nvg == nullptr || *m_nvg == nullptr) + { + return; + } + // Pick up any fonts loaded after this Context was created (#1683). EnsureFontsLoaded(); @@ -803,67 +831,203 @@ namespace Babylon::Polyfills::Internal // now it shares the frame encoder with NativeEngine. encoder->discard(BGFX_DISCARD_ALL); - try + // bgfx framebuffer pool exhaustion can throw both from UpdateRenderTarget() and + // from FrameBufferPool::Acquire() reached via nvgEndFrame() below. + const bool needClear = m_canvas->UpdateRenderTarget(); + + Graphics::FrameBuffer& frameBuffer = m_canvas->GetFrameBuffer(); + + frameBuffer.Bind(); + if (needClear) + { + frameBuffer.Clear(*encoder, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.f, 0); + } + frameBuffer.SetViewPort(0.f, 0.f, 1.f, 1.f); + const auto width = m_canvas->GetWidth(); + const auto height = m_canvas->GetHeight(); + + for (auto& buffer : m_canvas->m_frameBufferPool.GetPoolBuffers()) { - // The entire flush is wrapped: bgfx framebuffer pool exhaustion can throw both from - // UpdateRenderTarget() and from FrameBufferPool::Acquire() reached via nvgEndFrame() - // below. Converting any such C++ failure into a catchable JS error lets the offending - // test fail cleanly instead of aborting the whole sweep. - const bool needClear = m_canvas->UpdateRenderTarget(); + // sanity check no buffers should have been acquired yet + assert(buffer.isAvailable == true); + } + std::function acquire = [this]() -> Babylon::Graphics::FrameBuffer* { + Babylon::Graphics::FrameBuffer *frameBuffer = this->m_canvas->m_frameBufferPool.Acquire(); + frameBuffer->Bind(); + return frameBuffer; + }; + std::function release = [this, encoder](Babylon::Graphics::FrameBuffer* frameBuffer) -> void { + // clear framebuffer when released + frameBuffer->Clear(*encoder, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.f, 0); + this->m_canvas->m_frameBufferPool.Release(frameBuffer); + frameBuffer->Unbind(); + }; - Graphics::FrameBuffer& frameBuffer = m_canvas->GetFrameBuffer(); + nvgBeginFrame(*m_nvg, float(width), float(height), 1.0f); + nvgSetFrameBufferAndEncoder(*m_nvg, frameBuffer, encoder); + nvgSetFrameBufferPool(*m_nvg, { acquire, release }); + nvgEndFrame(*m_nvg); + frameBuffer.Unbind(); - frameBuffer.Bind(); - if (needClear) + // Reserve the view id for the eventual canvas->texture blit NOW, while we are + // sequenced immediately after this canvas' draws but before the scene/backbuffer + // render is recorded. bgfx processes blits in numeric view-id order, so the copy + // must land AFTER the canvas Flush (source ready) yet BEFORE the fullscreen ADT + // layer samples the destination texture. Deferring to CopyTexture's + // PeekNextViewId() would place the blit after the backbuffer view, so the layer + // would sample the previous frame's content (a one-frame GUI latency). + m_canvas->SetBlitViewId(m_graphicsContext.AcquireNewViewId(), m_graphicsContext.ViewIdGeneration()); + + for (auto& buffer : m_canvas->m_frameBufferPool.GetPoolBuffers()) + { + // sanity check no unreleased buffers + assert(buffer.isAvailable == true); + } + } + + std::vector Context::CaptureRGBA() + { + const uint32_t width = m_canvas != nullptr ? m_canvas->GetWidth() : 0; + const uint32_t height = m_canvas != nullptr ? m_canvas->GetHeight() : 0; + std::vector rgba(static_cast(width) * height * 4, 0); + if (m_canvas == nullptr || width == 0 || height == 0) + { + return rgba; + } + + // Push pending NanoVG draws into the canvas RT first. + FlushCore(); + if (!m_canvas->HasFrameBuffer()) + { + return rgba; + } + + // Hold a frame scope so ForceMidFrameFlush can complete the readback even when + // the unit-test host (or RAF path) already has the frame encoder open without + // any JS-held scopes (pendingFrameScopes must be > 0 for the handshake). + std::optional scope{ + m_graphicsContext.AcquireFrameCompletionScope()}; + + bgfx::Encoder* encoder = m_graphicsContext.GetActiveEncoder(); + if (encoder == nullptr) + { + return rgba; + } + + const bgfx::TextureHandle srcTexture = bgfx::getTexture(m_canvas->GetFrameBuffer().Handle()); + if (!bgfx::isValid(srcTexture)) + { + return rgba; + } + + const bgfx::TextureHandle blitTexture = bgfx::createTexture2D( + static_cast(width), + static_cast(height), + /*hasMips*/ false, + /*numLayers*/ 1, + bgfx::TextureFormat::RGBA8, + BGFX_TEXTURE_BLIT_DST | BGFX_TEXTURE_READ_BACK); + + if (!bgfx::isValid(blitTexture)) + { + throw std::runtime_error{"Canvas.CaptureRGBA: failed to create readback texture."}; + } + + // maxViews-1 is reserved for readback blits (see DeviceImpl::FlushViewsIfNeeded). + const bgfx::ViewId blitView = static_cast(bgfx::getCaps()->limits.maxViews - 1); + encoder->blit( + blitView, + blitTexture, + /*dstMip*/ 0, /*dstX*/ 0, /*dstY*/ 0, /*dstZ*/ 0, + srcTexture, + /*srcMip*/ 0, /*srcX*/ 0, /*srcY*/ 0, /*srcZ*/ 0, + static_cast(width), + static_cast(height), + /*depth*/ 0); + + std::mutex mutex; + std::condition_variable cv; + bool done = false; + std::exception_ptr error; + + m_graphicsContext.ReadTextureAsync(blitTexture, rgba) + .then(arcana::inline_scheduler, arcana::cancellation::none(), + [&](const arcana::expected& result) { + { + std::lock_guard lock{mutex}; + if (result.has_error()) + { + error = result.error(); + } + done = true; + } + cv.notify_one(); + }); + + // Drive mid-frame flushes until the readback completes. Each flush ends the + // current encoder, runs bgfx::frame(FLUSH), completes ready readTexture + // requests, and opens a fresh encoder for the rest of the logical frame. + // + // Keep `scope` alive for the whole wait: releasing it while a parent RAF + // FrameCompletionScope is still held would leave FinishRendering blocked on + // that parent while we block on cv — deadlock. ForceMidFrameFlush is the + // path that advances bgfx while any scope remains. + for (;;) + { { - frameBuffer.Clear(*encoder, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.f, 0); + std::lock_guard lock{mutex}; + if (done) + { + break; + } } - frameBuffer.SetViewPort(0.f, 0.f, 1.f, 1.f); - const auto width = m_canvas->GetWidth(); - const auto height = m_canvas->GetHeight(); - - for (auto& buffer : m_canvas->m_frameBufferPool.GetPoolBuffers()) + m_graphicsContext.ForceMidFrameFlush(); { - // sanity check no buffers should have been acquired yet - assert(buffer.isAvailable == true); + std::unique_lock lock{mutex}; + if (done) + { + break; + } + // Yield briefly so a render thread that is between Finish/Start + // (unit-test pump) can enter Finish and service flushRequested. + cv.wait_for(lock, std::chrono::milliseconds{1}, [&] { return done; }); } - std::function acquire = [this]() -> Babylon::Graphics::FrameBuffer* { - Babylon::Graphics::FrameBuffer *frameBuffer = this->m_canvas->m_frameBufferPool.Acquire(); - frameBuffer->Bind(); - return frameBuffer; - }; - std::function release = [this, encoder](Babylon::Graphics::FrameBuffer* frameBuffer) -> void { - // clear framebuffer when released - frameBuffer->Clear(*encoder, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.f, 0); - this->m_canvas->m_frameBufferPool.Release(frameBuffer); - frameBuffer->Unbind(); - }; - - nvgBeginFrame(*m_nvg, float(width), float(height), 1.0f); - nvgSetFrameBufferAndEncoder(*m_nvg, frameBuffer, encoder); - nvgSetFrameBufferPool(*m_nvg, { acquire, release }); - nvgEndFrame(*m_nvg); - frameBuffer.Unbind(); - - // Reserve the view id for the eventual canvas->texture blit NOW, while we are - // sequenced immediately after this canvas' draws but before the scene/backbuffer - // render is recorded. bgfx processes blits in numeric view-id order, so the copy - // must land AFTER the canvas Flush (source ready) yet BEFORE the fullscreen ADT - // layer samples the destination texture. Deferring to CopyTexture's - // PeekNextViewId() would place the blit after the backbuffer view, so the layer - // would sample the previous frame's content (a one-frame GUI latency). - m_canvas->SetBlitViewId(m_graphicsContext.AcquireNewViewId(), m_graphicsContext.ViewIdGeneration()); - - for (auto& buffer : m_canvas->m_frameBufferPool.GetPoolBuffers()) + } + + scope.reset(); + + if (bgfx::isValid(blitTexture)) + { + bgfx::destroy(blitTexture); + } + + if (error) + { + std::rethrow_exception(error); + } + + if (bgfx::getCaps()->originBottomLeft && height > 1) + { + const size_t rowPitch = static_cast(width) * 4; + std::vector row(rowPitch); + for (uint32_t y = 0; y < height / 2; ++y) { - // sanity check no unreleased buffers - assert(buffer.isAvailable == true); + uint8_t* front = rgba.data() + static_cast(y) * rowPitch; + uint8_t* back = rgba.data() + static_cast(height - 1 - y) * rowPitch; + std::memcpy(row.data(), front, rowPitch); + std::memcpy(front, back, rowPitch); + std::memcpy(back, row.data(), rowPitch); } } - catch (const std::exception& ex) + + // Keep the CPU mirror in sync so getImageData after toDataURL/drawImage sees GPU content. + EnsureCpuBuffer(); + if (!m_cpuPixels.empty() && m_cpuPixels.size() == rgba.size()) { - throw Napi::Error::New(info.Env(), ex.what()); + m_cpuPixels = rgba; } + + return rgba; } void Context::PutImageData(const Napi::CallbackInfo& info) @@ -1221,11 +1385,19 @@ namespace Babylon::Polyfills::Internal return; } - Context* const srcContext = srcCanvas->GetBoundContext(); + // GPU readback of the source canvas (flush + RT blit + readTexture) so + // NanoVG draws (fillRect/text/paths) are included — not just the CPU mirror. std::vector rgba(static_cast(width) * height * 4, 0); - if (srcContext != nullptr) + if (Context* const srcContext = srcCanvas->GetBoundContext(); srcContext != nullptr) { - srcContext->ReadPixels(0, 0, width, height, rgba.data()); + try + { + rgba = srcContext->CaptureRGBA(); + } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } } const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); diff --git a/Polyfills/Canvas/Source/Context.h b/Polyfills/Canvas/Source/Context.h index 784864804a..5de25be830 100644 --- a/Polyfills/Canvas/Source/Context.h +++ b/Polyfills/Canvas/Source/Context.h @@ -33,7 +33,15 @@ namespace Babylon::Polyfills::Internal // Out-of-range pixels are written as zero. Used to implement getImageData without a GPU readback. void ReadPixels(int32_t sx, int32_t sy, uint32_t w, uint32_t h, uint8_t* dst); - private: + // Flush pending NanoVG draws to the canvas framebuffer, then read the full + // RGBA8 contents back from the GPU. Used by toDataURL and drawImage(canvas). + std::vector CaptureRGBA(); + + // Called by NativeCanvas when the canvas wrapper is destroyed so this context + // does not keep a dangling m_canvas pointer across JS finalizer ordering. + void DetachCanvas(); + + private: void FillRect(const Napi::CallbackInfo&); Napi::Value MeasureText(const Napi::CallbackInfo&); void FillText(const Napi::CallbackInfo&); @@ -103,8 +111,10 @@ namespace Babylon::Polyfills::Internal bool SetFontFaceId(); void EnsureFontsLoaded(); void Flush(const Napi::CallbackInfo&); + // Shared body of Flush() for C++ callers (CaptureRGBA). Throws std::exception on failure. + void FlushCore(); - NativeCanvas* m_canvas; + NativeCanvas* m_canvas; std::shared_ptr m_nvg; // A gradient style holds the assigned JavaScript object, not a bare CanvasGradient*. diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index f767282baa..43592cac58 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -1465,3 +1465,26 @@ bgfx::TextureHandle nvglImageHandle(NVGcontext* _ctx, int32_t _image) GLNVGtexture* tex = glnvg__findTexture(gl, _image); return tex->id; } + +int nvgCreateImageFromHandle(NVGcontext* _ctx, bgfx::TextureHandle _handle, int _width, int _height, int _flags) +{ + if (_ctx == nullptr || !bgfx::isValid(_handle) || _width <= 0 || _height <= 0) + { + return 0; + } + + GLNVGcontext* gl = (GLNVGcontext*)nvgInternalParams(_ctx)->userPtr; + GLNVGtexture* tex = glnvg__allocTexture(gl); + if (tex == nullptr) + { + return 0; + } + + tex->width = _width; + tex->height = _height; + tex->type = NVG_TEXTURE_RGBA; + // Do not destroy the caller's texture when nvgDeleteImage runs. + tex->flags = _flags | NVG_IMAGE_NODELETE; + tex->id = _handle; + return static_cast(tex->id.idx); +} diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.h b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.h index 18800babe7..17c2afe84d 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.h +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.h @@ -34,4 +34,8 @@ void nvgSetFrameBufferAndEncoder(NVGcontext* _ctx, Babylon::Graphics::FrameBuffe /// void nvgDelete(NVGcontext* _ctx); +/// Register an existing bgfx texture as an NVG image without taking ownership +/// (NVG_IMAGE_NODELETE). Returns the image id, or 0 on failure. +int nvgCreateImageFromHandle(NVGcontext* _ctx, bgfx::TextureHandle _handle, int _width, int _height, int _flags); + #endif // NANOVG_BABYLON_H_HEADER_GUARD From 8ffc000738ebdce77ca7f2d28080e080cea74cf4 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 27 Aug 2026 18:11:52 -0700 Subject: [PATCH 04/16] Canvas: regenerate native-canvas.png golden for em-scale text fix The fontstash em-square scale change makes canvas2d text match CSS px sizing (glyphs were previously ~0.86x too small for Droid Sans). Native Canvas playground (#TKVFSA#8) draws large stroked/filled text over shapes, so the old golden (captured at the undersized scale) fails pixel compare at ~4.1%. Same golden update as shotgun 520be832; CI rendered result is within 0.125% of this reference (limit 2.5%). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../ReferenceImages/native-canvas.png | Bin 962463 -> 962463 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Apps/Playground/ReferenceImages/native-canvas.png b/Apps/Playground/ReferenceImages/native-canvas.png index 57530a052688bd07a59e4ff2ca0b4562a3dd984c..b3c09f8afc3722643189f3ccc3c97911bc9a9577 100644 GIT binary patch literal 962463 zcmeF4Nm~?65a)|kK}3;VR6tNva6$BS1KD?E-%(^o#a$6aq0e)h;~eIhuQ10CmSkC!B|IcV@8X^(Vf!Ki9fY^Z8fY^Z8fY^Y0HXxKG*5sZw zi7rK#Vgq6WVgq6WVgo@N5Xus32wH>aLUbWEAT}U2AT}U2;GPW#Wr;PpXHB9@(WTgc z*nrr8*nrqT&<2FE#2SLuAi5A;hz*Dhhz*Dhhz+=B143D1P3~Ef=u&hkHXt@2HXt@2 zHW0J{p)9e6pf!jtL>FQMVgq6WVgq6W?%9A)mROT})+D+VU5X8e4Tueh4Tuc{Z9phX ztRZL(q6^W5*nrr8*nrr8*noRBAe1H6?pc%QQgkUcAT}U2AT}U25VQfIEU|{5HHa=m7h(fq17ZVW17ZX2 z*?>@%Sd)9!B)Sw`iVcVjhz*Dhhz$g7KqyPBA!rSv3(aLUbWEAT}U2AT}U2;GPW#Wr;PpXHB9@ z(WTgc*nrr8*nrqT&<2FE#2SLuAi5A;hz*Dhhz*Dhhz+=B143D1P3~Ef=u&hkHXt@2 zHXt@2HW0J{p)9e6pf!jtL>FQMVgq6WVgq6W?%9A)mROT})+D+VU5X8e4Tueh4Tuc{ zZ9phXtRZL(q6^W5*nrr8*nrr8*noRBAe1H6?pc%QQgkUcAT}U2AT}U25VQfIEU|{5HHa=m7h(fq17ZVW z17ZX2*?>@%Sd)9!B)Sw`iVcVjhz*Dhhz$g7KqyPBA!rSv3(aLUbWEAT}U2AT}U2;GPW#Wr;Pp zXHB9@(WTgc*nrr8*nrqT&<2FE#2SLuAi5A;hz*Dhhz*Dhhz+=B143D1P3~Ef=u&hk zHXt@2HXt@2HW0J{p)9e6pf!jtL>FQMVgq6WVgq6W?%9A)mROT})+D+VU5X8e4Tueh z4Tuc{Z9phXtRZL(q6^W5*nrr8*nrr8*noRBAe1H6?pc%QQgkUcAT}U2AT}U25VQfIEU|{5HHa=m7h(fq z17ZVW17ZX2*?>@%Sd)9!B)Sw`iVcVjhz*Dhhz$g7KqyPBA!rSv3(yh_uI@zkEEg@qav?_}wQUh+i2N0R=sAp)J;O^>>ti z5`C3A-Pb$DwE7eZ+8wPuwDCRoTNTQ3=h-JdB)Rk`lqD9KTo&opc$_q4=@^zKAbl*1 z!3$ZvmM+VBJPFEr=t6(2{qy4YyHV;1V|yC)5KFt;xUWK4-h2+b^*le#oRZ>~~l+K*Vvz7DtWr6GT_0b%wYg>-Qu{057W}Q7&*=UC@>&P)J z?NHe-@`~c>sXDG-?23;&Ms&SPn{xfaw^&Vp<1*!*rTyOe)NeOOU}`^>-L zJMsxZaG)>qZ+4%irVRip+C5dA@MxF)Ig; zYL#3**Ul2|em8%2A}rlD#Ct##Zq^yDEGNRkEL3w$BVFnWU zTGrp}+HJkY`n`?I5y}$E@~Y1g%JS;Srh31GvW|K{t9NtjJ7OYcn$Vx)x%My=VS#3J zMz?N@KE^X69;Be_u^y4F9*w?nzx8^|HXfH;3%Cr(O`Gzq^@aNWAK#y6Q-)o;LzRQy z+E|3LgtENqvxKs|I957u(5?T)0qLRmssUiMc)SzaF9RPLKl*6}@ZBp%Xy zjnnL*n`;l9^01KeWVf%r6KP@g(5*bJPND#q+-EDTV3&m(EaC18_dD{!Q1)?S zVyFjsULYiMoz`}ne{=ebG9;EIq~%qAC6wjWkxlh}31uDjeoh|F?ek8g<;L2>P=tlu zIi31~OmBF9t6;{<8jEo%!|A*eVPSSJ(oA=|vaPg&w<+7@LD}6K>YN)nS-Sj7+$L+B z&f0E$ZKz`#mm`!Vl;u^QC6wjWkxlh}31vA`M<|cy#&1rfb)2<_PK1RlZsZ7sYj-Hp ziZ!O?L|BNc9fz>c7b~q)^SR?>zMJ&}Ix>Hc^)q(wZMBqTZFeMP3uOsmdD&kHWqEmY zQ@L+KS?+k!NW7%oHBO`z%i6x`?l-I27bjmwc4!_)PA6@M7{cnM{R zwM1?$r%6{rS?+k`NW5gIYn(_cD*{g+Du#8Oc+qi4D^v{2Ey996t+aytC#>lUrRzlh zhM7@o7QMCIk+exD%bU;O)8sGW*;b*fcxqS5KDnodP*(DIWGB)Jwe~Pn9+ur&!brTi z`5GtE3bpnyR36rGsL$Lk{n?IIvTUeEU!UkBRvQ%BP@i49!?jH)>*Su3;-liPd!a0` zviMusN$6N8D|sjjUe+m3%WmzV6Ja5C5^61BIB#zK-6>DYZtbBHVI6lZp|wr=yB)3M zbN@jk`kIqGR&hHJ*R!_4uHDvatl!ZmnKAU{bND2l{mE9RP*$>yh_uI@r;Sk7Q4eVK zZi)WxL|W#xhfairwS;aGmhO8f($Z@Wod^rm=SEyjx5a$4qm^4+-&Gd#btm;1neQ)b z+G5x4aBURIa_5;Rz9TvHD3m3ZnVgn++;I`gN)B&q^{`H)g-mKEnt|tzW-Xz$&T!wI zW)DGsq4KcY$|ARGpA)Sd_x*!*bwzs){YJdc?mnk>+g+>A31xZn8GPJlxL58M%JRxc zNtQO+} zd#bxU12jna#s*~@KPl7rO&P`?mR(9W{<8d2l4k6YU-tR9Z%ZQZZibQ0;&WY4=V8(9 zDGjtG!{|_LM<23{ZGQX8CS5Ux*n8fmW~1N;6+}u}(GnKoVOpZyD?L)uiiNQJg@PbkZq&tda3IaNOkWu@xKd%fSuN?8We z)$vKlKIRiI$~xNKJ?=wAfUq<{7)D=2eJe6vb3En_l^ADi%JBCMGh@-@Jb^wiixq5( z|8qT`v-9ju=R%Pd^04f3k(|mxn=lh;X+mkX!g<9WW4>%AVL?ojP^ zYF8VVCzK_Wi4!@pscS<4rh-z-Z9}<2SwdM}^;tq$ULDy~@7GCD)&rxCXH=BLfv}>9tAYBCLs+1ZaI{j%*My2GIT6iq&ZAFQ0~)G~aP79cw~fmb z$`Z=*s?QS2^6JQ@dcRJBvI==7%pb?uRbCPv=csrgmZf7=pSXWcvwqQ%7UE;mbir5) z>BX&H+KFb4>OFeWYUc5aWOmQmCag`gs~<5k>$Ohbalg}VIFDEv;#p{8sO#MN4SGPo z!j%`U-FEl4ak)ZSLRnt*SwdM}9obaxSE4Daz*u6>s_7$DC=31l&XQ(J6_Ms?1@-3F zA2R3dys`Bh@{{anro`bquD!!zEyvC8ARoInMMGF<56TGFu7`XLX7CU@3wP~te#hH= zkS7PS3s+7g?Y5TD#^nlS31xZJX9;C_b!1b$Ux}ow3S*ePq9!aHQ;D*|^|#le8?&CA zKNlPGAw9YiVHF>c06aH5zMaI2wA>;rPzc&>r4hUD)qGvF1ZP)HsB_5Rk91vSr0q7> z*T&@wWeH_@)n^H1d39t{yXM7H+Os>ded`VrD_ zT1$vJHDPHF4l+QV*U|PwKYOUlv^zSjB}Bck5)kgStIO^jW=-R@<`8i~kepq;cITpL zx4yrP%NNQL%JQnu63X)G$fkO~5<^*Obk;yVkx-Uj2X3xC^l{$N5BBb90S}LvF{ey- zD`9C5j{UZfy$M9s>2ocYY4=8G zr|WS(+IHifHWr~Qp)9ZZETJr~j%=#;D-o2{uGZLD2@Bs-F_xwEfQ)#z)*kxkxlybr z3-KcCGjAm&e0L%&O#^r%Os2!V$002@))MMAT947vN;~%tG>N`i%ZlYY^a6dOkI~#8 zZM#u+8;ekuP?lGHmQa>gM>f^_l?ckJHoDmBYq~HWQ<<`$2lv(k_h-tY!d*~FyiglJyw-v`&Xazf_f+nNgFW+1*K4U#0a?C((*5AT?kEPvhT#rze zP?lGHmQa>gM>f^_l?cl6zwO3MSNe0RQx^1qwTF1)&rvdpcI{!V!y3j&2}_d!^0e&R z6aCu5vkqmsL0D*SEVP3A{$l}YMYBh})(?(&KOtx|7Q%|P-9lMHSYGy5LRnrO-Bj*d zA}Gr~js?OH%JS3*|gzNn?q2FRfb# zx`}p{(A@S|+Kn-hSeB5MSN)YxmRCnM)%z81%IY#Q*jww^mGu~kF1)lWskPsB-?8=( zy0W92P-_qIuEY}C`(h<5O#^!WC%ZO<%F{w_nB98`QI1eKhc((X2{Qz%O)%d0+1D9fuOo9g|FH)Y*69y0;hQ3cKkWgXTH)*eQ) zNBm#DzMSPKnH`s~Gz~<%_Au01LbnMEZH$Fhbl>z5vwqmMG}Qj}psK4Z1uVxUtXSJE zlqH1aWq&1<<>k>$<-Wz6vMw8A>1a)AXzgX=(;h;!XO`0jLXwC7`K zw@{W)mY02%P?ncRHp~+JpzwxzBYY&mLc664|FzSxP zVM2L-cUXR%my5b8S!)tPJ#|5m5U zj?~k`TjPGvM5OoNI(YiS^*Vo#c9szFB9x8n@<`i+>m$83n(Gj|!we#3L8G}Z(raUB zcN@1wC`%~It3FF8%c~=s>ivp0Wu+U)&~_q;RG_SR3zZN2AxEX1(%y*T16*O9ad*GAIkWVSvmUg#sUxl)Svb^fEgtELkvZ>y$cvDuIfwgc> z^x$>MLcgD|Xu2{VFY@{A5W2DI%d2;gzl5@^G!u{Sp-3y5nAQ)b ziY=2IZL1Segd;3XIEZOQv!^*`4?#m(oa(nbZ=EI7ZNM&}kq{QjjD=S0+LGvVv9!C5 z`zw?sl;u^QC6wjWkxlh}#hny z_hTP?JL1Xa^U;*!e65uvLJ=0?P?v4n6aCslYrUbq>$QY-x`<}R5_QBvE1}w!sNZ5~ zcN_OtC`%~It3FF8%c~=s>irVRawZnH$mei5PNapkhsTd;nJM9}13_UADDxYVok$D2 zaYnMv&s=A?&rUHcw5gRP-2K+yk-v3X#oCu}--WV-u)OTAgtELmx~beZp{ztx7V2}F zJ&bnk;nB8hPwkY4g`6j3t?TRb5qq8^zG4-;^*Nfghq^qwqZ468^R{NYa#Q7;P?k`Z zmwlB`mX}92mHQTN$~qj!u~I=Q?nGMAu01?lE{G!(VLj#Fov6f#w4z;mXjlGm z*Am*5nQG^RvV^j{?5l*byga(8+&7`D!;})53f;ao6lv{nTq*kcoCpgua-j%I6N?jR zD9g*Eo63C?%8H(3Vxb@Bdz?rMvxm{< zX^kJ5@zXPa(Y%Y#DNiewwTDiGg?5>9!%~%;qUpC#mQa?LeU(s_mq$01`zDli(v*ez zLuJHA+P*O^2k%Nenp=gngwc3&Tk(S-sLubN@B-1@o;&LJ_yS0b1mN0gT3O61?SwdM}_Ekbz zULM_4?we3nBFHJ)Izo|_j%nEwR-|hQ!+CV;?@pwZ%ke3__ApcoOV$z|pT2~$gtENs ztAw(=Ji4jeH=(R(2`Ew5JCPRF9*)?~5*kcA(X1u3l2o|wPNapkhfairHpwjE@d*oK zBa|hS z9SLO#WqH|G31xYCbW^!+LRqoUPa^M$gtV}O!xt7`LWm|3(vr1=k&#n0gejCIl;ve# zC6wjm(M{#P31uacm}0HVY4#AYqb!#9im(zzSs(+a*+ay%WG$ilgaw@oWeH_@*;ff= zd3khGxo<*Qv64@s@3SK<5Y|S3uo6jGPNbE|G$5JoLRqQ&OqIUJM#Dl`VmYx{PO|Gw zC@aylbe#Ikq?N&RwRwoJ5=~iF(n1VN2rE8>C9y1TK7W(lKP0k!LRpE_w$zolCyE_q z-KlJ%nwzaks%u-Q>~e#xR9t-!xK3YhJsLNeDxBoTU#5hk8Pn=q)+OiroXWGFcPcwA z{czcN$046Y->XUM;}OC-3Cc2)maHX=A7Ke)CGMFh{^w+LEtDmecQTe2Z@t@57M|#C z2|Ryj$#)>FhA{VqD;K{z5S+o36}DZGlz&p!Y&*8!t#OOC9OMi|dyiZnoz9?RdBcu3 zpTqHf&YyniDN?FcyC*%?xxtu0_31vn9d=sB= zdUPg~CDwX+tTmdkI(fV&i0-#MF3V03KXa>&KKIwBOx z^5!!*n&-8buNBJj^5~{=-;#r}l8aPMGl88HxeV}Z);P;F%N)xL%OuM%OApIQwaZKO z3S|jpdD&M9WqEmYQ@L;PrmQq$-?MR}WTde~QpF(C(+bOPmR*)lESoH=EbA=qS$?wY zvwUQkVo7BCyjGV`mQa?LeU(s_mq$01`zDm-tvFgD$O5q`(9~a+w=Az%Le<;Lzk{&8 zvuv{k>$<-Q4JouV1T%->{`j+0T#JCmBFVt|b7pNeMVNe#%sLavn? zbs#2&8Alvjvkj`dV$ikP!*aLOpw4u6>T{#bCtZe6mQa?LeU(s_mq$01`xbx7!v5~3 zh-8wvtxqqK(Z+j#IY?&uwkrhWL=%|lbNVfo^PjoAaVD&^R_8vQf5dgBxm#~6ZB2%I zgtCOPyzHxlvb;RHsob}CQ&xtNK+4MFeYei9M=oc-a17|j>7c+f-ve~jzt&F!>jMEP z94J)36=e^*N639DqVB+wt-x2#Tn8#WVVTb8@^9;eMGaqmke z(I@8(YRWUH<}C03UiXYr`I#ks_as3UP|{IG%sWZ(c^6 zsPP&ZRjp)XB&f~N#Ldop4fN0F^O0HVCU_@TrjeSQ23IM_N)$eqDQkqwo4e$vtorHt zLw!R3@_KkjS?(8R6s>JZmG4bVq3JIVVtp_8c*~%hcZIUVA9>Fo31x{@d(Wydrtzn& zKk=t5P!W6MjP?x5?aqx@9I2}{jf_X7WaNH|f7>G^tq3UVQBHnbX+$V1@wAuy+)r4_ zuG?}cyFQ!h?mm}T7C*bhCwWT;LRn(b-m+*tp7ExvEMq7Bq>{lT$COnuTyZ2Gf|y98 zvhr<(u8nX!i^~c~U1R*)9|>tiKv^$x;!6QSS@G4cU3^a4;m7Td^y}9z8XX;Fy66$Y z5X$nL4G3jvuPjGz(N?@EtI&88e@bzQvdlF0-@<!f$Z-tyuT>HM}r>vKmoE4vd95W}<63SAP1;YCM*`Qw^4C+gPtazcUlYKsl ze@dMGg|fuj5@&6R)OEZm3;+MeeZQ;_lvO)k8_cmX=UbURXLn{(^=LJH{Poe6xULLe z@t0BaJpO$V$_k8ub&VmY>d#k$fA`A{(^iki_tFx|^5%0lk-jk{a36MuP{N-R-ma^LhHS?~P;V7Uz)1d$IkBTow!m=w1bwop1`+G+T zOOw{$Ux)jI{k3YU49aW{_8mIFzEdR)20cG_`1)}AF#m4tOGn1hvhI`{bmrONx>5gS z-s|eo#iQ4o%eQ_O%JSxOH`FsZReux8O4X6~dcPAvS&t3gG1N*SZhk*5W$CswpHZ|j zv@vvqw6wP~um`)o-h2e9`u*GD*2`M}AY z!{D7mH8Hq-bc?d8+LV`k{^q$eW#xQ6+K)@G$#{1DkcTZBDkGFL_i;{T*#F!AN09B^ zZF>FrHNANAf+}8BPDn0&##`p0pn7Ire0wD(m;s zdVgQ=CLrAx9YfUR;CQy{EHz9w5N4h+))@0&e}AblZ(C}k%ddFW`9S}OwSms8b(z2G zYlX6evb^r2gtELo!l~N7L{JvIMuTzRnIJSt#Cl95Wm#ic#nS<=nctgFy{o;1JT6~^ zyP^L0^MmTA>iuP4X3=S8GXly238B1jb+3P4r=o!(RVQY$%%pYUp`RvRZM-^K4v!~FAICCZv%7bJ@A-46KA+84eE;)Z z(U7K?vOA$^M^o0!)=Z%6eILg!xgT0UQ8;H;pZOftwQAZ;XB~Bhh-vAvg|dXQyzZlf zvb;XRsoK9pQC7E+M#aW#d`QbJ%DR15c}&E7%w7?nmAAug4wxA<(;#O1*weNwwy3y; z^}a~IqfS)^a<;O59j*iQg`g}D)4iE{6lq=hI;^Wiop_6jo;`d})}D5x9K`TY@4ahIu`Ey$Xem0v3SNJS?+s5oJ|0)>@{!rW?jNOSB+Mw``>jiam302-3Rm0iLyGD zJJigiE-RWNW)shJ`EvoEoVTx6*&Vk_C`%~I>pn^-%j+YYs{KnOWofVV+$g3j!zNy1 zC74LRABVECIqUq!fBw8E_GXv*5eGROlepM-(N8~i`@dr?A>RkV4yO8E!44YwOrMrfe=7V!y_a`+RJ| z@{`ui-?T&dS0E|q9&sd}WsE1@PpCE_SIVwGPLyRW2gHk9JG1`r9XVXqYw>+}b6C}z z_sG6ptt+7{p)9ZaD4{H`k8rB??<6S8?8P1#Ra9n7QjYP3vW=gV$#0j=F#b@w@t4w! zy#v|jqhF51l0agSP}YHG*Ivu+h`cJRCq$;Yx|U}P)6@12Nk;k}Wv2h5jLHsm4YH#9 z*82R{AJmt|$84sYT+o&J93&MpmFaqsDUSL+F;!{8ij=bM&v5SBLA_agM17FVgLC+8 z>ciBLJ~quYsr$_FEL;mB(d|-4!=PJ}x74**i)bE4^XH}le;@I7qC@vHRa_mus_sY3 z$V^%HW^VDdIV^rca_jhI$DkbE!6Ewv@8r-L)cxqY(Iaisy1!O?v~S@k3$zM-1@zCf zhB_h0eZzUoO!_Qzhwu0eyG)pMs6XrSgtCOPyzZlfvb;XRsoKAjrY!AEVmStgD+FbM zJTCNIu$6-T0z$?(a+>V+eb;dzc)DR;yJn^y^XEStYs%(pv`56TxZR*Z-i6@rK`hFA z{c}E#Yf6Vok9gfj^N-YT6PEhI#j++pO!~bwmpfv)7j?HB)Co`j``_c?A3)jpE4Ec>v0T6S$bA;(uS~f zIcOWoZhhN&#MA4HbiE%IcGkqTHRCnGYYoJie3aGc&#S^7Qkp{a5qm&o9T0^zzin`1 zP?dT9V0O$*S@qNPT=psp>TpN){~abQ-9}A;HzsayUG}rX4=)|=TPVs}`nsh0g;COj&7Z zESWsR$hj|=jk0>pXucz7NblvOy&GoS{FK6L<$73p4~{l5szcY|+C$eI+}q1}M=TW` z>8j12etkOBX6&GVx}7Ks`99V@A)gEJ5QwiA;;%y;RXhA`{>Y!9r<|^wBiZuUM+#+M zyXp7zpe3{ev9ACLrupk`UTjv^Ugc|h47^J)je644l{~xXZ%1x-?vZvvx2WUs#WY1{ z<9zQ7(+ukIQx=Hl9g91ZHR6!Tj-4n_C$jJnoAa4SYiess{RXN6<*2@K|H|t9IbPYN^XcoSYFuisr&Di6#v#F&V$2Y0{q^e?jgF2| zQ&SI%P>1NiTQ(q+B^K>1i`L_LdMPVI5msiVk8<*M2{~2B8Pc9F*5mHpuF7J+F@8gN zTX@Di&YJ$(n?=3>&KrXC@mqL6GKVON$pGj2aNSw*^`E8vzD(NaPNTuDG-~e7qQ||t z^sqml>iREIDQDDUCeu1oiQOzwC*n@#^1pF8@VHo$X6+kxBS0+cCYS4bbs$5WV>Za7 zgSK)zeEr$9KX{G`cugU$1(})4!5&&?I?qu4lZ*ga6hF#RL>9OXTv(UQ3hDAz7G3$ut=>sf#ABE7nJSWqqmD;I>ay!i~)&*D@+3T36b1$edp zrD+CBDKc6$IEB_MW39U=50dJ|jCidTkhXT!f*!~a@>r|&9a z(e>dt|7l+BDo|skte$j51z5Lez8;@PHb>NYz}flxXL&jK-o90z(GTpg{<0#U z^Pb9R`o;~KYv8h{?-6q0th&X1Qgst`bniulA~M8m_L;JpY$@w_q%~7lrf8*)snASU z`ZMajfAc&cmkRl8s&5CRwddOl&~?YzOd4W(ME@F^FRL-cUjF9mk@c>}bN2pSn!9sH z>0-R1LbU<3g*YGTcA~7Y_hZU_+q>@4{Jnd$aQ{9nJb0jv+D?!=CzK_WN^B(y}N!+ehW)K6>=uKk8rWS8p{jdq8}K2SY|VR?>rXKs<;EA&zu&;wC*= zc%rBPvyDzWOM*V|d*}Q^@-04QTB-Jviz%My zSCtRagU5s*8{E2AIASZzaJsrCdZK0 zyUDlml4*;HYy2`37Uu#@*Q#?Xn?r>BC~M|7+HrN{s#+)4cP@uUOG;?=ZUenseCglM z{vYpW|K|_+m>7J!yR`6WLFuIaxu3A!K7C3{J)P=WP@lDpgX@FzV(|qHKfF!DMMbp5 z<$PrFK>icxBRFFij4QqQ+|K&p`XZb`+}r24;p|;{^Y9^!U%g8GIXSlRtiJ4Q>Z~nO zV+6ff+ln#6%x3GwGc?9z3G!(jY*b^5ew(icWnq?gXtkd+d(W%52I3vo zd>^xC4)qytpFs@lMknW29ipssML>4BXYYQ#BaqhaCw}|OMxURs7XS0dv&PEEH(J6m zE{=1(AEr;A=2Yx!UCyxe;3uyM%Ekz6S3&Vly9u zhbOJSduhD?{tX(N^Ha`DT^$YPq>LHcq2)eW?dYKOt}c2vI7sh@hn4>3>g#Fv(j_(X=tNnVg+y-F z@K~#&tk10D?<*_xWqzJOr+9M@VvQ2Zl313PeUVU>mq$01`*vC>t0k>~a&vuDQ{$uF zzCD7cg157JOw0&j$I0=@cWM^qRY8G@Eg;75_Q?}^&vAn__KaOkHI&<#qr59(QPy%6 zzbyJ`gJVIy*AJMocqWc13p*~rdxEmCbAcV9S<4Fbee=hrn#ro`!i=OjmgV1B6!|;W z`$1-)$={m;+%|4I&kU;BP0SJwo%hcczTr6BhcWuFHL2*r8k0epcvBB%NWXTM6Jk>! zh2`OX6$?b%53^~iZYBU9&!mmlH;hG=tOK+W7CS4`JqrmO*uaSh+8 zp{|uK+kM+H8w$#5E<2}Y{}7uRo9|UKyjIFW9v|XOPbMGI%*~rBeg)mV8ycbyV`H@P z@+D1NyQatvW!mLs;lBXT^Rt#3dehXT^#1kD8x_m?Z{a`nc539&31taodD&M9WqEmY zQ@L+e%KFUz>;H^VCXDO!(CDB_V}^GcU7{jmoeGW3xX2~rJ)KYcK^HTAP$9pCrYQG| zlCr!XWR{}bPgH*X1J&ej(39L(G?{smwmFXSi?f>laF+64&e+~%Z@k0#Gv71QX*18D z_vblw!nrtR*)J=luh&Xwj6I^JtX-xo5EdkmA$~v>KGSiBXWNin4uZnYmk{Jv6%G`t zJ+t&V96>smF+9uER>nI+Xer^(s0W_fd_6v6zk1AumiFdR8Pi!A$HB@v_D=UrW3k|w@KaKJqr4e>>faE^$_X}}E zUA8_VriX0*{HGaIiMXL7Wqn}ES|!l&%;GCQWpNA%IaQ97RbN9`S7{f^Izm}Y;o}Q^ zDo*+4%i(N!#OYqXeaR~iDplq?Rsc*^R?>2FGi@jMWeH_@*;ff=d3khGxo=j=+GfJ?MIqD1Z6>OByl4Br z;Qm#5AFCuiqOEB2v~ee@zU~y9SF?}12eGUROfn!JWP6*bN`J=A5aqA@S@6itNA8%W zGOrx5pL!AR#M#KZeZJ-#tIxc%=0DyA;%7F~7v9ZxmEZCBkoR9d*hA|${Vp9m8qX>o z$WkP-@^vLD${K3$QRvY5{C;5={4tuAr`pF zvxis}fVGDpSi4vjW?7+^np@d~todb*e}T|e+uPNwEJ#!K%M!x!x}Ort^7;s;YX6)l zYn@4JErhfh4;;zrobipSjVY=(`slvV85g-@yrSy#Wva_rruzlU^ssz}8q24t<=XNg zX(-{@D*pX^7Qe61hWbEUJ%wHLk!KP&;3126-|uS%eRx<$KF-Hc!f^?Zr>3m!Isc4d z2766p(pxjwL0QPB!J0bcY9VX=$<%Fn%exzB%DBYwB*cdvFFfXaqLzT*Z8wNB6?JB+ z{n}f2cT&iIEc#ya_4thJdd%oO8mXX`zH`*ll^(p8cado6IZG|WrPMNclkV|OoLF0j zcNCh-L1sGof;PSA%cB;QHF<-YdTXfud8x|RLX6FP4?ZK0OScQ}3AC0A9bor{{Ksij zecd=B`kjfz1I0(m#*V0n_to6Xr5ks0=;pmVsu{&wfBdv{u`j3-P!V=im{^W1mNjtC zPg&L+GK?K!gD9`PFfz(|@#Y0p_g?nrdvVU!XlW@eK7LH^U%gVXEM&oB_R=YqWu~k# zP!`+WAKYkkz$zo*u`+2tC|e4J6lGu{1yCabDwAeZ;7%Dh8o)3^EEi2M$@ z1IbMLOI>FR)b5j@hC^%5T3ZiM)|=)Bv^i9uViTYPcxJP#eqU9yQ4O3i|89`;qL3Rk zcbnJ$4Frg!l4ttxh8=4T)86|X? zQi|Oike9VJPOF^#-n-g+WCscCyZ|D^P7Z3%tyymGS{Lo{dclp}9%`9*tYSvioewbU zrYY;ldks6>)dCj>dSF8L=qTja)8J z7KmwrXArU8Q7B7DDOLSUB>fi3ibP9t&FOoJL_=9kMooZ9KqrO98+vJ+xAmTGQ8z&0_-9IFKxabne52J zv92);MOh#PP*&%gFqHLiO6ddig0*qxda+IsIbl`ZOj-RbYF#4l1hLjZyDLMqI60`+ z4+i$mI_N8?1T$gi6LzjJmx($+S!JD<)DE52F~s*!l!YoxW$N_&s;VOejk%$sJ2dHa$j4Sv5v$fJV}d zzx2r9HDl~0PfGIAwhmDi@@veVS9@Tr3468qil%#-)vA1CAtQfAtuf@-2=*izdNfHH z=^Vp2h-;-+cOIr63uR%)$m(Z)${OAr4tRTACn&3zDa$u&p)B4>0iG6uStfXAeUCn3 zEhA>;YAyR^g`zBYdQjH0FqHM@`)rYj>7^hYIXp57NT!rc2N0C+~zY zygo#D&!UwKk)@74Eqz%!)SuU%UMtFKXpD`rFs7()sk4n%cnpvYzA`zicA~J;`?J^2 z)QqRraqmU@og8}ea@6}7LBiHGp;)Vl`-|>HMp@Xo7VVtg@b7Q+X=aA769d*5V!a^t z+rrKf*aIHr3T1ioIht(G_0!lMp{&z5YSQNLPqauVtHx*x5J;}^iCT?wCq`GGx|c>i z;r)f!$pJHl*vSFA1=#K6fH)SYXK}1wc|GLJU?o3x-NXzOC=1@Zx2~Sjaz6+9`SPV{ z7SKdl6HBk?bvVl6{Hq>Y%JRRX5Z)Fm^btSObfu3VAjF2Od%uUGEZmFu6ew$i^RJv{ z4Ci@=)~OFuWVcrq-kglJhikn1soK*XYx=OiJ@y5gxWY4@cW7wtxzeSTW)a`QY?$tY zK4K>Z#IlA8W1}q0+CH9tOyjp}X{x%Kum{%bCy&*PC-UB`@i^@GirqAjUj`kYd+L9` zqV+$4+6F&mS!X}d?uy|Gb??0rKV@Ne2H3~=Lci@ALr@mhDUPlVDYE)JJ4;^{7F0Y7 z?M2o*_L@f(WQ;t&Y|C%hNY4Glql8)56I$c)wo_k4sNszRGLD3?TNN>KlEo zVt0152-*ahLC28m)ral1vX<97ayCB7#oL$6ZT|i5ci_Gb-gyFXPwdXR!TY&`vhYSE zr~R^?O+8ZU3b89G_HD-w4tU!R-b<+aE|lfXXQ=o_cg9sHODxG9OG-99hN7%CBbPJ8 z_Z4*@hLsG2g*cYMZL(4p=m9Y+v)9CDMx+ixkkb)8)(ehUg!bs zdeFgZ_jK0$p(E24vgrAJh}EOjDqi&S&*5^A7iBINvpSrs@k>36smP|;po|vIXy;ug@Ev<|+0B|_{PBBPy`jhmgoL;2 zV3$+mWz|pBAE7M0Usl~fmH%x#oQI|IvN(tA?Xzbp8(wA%z4`1+wrBZiZ;w#cX&*sp zzx|Ug6lLkPgz3E7LE8z>5+VlDVVnuXnXHtBENRRlnLRx|V-^u_yg_C>2xeeyK)vk+ zGfS8~!&{_x{_;Ee_*yO7+dlOsYQ*sHuEg#d&Q;?2|DMlNGmL6KZ(cWsHFcO}vwC`z zgIpcFI}vdc?Bsyhm6@{87F=hY^}=i%u8$`BUAG%Y+z-OQjtn3M#F#*W$XNmrn6JfW z#BUJC%3?D6%x|Q|e)ej{5wnF5egn4pj&+MnS@RSAH`5@0$z0aA-`@h?p%26rL0PXk zH!0Rw7U&oI$#-4IRoUa1=^eX#S)KBRqRW(5APJfE)JXpud?5H zmkCQ{1E|YnS(mM^-ap8*d&xqMBD#YBz>{&ckx5192_vOOE~D zagFx;pezXDBj$Utlf&g#mw6`#Ugu|;y>p6XAvQI;HG8NGT^T>Uzh-)IIi$uv@8huXs&amEC3P%}toqoaS6EwUYzhP{cco>K#Nt zdr?tVTbqAZQrR!d({D)@-|F|RLRn%7sYO}o2Q!65#=?nax`Xg88mos1%h;ofX=`*R zr;A3fH*m&ww~AL;<11!Q8trG~6%}6n7^v6Br1yt+fj}G!q=mej*Bvb?iybp=)*S(y z>eG~kohY$46=tu@u`tBe@Gii6{rUb`x`UZJC(1(143wc`V@~zjod+o)4%5t;`1_pQ z?i(xjXS;)}uwT~VV-|kv^c$v3$P&km)>+s5E=R-yt;C`0!2VH)L%kgz47@v1=O#sy zmxUS7&XrDDc=3c5|NBqvJ*8&jIJUIW-%n#N8r55WphM)}>H75%W2p6Q-51Mz4Q3t@ zXY8GArA3ZIsk#n!WWZRjGVT1=?Vo8zoAkXng0e6xeXYAx#oMrc6)P*S_d94DcCv8) zzS_+JgoU`F`F?y3>`GdfrDh$Mo<9%lwS^f|*oo{)>doh`_|@RJ3T25k1g*hKy0D97 z;jK9;Uc{t@JSeMYjQ4lMt*-ID@n?;%svVieZz?wCsLpuCJ4jUW%wd+w)4|?X<5#ZG z;tPK^v>TL#*(7*U{sONX~WvX>~N1zmN%cjUh*Z!?Tb*>aWyFS zC+Abbj zAz*D>*VCJno9W-Rw8+?^Uz~xh>f_m@`MY;j%;W9zXVg3LOs#9fn{H4x>c_4En2CbG zqhr>veYrj0^|Ly&=v;f6+5rJGQD`413HejkMy^paVp&+VSK;-{;Ko>3WWYaFUZAu{NxJF@_Inb0wWI#f_T;fXTIa@&B!aecK=LZtWXE+ z1MS4FwAlTj{Mk80Sm+~YOT9IkNehHFb^W@s8`Os#FA%HLV~8W(sDw6NzmcJGyC$w) zCF~%9^_2NN{v5m7v0A!0e3LFezD#%Sx5x}yQ*u5}#m~h)E<#yiJ+WI)GV9Kcvh&j@jv_!4KZ^~{^#F@b>0Dc{K zPpu2z<~cIaO>6xs+JmH}`oaA{eB@Sd9{uB;7M}F_<2t$yeMGzrS@;Eg1xhc_&)`To z?IUIdvce1@Vps#Y{%m(-y(33U#kKhN@e04-T0Zx6;GEJYD9aqz!DkQ*`uhCZc{MJ` zSa+H&1d(*6vwmLs`vskWexL`?Bi=xC>m}z&F{weYuEzxfpZs{_+5oL8HxfyMcAoUkEg1S=?Y_wecPX%4~{3=iE`0S zZC?*Aa~zA?q5cElBkmdGcA~woPmpHiP5&PGdR)v$tbc6F_TPv8p*^_&;blLWscZ4Z z?OO)jy!%WjODM~8HXxLh%%mV~i=6pEorn)m|4|LVbyC;|kOMm{LuEq6!*+PcAx}M`+iptVxj(-D@pQx-DuaXy_Xq%)hx z1p0HBDYTCDql*XQ!()Xp!}z_-4~_@MSI@{PsyZMVU5+}(b)in&ceR0Ig)Lc>$pniw zWpJBvnlq@NDV^?>9DZkk*@i%GO4ttc`0F~_ve;($ZQ{y~4%&IYob4xvX)i>3%zAiv zhVL&sY>N*Ig6%Q)Ls2A`+n#CA9k$)NQXW_6>9$aoJo96J<|nJ}gtC%#WTaik`)jjO zR-?gh|2?1z?Ojs+sP%E*=#82@zdsJr6i7+MB-raOG6`vNI&DYY2kYMTn32S9#SKAf zIBDA{zwOs~NfNawR-LpN2FHO7!r!bs_m2166a6Kh{)Do`DwEGD-53)qWnDGq6=j`E zU`|%7UMEraK^Gvc3gdOulr_Ndk6k7YO;VarPAg^USx(Hpnd4aajM+2vudz@lD;~Bf zl;zE5up7^DFO(sa<%Lm{J{)~YSt+a3SW!e#ZA?Z@6!GvZ$x!BXgJU#IS;fX$Ogb4p zXVCsZ?CP{r*13b+FzlISrYyX*2Yt(B>CYC*iih0_WgUHf$ur$MIuXheYxa&co5#{h zS%t=?B8r9>X2;^;S(2g5yGD=F$tB}`Oq7K_%yTZzX_+xxVj{fN!D~1>4O-#PYb+Bi zmktOE>+;Nde1WzqM=KR&HSv9zB~6BLLmOnq(3{UK^D|67dt;1+vc!_TVae8Uv{DvG z0&$xMMtjnD%9HlkjYgZ&NdfP0ah$fiyOaERS?io#uj6fYN355$+fQC!`;YGjO>WdB z2WbgqdGonteU8cZ(?8BaSz^Vf--_+VP$(-r4Y);FAT&)Q$(on-AIGB5M(hWLy_~S~ z0R%fM;LY0RSwj68yEovTD;(?6`DZvzB|(0cP?k5JTXxSfId{6pS}02__;g#a)A$Kx z31ykdNK=;6y8$&>>Ep<|5k**OOj+h~^yfj|x!^eQzvJF3l;zFmmecc0&Y#}#7RnOq zJ-yZ&YV3rv!c#yzVp(rE&qX|0Loz5{A$`Z>xot7JJ+=Q~i6F@vDtA}&6l8LT#TQp_ay?t57ukcnKys1dfe)e#d zyuRP=_~S`<9XnCdanF|>Akpg?qlq@26+ng|ft2Pmi@mGgd-b;Rzrfl!g7r zks)qJB`10A700ABJz@VVyc5wZ1^oqq-*Fw*9!ApM_q-qeafvCG`wQ>+`*WG!OcTrf z$#jqGk>$<-Q4Jh4;Skpsd^ch9A6xZ>KpjN{NJI+`6XvV8;ec zTXx4s9KZU*8Sc^4Im>T|Ni?``Ry@SM|{($1Q(^cW8B+K0=kJ3Gr4%V(SgnrdWRy$`Z=* zvab@#^781Wa^Hlq!h2sgDGM`K@cb7WgY^z%M;8-1F6o@){r`BUh`x+q-(w|?Nbc+8 zdNgI3-^d)vy~%iuP?k`ZmwlB`mX}92mHTF;EWP)8z0sG@m_sspHQlC$z~1lgj;)iX z0>9@^Yc&WIpzFYF+DXz+BI{06Hhe47tEQ~xoS?3?&QIG>C`%~I%f3n|%gdvi%6+p^ zR`5NBQvm`vZQjW3_Trt6*bl!X#`hSS+tZ#I^!yJm9rpa|Jd2n}+BwO(5|Ism^NL^B zXPzI{vw1vD(`TV9p)4=^DxoYdk8UdW%}QC`Sc^5Dg)-*L zPmX*NO?Q|j#cXM$x(jz*C&vS^x&ZpEu6D27aP`LfccCnyEHC>ip)4gtENstAw(=Ji4jeH#^E&Fm5QaNHg~7g;D65_jB9p#Jjv^;}Ss! zm@%AZowymxLffC^1!FEn{3aN?{``EJI;1t{!rt&t&IfgR+Q=;p|4z00{TLQ7 zw-jOc*Vf&L+4CiXcZ#JA4~?2Y|2vJ_aic7>t>8hBv~FHKN*O8}{sc4B6G>Y8{PyWs z#tZl8H$x*It1T<`a$>DN-uDS*31xZNR|#c#d2~~`Z%&l8%3dqW_!S_Hmqvcf-Y?$X z$L;c4jq`ziXBj&LUHDky5zEqTKl?05T2(bi31PM*b>dlnIG+k{($>0(k>$<-UcYtajrp&m8*K8zKw5%gFGg_cD81^fAZyq9_Y} zZ!>ZeLRnqB#xB1pNLq-?V6B|FZP+h8k#VJytZS3s_h{~WsLy7?y6R#UeW*H5;y0lz zp)4=^DxoYdk8UdWEfi(JlRP#m196>8##{D$X%TzBlkh~xt22#sE*54=A06zEm55jt z+6~ew<9*5zkAYx~+<*CpY2!x^m2l|*O%F+~o+$_D{6KD(cfHxoG z_dX^ItXxAmEPA3l-q-AD3mZjo=}1Nqij*4~qE^&Rtw+Eai6RQeICU<@e?*eM9fKM#r_%+S1fD(0SDe>wEM%)sENt^*zD*p1ec#Q}qFT zH_kRv(_9lho_|bF7M>`5zj*V4S{7TV^=&J)FSS$Wawm1KbW`tYFZHkW)8P6bz1nz1 zBby^M_HK+O-cQifhbfxbnxVOmbF}bjf!=(6LvO#lrRA^7wEAt8*1xYSeZTwpj<&Y9 z=;N=C0eye}^PPU~{#5$@{qHyJ?(NdQ{eQIY+b7>X%O1-=*7e_ietrL8eg9^C|N70c zL%yHC4)o1BSNi@zyPrN%!)P5rA6hs4GcOP8+^p~A4eA+tLTT-3l+m6+Ssht)rt3_A zpf$mqhNDoHP?nc{l~9(KM>m!G7Aa*F_7_q?3$Hs&3(`nd{%5M_u0OSu_386JDlHB6 zCyfc~-TDDx?Vc=UUFyF?Mg2vT-;*Dp2~8ECv&*k8ryOP79lK4LOiEf0pwJ&1E984` zAzPs=O66yo^!;?ws8E(z?&-ALaN`yYWfcz;Q^{ZnrDy*R#I+Fnfs{PzqT11$99yYN z+E~_ij%EG${fl0W>>g=j zT`0>-qbYp}K9QU#YrGruAQODYiDWdS`JND=F-c;`;^Z)TAH%V z$FrH+RF=0)RTt-|=F+s1T0YiZnxPw73TNr2k~yj?WGRBo)2#!!U9><-ieJ;61F0{5 zL-i$)MQTv;mhP4uNa+&YJCOUO%XGgCvclJ`(e;8Y%CjLuv>APvuIwXUQxo|fLLQQ@ z5z^?F2YkH$AmKBHI+mp=3$ZKA79zfdSuZnnSU+QC>|D<|y3luliUx`l8J7-~Dq1Ta zDpzqZlvgoap~}2Ee3h<^TvJjx>Yo9w9<8S8v1&CFTr*aq;%1l;*0aKO2Q-b@;oFn9 z)eJEx53|G&&>m)s@6X&<)Q4H)#<@l{bBvkHfXqImCYHvT2UIt9gRTr+J~SI>?psdh zS$a0bV}lu5h)|Z$mC%)EY(OYW$RtQ6UebjVW%)SMIucnlI3LTHr)=YoEpeIYD_K6H ztv3wLG2*sI(#OCx3CzoKqO5Df*Hm5>D5!X_SmkOVo@J&HFMU4Ol}k0lRW!dc#(8ka zh5IIyb@Q2LJgb+UrP7z<`ADHGv7qBwP_pXJN?BV>SF0D&Y57uyl9j?tf~*#1(P~i^ ztrca{deNDHY!siR&Egy-!^O4qxTJ++R%3KE|24<6R;cvcnqTtP`MX&*s63D5yp~P6 zeBLbYsNy_JzLxiNC11-2F8@F-@bSVHT{|Qnsq%tfsxNMFd~A~*U0$aCsxexvdPaLy zm9$?~Mf=rNS=Q`#tUACdgo;-cDt3kY3;GJw z>IG!dBd=o_z=r15#13}JR;Xt8^dm;9PnPELUj940GBy}tdvzLg6mA^8@#JEl! z7el-av9YTo{`eSHM<_AJ$PgzxB*^BkrrYCn^kC{9wJkK$)Y=GbeOx<|{VtSc5dLP9 z^?4%g@}{3i#JCD&B|^(mN8e=ws2GWPjUha-_Pl!5$t!CNeLl{~QfuBE z^0KhzZQH!&E&gi^qou4=Sz{=aC6pz8BJqAA5jq#jN`#iBj>P}<2xT!@31yv{og9R+ zgtEj>B;HRXLgzwRiO{mtk@&wJp)8@S)3jffP?k`Z_=&{(iA3mJC@T?KmO2vu*CUiA zlqHmPI^N79yOJjU`6xaqaXJ^u5^GDGwIx#5LRmstLRmstiG1crUEcI1#~oLptmA4> z?oZCYETJr+ETJr+EO|aB=kqz0#ziPAl}0<2`YMzqlqHlUl$A=)Jn3t45~Wae_ql}K$%U5WdP70MFI63Pp1GoHdtD1< ziDlSZ#%b4!P?k`ZP?k{EX@8DM`%k}r63P-QKK)i~H-EmST54nX-I*RZ+!eVbBVss6w(hZvqkh%Nil8Dme8*` zEv2+G$1-0=zvs)9{NdxDh0C<7q=NpwzDm1`wY0l(kAA=Jq~G6P)6U-=`t$D({oVUZ zd;7^tS&jcSQvc0=gJ@YbM+7HUfosl_u5~A{JZu~$=H$e%eSIWG<120GTJjJt0Rlfbe$o* zN$RwT?3Z=CXPx+pO~bT z7cSFoCewn!6e8k^4(5vHgtD5v`3#Ho*(diUmpuq&iA5%tMY=T}p(yLGiLk~;&eFt4 z4o$L5jpnkPqv^3cni)G!vt#)*H=a-P;}>Xt;vy|f6wvF5OY~;)5-m;^(&AJRy`3ti zrKu8Ho-U;o$V?fn&Xm*I%w<}it)PwBE3`QWxk~TmuhIMYO8PKgMOzD1^l_n@KEA$A zpI+C{=hwCL<;@NH3R%2K-xllW``cUeRawNdRz9ErmLoDrh0~Et7mDhdXDC<&(Zw# zd0MDhpw~68=}qk$TC81E^7h7CTDq}B%Qu&4<>rc#)w)$$t6QV>TkEuOi{oOqIWETX z?l#B8?!2cDcQ_VS&#^F;kM$e}YxqQ;8#w-T_X~Z!%dsz(Z}&Lvb?-aSa{JeTY;#PEe`9?LWqI@Y z<<@gh$~k%a5XurOJb5dO#~3(KR(=23Kn&~pKt2`r7gF&+F_jFKQ29_fU4C_$t_)wH z%8^Q{8m*%1W7p}%_zk)_ag%OM-l98Gcc@{yf$q)RqX)AO=;7Q$dNlut{#*Eup1yuc z&)+e@ssno+y34c=Li<7F($GZ7a1ewNvMECv~rMlc)2tYIxj) zvM3(U4k^={KH;RsRVeGEG%xkW_b=Irvi1(*SnH#ibg}QErzlJ3W&QijM6^&wzKMfb zQ5=7+C6uzB);$dn*1zHAXuB`WGKQdBFH#bm3f7G!%swIJIqCfg58w(l)u zYo=PEEFmnJDLoZ7Ae1Gv6JOdnDg8Q8mX7f}7(C}$$_iw^f1e}YWM(kF^}ahHv8;xM zh5%uG&G|~@t>u*8n=h1w*x@g8>`*8Rnfj9b?#*ZLNj>|MtzMz5WE&A_k2_BsJIZQp z_7lfHj%Quyz2JSy@^OC9AC8U8UI>!byNy{edh&#_ejm)x z3S}Kx11Xf{&NEMZM{?>>C`&9eIW6J-X4LRWgn385@+K7)_@4EM_YLRnrJN$JPoCzTy# z{qFPUDNPS&r5$+#7|jw`g2o{varT*hi4D(~Z9BcK$s#;?x zly$JKQ({>mNlRi`-h2j&Z}pF>P?lJNH~+$|l+~Ygu-=X-qqzATmA0IxvX}XEx%DDl zX}d&M+X|_&y_l*yO6huM8P#@Orkh!*0IObKK_I{C!SH)Gj)jT3ni>x68ao`pOKgtj^Nf z+B|Kny{66eH}rnvEp2Ts)2DYU^!fc7ef_XO-#@&gA6xHf`{NeveELK?A3xI07JvTm zfp*@%r=7+A0BP;557N%s2JNh_)6U8o@58l9J4-9H^LCl9V|lYgJFnmRWqy(Oqkls? zv%JUr>;mn~%+t;^%hVk0OwQ8pp@l#ktFGZ1U3iv71<%jWr59(Zs5zHPn)9fvC7;S) zUZ9HB0;TV3?L}18QKIx+(^*b8x+((tzSCPp4ZYXtUSBQU@4rEf19eK@j|T7f_5JD| zJsZAHFGd=bzF&?#qPDUBsAK#obxk~{?#UMceGkmE(Gc`K+essH-844etMonjdVr?i z4AJZxzrGjVj?tT?38n9)-z+n%bCbUR zUE?*0Oth~HX6gLX45jaj&$C0)jE)^8+fgXXo6lhJtwLF1*j6iSmQofm_0>ud`_b#<2pSwYD0S7SLcGdm??MXy?1{N-qDm}EAalwp$m8Vm8^Xc z$`Ue3<>&ip>bp?ZX&NnQYvfN>D`l$g(Y)_;D=+8;aW)q0pt=8Z90Homg#(GtgG7LoJ9GS4!@=O*~K0i!Od z3BpWS4~*MK+FEL?Q;G4ODtVS^uHrx+qet}-3;AfuS}^V(sW+DLBDts6$fRFPmD@Z% zKUlu8d|~;-vc>X_WrJmnWrbylWszmv;5YCj@vP<(;|kOAKg!^?RvJCFZ57Ij-?KxW z%c5I1Z9pi?O>Ih9$@AA~rK~S6{8V9$-HaRc?2Y$nlWA&=WrgJ^W%+1|&yO2FY&~AG zcpIIS%Q6e-dBW(As-9T-6Y8F4YF4+&u?>&;_Y7`l8cUVYWqX}aR`NV+ z!kpP%`O0vXzKMr*(uZ!l?1Im9@II6gCBeB)Wf zhqfLslt)eEZ{aAbld0snQ5NWfrYy{+9=DyxrL0w+&HP{#AG;lx@%_L#VxTO(fAdhtbQZ^nBF*x?>EOK(x{VjYdV>YCo7j&2-RdWK%1b4{77}cZF?NMqsZj zJ!9BwI)(J`_;qs| z%z8Qe*||^t>(W_ohxf0-_+01QI89ltyb1wh-eIF3OfXC!I+?XydvO%F~Yk; z3@`;cW&Wo#DZB_!*0j+`L&iNC=HsZEX>H({*gKrlc8%%9Ori$Q=4El7R553$mj@{8 zlW~d1<|1dZ=W|Tz>>=Hm^%&}Nw3Ic#>oG@-FKib+dc`ux($CUkyyNu_JOYMaCx6k{aGl> zo6jxrN8TG(p)9d#?^(4iW$}&$$U|DagBhin0A&q1#G14>3wK1!qldj@7v}(VF^RM> z@wD)FO<66*t3zJ0i9K@z{|?H!ZTzMC#sZVpPeoakeC{gCbDLcl(2iT2HiLt3D>$)+j+O&GxuajThe#)w6nyTaVhknW$<2c!Kri6Z$ zSNwU9slslThD@fc<^W~Q^7TVT6Vu8qmTJyt^HbIgf1hU3Fzd6Q*9g95idpAeF&)cV zH!g6ddLEBaF0btj#@)>Kh5H;0WexJ`hY8l*7z-$C$k^buq^pXudii@7OSf_PP`kT1 zOCOZ=nB@iQ#oC?+YD_`Ue^@vEjQ$obQ&3KTKKh%Af^WN{zOSP;ieU}Srvl->|Ol5A~q}pRje3)e&skyiGpv=-N zYe)X~r&{?sqTGc(%Dw8ttHv;r&9wTmI@_?#h`nc(+S=x2VOc(}Pt=`s+7-)!cAvB_ z7%?pMWd+OnFdq@>tZvn+xY+NVmSu*u&>|N5jPUm{t#MUYmR0u7(X!3zSF({Mv#jv? z>fLY0Q*K$~dd<SxE_i)B5SpLf-Q^V<0tG4y%eq8|BcS1b#v{ZVag+KXi! zQ-4e6+V91xq&9zNI-BaKdS%O+$nQ&A+@?a64v*s+MLeKUwOyJq-eg&8)Nf*z_1yf5 zqT#*e$Hiv9H;owHnpoYkto@paen>OW59z+Y!}+mVma$rWSg+4pR&_1%s%5>@k)QDW zf@NLI)-L#a49l8SpW|ia(=6+(?)y8Y{7;pRN&P&j`EkLr#x>(T#8Q6Tr@lS2tgSjW z>oCjOS2I!=-g7UOWoy5O_f;Q1SD%=zo^f4s46A<|w%^$;+UGTx+FGs`5RCVfOo4 zovS)x816;e(WQdh2-N-bWtAbte)Ax)^?uXFbM!nsJ6i$Sxj zsqFEVW!b!}vpTBx7>3#J)|Yj-adi11^=I8|S=P43*A@1?T$Xi9eVb-kpLFSm>&D<9 z{SC|Vc@3tvVp)*w`Cmad%etwR5N!6lt>iFOvsO{H0$IPu@>te`x?k^l>mEa!zq3(Q z*;Y%~sd-l$vbP)ivVvueX7znV_MMl*vNmaCY^z4UwrON=dp53F?}yZfbx{3m`!y=I zSN$}jI!}2goKDMnOZ{=x{Mqk=Zds?*4sYm~)^S3|F&$GnCUhLhk0Z5lS!_Gi7Z&=m zMi<$=XQ`I;E16rjzN~L_f2mp4tlFtA-^27AmPIY{T#M+-^4z*9$JWiVuF4vMWu309 zE>KsUCUwu?a#+?bjrMI(WwezX_Gtxy>Z+IB=4A!T+S*_-_Pu+xtXK0N@7R{NT| z_YLZESQeJ$xmICWo?AEN*t%KP3C)8FmUXpeR&%u)Eth3&Q&lv}+OBnzs^_rZZJn&9 zd09_uH36HKwXVxNoc)?l*6WC2)9m-U?^`@qSk`Uzy;#+6nkO`9mSv;Dmo;K*^RiBB zRJb|5=6QCuet($#zEk&TZp&`v^RhNpR-!F`_EIftyvtf)$93LdS?6`fMEUcF@n3WOJ-S5YP7SdlCITUviq^D+Y^tqX1^cQ%=f0xuYbO0jp;?x#mvvBman<+R@4c)_IwC`}RY^D1tYzMLuIkDHg=I}^ zzr9fT-_SjYo$u*BFYBDvd5^Iw=}Fy55G?C>m-er&AZxLWYsa9lD(S&|^ta$@OskcK zRY?!j+)ue(^V-_$WmR+8wxs9O$NHj1U-xy~X;A$AmhQ%|Tz{Fb)75@YHRr?fJC=oI zd9GDhmgm+@Iku{0-Plv?CiDIAc2&Bl)d|e9KGE@!j(4(IjUJ7tZ>v~s;JEsAPHMLK z8CAUHn3uIfBT6=6xLJ4T@6YD~*-DnSw))Gu*YKt+>xV|mvN>G8QU9A+)?*s!8f{s@ z1Dc`!yw=fryC=)qt5qn%+V3{|eM}ZQK0m)`TqBYDHJZ66+okWiqhe5nWgRQ+vh((W z*L1h66B@k>mUT^QP)(KAj=vvMSnAsE_SxrkH$u3_a9XP)98=u3UfQJYikQqcE7lp! ze*avLo$FtP9d>qWcbw{X+^l<7?cULinp?GS?%lI3%lf0%AFICCet$*AYWq{#s-(ZI z`TStB@9A!Yy7v#;b66IZ<+)a2S)N-r<=Cp0HLG2sZ68BB%2nvRW)BC;npIW1rsF*w zP3vWy%no$Ce^N$P>|7?-t~W!dcau#e%0MgVQM=ohs+&H=4e^@8SK)ve@U z-}#UXxeU66Ri)EQjos;1%wvvOb zSP^2{uT>PxvPQK#&(Vr~*=uZ_F|(|f^VjTYy{~xxLo(@Gb5HAUYjixN<4GOQ%kaYI zKc7vvEbCFdHau^SW>%KtyW-$jm$_Tz_YC^aSQeJ$xmICWo?AEN*s7Lw;hB2NvI;b% z3VKQKZpyJ$Ez2s--7|%0U4FJ*wT7h+AU) zqsw!%%2h12pI5@N-pn?&`m^e0!Kaqv<%;*LcKj&}%jOU++*5J6&J*Kvuq-UgbFIR% zJhyJjvDI0Y^$WmV{B8J3yW&3#zbN?F!9%?S(rE}v>{*RWRqLHOP_ z!~JW`r3?A=*guG6VOgGQ6_(|>byJS5$+E7loA1w(Y0W&HwrQ=DWtp9PtQqsc3@-Js zgUukAPOR@>JL!Bezc!ZT@{;Zzi1l4o^1N6UmgTutVOgGAH|5xxEX%6JPp1phx~qPz zlaHipS}SQ;HuL?i?xieD>#puU9R8hPR?;-;K0AyOS`KXm2d{S%U|Cp}=URnjd2Zd5 zW9w#FCmwE(9DX~c9d{l`)3jF7vVt|(x=x`_>zr0T4bSt~%-nIU!S%CNo(*}MZP=~H zOAfItEX#AP!m>QKZpyKBvn;Dl7k<|sIlM7udmA=aJulU)8b?1Y%Z%ZUW^4yzuu-oR zb6w4S-S0L+_?C83Y8%#Ft&Y3Xy_sTJSeEBng=Kke-IQZn%(AS?T^nhe)_3aDI{C}A zOsgN3WqCfSRW`#qT4r0HW#_aKZ0Aayb=Hy6b7!^g|2NsEEgQX~m1R>IWyP|vEYGzH z%ktd1DaW=H%d#r|e#x|K)lyp{K9%bJ>5jrOzZzfpm``Mn=erw+J}v9tx>DL@IhCAO z<2;=`Gj~htz=yop<8|$FWcv|RU+eXEEDOu>T&u7w&#jwsY)iE)t6X-kVZM&m{62?Y z>`!Aot+1-aXQL}`S(azp$>v&iu)`?6)9yu=wKI=bgJ_D&cGR~09?N$1gLVU-(N09E zji_Q-SeEBng=Kke-IQay7t69rczjj+-os#AKTcYgi%A z_IOgeFc<5@x9_J5=3$T4w{tVQI%oU$eW2Z{hvE1*o6bl4zRYGzD_Q*9(Pw3`nQ7TP zERU;|*wc=5HOC`#hUb zU)Ec5x3g<=Uu7Q*#bMvoX0zYOu4w)H>A82av3xXeDtidCQvYRL+hbW+mgic9WqEGh zlw-rPexBP#pGj?1udDPtS8qW5$P1Q*WqGbuSeED3O*u9!3(M+!?Lg}&$SB6^4z*9$A)ELSr}072GoxnVp&+0 z=URnjd2Zd5W5cqrtbP~*=jh$!uq-UgbFIR%JhyJjv0+(Q76#P20rew?SQeJ$xmICW zo?AEN*sv@ts~?8IIeIraEDOu>T&u7w&#jwsY*-eSg#q<$K>f%emW5?`u2oo;=hjU* zHY^Lv>W3k4j^0fU%fhle*D5T_bL*xY8r^ z`e6v1qj!_Tval@AwF=Af+`1{phGk({7*Ov9)Q=ouSy-0mT7_kKZrzk)TTaV*FuOB1 zjK{WY{ain)Btss#fu00S@p z192I^vLG-nf#je3!vGAx01S9#0L${)vc$zz3kF~S24DaN;xd3`L10`0$v^pr0T_S* z81TvfmgTi&iHoZi48Q;kzyJ)yWdO^9z_7=Qs7fB~-zU|C*UmbkcT!2k@v z01UuDTn4Z#2#iZ0`6vG{00S@p16~=xvb?q|adFjx0T_S*7=VGe3}9Ih7?(iuPyS&5 z24DaNyfT1gd2LzZ;;ID$FaQHE00VIuz_K7PE`j8q{KEhYzyJ(*WdO_a+OovORSO1S z00v+H2I4Y+WkFzE0?9x5hXELX0T}Sg0G8#oWr>Tc77V}u48Q;k#AN`>g21>0l7I3K z126ysFyNH|EX!-l5*Jr37=Qs7fB_hY%K(-IfpG~W|KuMAU;qYSz$*h-me-agF0NWI z00S@p127Pm0W1pw;}S^z$v+Ih01Uu@R|c>wuPsYlT(w{T24DaNU?467SQZ4vC6N4+ ze;9xP7=Qt<3}9JaTb8)EYQX>uzyJ)uKwJi}EC`HCAo(Z%FaQHE00Ukbz_PryEOBww zf&mzS0T_URxC~%f5Ez$0@=yL@00v+H2D~zWWqEB`;^L|W126ysFaQH_8Njk2FfM`Q zpZvoB48Q;kcx3>~^4hY*#Z?OiU;qYS00!bRfMr2oTms2I`G)}*fB_ir$^e$-wPlHm zs}>Bv01UtY48&ys%YwkT1d@O94+Ag&12Eu~0W8aF%Muq?Ef|0S7=Qs7h|2($1%Yu1 zB>&_e24DaNV8AN_SeDn8B`&U7FaQHE00S@(mjNsb0^<@${>eWKzyJ)ufL8{vEUzs~ zTwJwa00v+H24EmA16URW#wC#alYbb10T_S*uMA*WUR##9xN5-w48Q;kz(8CEuq+6S zOCb3t|1bapFaQHy8NjlocV1p_bu126ysaT&m}ATTb0nq4zyJ)u01U)s0Ly~FxCD}a@(%+r00S`Kl>scv zYs(TBS1lNT0T_S*7>LUNmIZ-v2_*mI9|m9m24KJ|16Y>VmL)E(S}*_uFaQHE5SIZg z3j*U3NdCz`48Q;kz<^f}FOI%#FU;qYS00v+nE(2H=1jZ$h{F8qefB_hQ0j~^T zSzcR~xVUP;01UtY48TBK2Cys$j7uQ-C;u=2126ysUKzl$ytXWHan*tW7=Qs7fPuIS zU|A3tmq7AQ{$T(HU;qZZGJs`yZCT>tss#fu00S@p192I^vLG-nf#je3!vGAx01S9# z0L${)vc$zz3kF~S24DaN;xd3`L10`0$v^pr0T_S*81TvfmgTi&iHoZi48Q;kzyJ)y zWdO^9z_7=Qs7fB~-zU|C*UmbkcT!2k@v01UuDTn4Z#2#iZ0`6vG{00S@p z16~=xvb?q|adFjx0T_S*7=VGe3}9Ih7?(iuPyS&524DaNyfW~>`;Y$RznRzQh>L%@ nFaQHE00S@p1F;zR`+xrZumAn?um8yO-|>l4NB=hd-k<*u8$2Bg literal 962463 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 H{>T3Vx%Put From 1e0bcd5db09a2ba3d00ddf5ec28eb3e16119adca Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 28 Aug 2026 08:46:14 -0700 Subject: [PATCH 05/16] Canvas: address Copilot review on readback alpha and text metrics - CaptureRGBA: unpremultiply GPU readback so toDataURL/getImageData/drawImage expose straight alpha (NanoVG RT is ONE/INV_SRC_ALPHA premultiplied). - MeasureText: use nvgTextBoundsInk so height/actualBoundingBox* are glyph ink extents; nvgTextBounds still uses the line box for layout callers. - FreeType fontstash path: FT_Set_Pixel_Sizes from em `size` directly so raster matches em-scaled advances (stb path already did). - Unit test: semi-transparent fill + getImageData channel asserts on drawImage(canvas) destination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../JavaScript/src/tests.javaScript.all.ts | 17 +++++++++-- Polyfills/Canvas/Source/Context.cpp | 21 ++++++++++++++ Polyfills/Canvas/Source/MeasureText.cpp | 28 +++++++++++-------- Polyfills/Canvas/Source/nanovg/fontstash.h | 5 +++- Polyfills/Canvas/Source/nanovg/nanovg.cpp | 19 +++++++++++-- Polyfills/Canvas/Source/nanovg/nanovg.h | 4 +++ 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index 9c53665876..7592b71fc4 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -501,15 +501,28 @@ describe("Canvas2D", function () { srcCanvas.width = 32; srcCanvas.height = 32; const srcCtx = srcCanvas.getContext("2d"); - srcCtx.fillStyle = "#ff0000"; + // Semi-transparent fill exercises GPU readback + unpremultiply (opaque red alone + // would still pass if the source were transparent/stale or double-premultiplied). + srcCtx.fillStyle = "rgba(255, 0, 0, 0.5)"; srcCtx.fillRect(0, 0, 32, 32); - const dstCtx = createContext(); + const dstCanvas = new _native.Canvas(); + dstCanvas.width = 32; + dstCanvas.height = 32; + const dstCtx = dstCanvas.getContext("2d"); expect(function () { dstCtx.drawImage(srcCanvas, 0, 0); }).to.not.throw(); const url = srcCanvas.toDataURL("image/png"); expect(url.indexOf("data:image/png;base64,")).to.equal(0); expect(url.length).to.be.greaterThan(32); + + // Sample destination after drawImage(canvas) — must see straight-alpha red, not + // transparent black and not double-premultiplied (dark) red. + const sample = dstCtx.getImageData(16, 16, 1, 1).data; + expect(sample[0]).to.be.at.least(200); // R + expect(sample[1]).to.be.at.most(30); // G + expect(sample[2]).to.be.at.most(30); // B + expect(sample[3]).to.be.within(100, 160); // A ~128 }); }); diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index d4b0cd8816..66ae074d01 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -1020,6 +1020,27 @@ namespace Babylon::Polyfills::Internal } } + // NanoVG stores the RT as premultiplied (ONE / INV_SRC_ALPHA). Canvas APIs + // (getImageData, toDataURL, drawImage source) expose straight alpha, so undo + // premultiply before handing the buffer out. Without this, translucent RGB is + // written into PNG as-is and drawImage(canvas) multiplies by alpha a second time. + for (size_t i = 0; i + 3 < rgba.size(); i += 4) + { + const uint8_t a = rgba[i + 3]; + if (a == 0) + { + rgba[i + 0] = 0; + rgba[i + 1] = 0; + rgba[i + 2] = 0; + } + else if (a < 255) + { + rgba[i + 0] = static_cast(std::min(255, (static_cast(rgba[i + 0]) * 255 + a / 2) / a)); + rgba[i + 1] = static_cast(std::min(255, (static_cast(rgba[i + 1]) * 255 + a / 2) / a)); + rgba[i + 2] = static_cast(std::min(255, (static_cast(rgba[i + 2]) * 255 + a / 2) / a)); + } + } + // Keep the CPU mirror in sync so getImageData after toDataURL/drawImage sees GPU content. EnsureCpuBuffer(); if (!m_cpuPixels.empty() && m_cpuPixels.size() == rgba.size()) diff --git a/Polyfills/Canvas/Source/MeasureText.cpp b/Polyfills/Canvas/Source/MeasureText.cpp index ec08594cb4..bc31bfa887 100644 --- a/Polyfills/Canvas/Source/MeasureText.cpp +++ b/Polyfills/Canvas/Source/MeasureText.cpp @@ -19,22 +19,28 @@ namespace Babylon::Polyfills::Internal { Napi::Value MeasureText::CreateInstance(Napi::Env env, Context* context, const std::string& text) { - // Zero-init: nvgTextBounds may leave slots untouched when the atlas is empty. + // Ink extents (not the full line box): nvgTextBounds replaces ymin/ymax with + // fonsLineBounds, which would make height/actualBoundingBox* the em line box. float bounds[4] = {0, 0, 0, 0}; - // width = advance (not ink box). - const float advance = nvgTextBounds(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); + const float advance = nvgTextBoundsInk(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); float textMetrics[3] = {0, 0, 0}; nvgTextMetrics(context->GetNVGContext(), &textMetrics[0], &textMetrics[1], &textMetrics[2]); - auto obj{Napi::Object::New(env)}; - obj.Set("width", Napi::Value::From(env, advance)); - obj.Set("height", Napi::Value::From(env, bounds[3] - bounds[1])); - // actualBoundingBoxLeft is positive left of the alignment point. + // CSS TextMetrics: positive ascent is above the alignment baseline (y=0 here). + const float inkAscent = bounds[1] < 0.f ? -bounds[1] : 0.f; + const float inkDescent = bounds[3] > 0.f ? bounds[3] : 0.f; + + auto obj{Napi::Object::New(env)}; + obj.Set("width", Napi::Value::From(env, advance)); + obj.Set("height", Napi::Value::From(env, bounds[3] - bounds[1])); + // actualBoundingBoxLeft is positive left of the alignment point. obj.Set("actualBoundingBoxLeft", Napi::Value::From(env, -bounds[0])); - obj.Set("actualBoundingBoxRight", Napi::Value::From(env, bounds[2])); - obj.Set("fontBoundingBoxAscent", Napi::Value::From(env, textMetrics[0])); - obj.Set("fontBoundingBoxDescent", Napi::Value::From(env, -textMetrics[1])); + obj.Set("actualBoundingBoxRight", Napi::Value::From(env, bounds[2])); + obj.Set("actualBoundingBoxAscent", Napi::Value::From(env, inkAscent)); + obj.Set("actualBoundingBoxDescent", Napi::Value::From(env, inkDescent)); + obj.Set("fontBoundingBoxAscent", Napi::Value::From(env, textMetrics[0])); + obj.Set("fontBoundingBoxDescent", Napi::Value::From(env, -textMetrics[1])); - return obj.As(); + return obj.As(); } } diff --git a/Polyfills/Canvas/Source/nanovg/fontstash.h b/Polyfills/Canvas/Source/nanovg/fontstash.h index 1afd1f01f4..171229c723 100644 --- a/Polyfills/Canvas/Source/nanovg/fontstash.h +++ b/Polyfills/Canvas/Source/nanovg/fontstash.h @@ -220,7 +220,10 @@ int fons__tt_buildGlyphBitmap(FONSttFontImpl *font, int glyph, float size, float FT_Fixed advFixed; FONS_NOTUSED(scale); - ftError = FT_Set_Pixel_Sizes(font->font, 0, (FT_UInt)(size * (float)font->font->units_per_EM / (float)(font->font->ascender - font->font->descender))); + // `size` is already CSS/canvas em pixels (see fons__tt_getPixelHeightScale). Do not + // re-scale by units_per_EM/(ascender-descender) — that was the old line-height path and + // would desync FreeType raster size from em-scaled advances/metrics. + ftError = FT_Set_Pixel_Sizes(font->font, 0, (FT_UInt)(size > 0.0f ? size + 0.5f : 0.0f)); if (ftError) return 0; ftError = FT_Load_Glyph(font->font, glyph, FT_LOAD_RENDER | FT_LOAD_FORCE_AUTOHINT); if (ftError) return 0; diff --git a/Polyfills/Canvas/Source/nanovg/nanovg.cpp b/Polyfills/Canvas/Source/nanovg/nanovg.cpp index 1bf1f6a4b8..0ad1fb6621 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg.cpp @@ -2849,7 +2849,7 @@ int nvgTextBreakLines(NVGcontext* ctx, const char* string, const char* end, floa return nrows; } -float nvgTextBounds(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds) +static float nvg__textBoundsImpl(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds, int useLineBounds) { NVGstate* state = nvg__getState(ctx); float scale = nvg__getFontScale(state) * ctx->devicePxRatio; @@ -2866,8 +2866,11 @@ float nvgTextBounds(NVGcontext* ctx, float x, float y, const char* string, const width = fonsTextBounds(ctx->fs, x*scale, y*scale, string, end, bounds); if (bounds != NULL) { - // Use line bounds for height. - fonsLineBounds(ctx->fs, y*scale, &bounds[1], &bounds[3]); + // Line box is what layout (nvgTextBounds callers) wants for height; ink extents + // are what canvas measureText actualBoundingBox* needs. + if (useLineBounds) { + fonsLineBounds(ctx->fs, y*scale, &bounds[1], &bounds[3]); + } bounds[0] *= invscale; bounds[1] *= invscale; bounds[2] *= invscale; @@ -2876,6 +2879,16 @@ float nvgTextBounds(NVGcontext* ctx, float x, float y, const char* string, const return width * invscale; } +float nvgTextBounds(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds) +{ + return nvg__textBoundsImpl(ctx, x, y, string, end, bounds, 1); +} + +float nvgTextBoundsInk(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds) +{ + return nvg__textBoundsImpl(ctx, x, y, string, end, bounds, 0); +} + void nvgTextBoxBounds(NVGcontext* ctx, float x, float y, float breakRowWidth, const char* string, const char* end, float* bounds) { NVGstate* state = nvg__getState(ctx); diff --git a/Polyfills/Canvas/Source/nanovg/nanovg.h b/Polyfills/Canvas/Source/nanovg/nanovg.h index 14989ffba2..2a0938c558 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg.h +++ b/Polyfills/Canvas/Source/nanovg/nanovg.h @@ -595,6 +595,10 @@ void nvgTextBox(NVGcontext* ctx, float x, float y, float breakRowWidth, const ch // Measured values are returned in local coordinate space. float nvgTextBounds(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds); +// Like nvgTextBounds, but keeps per-glyph ink extents on ymin/ymax instead of replacing +// them with the full line box (fonsLineBounds). Used by canvas measureText actualBoundingBox*. +float nvgTextBoundsInk(NVGcontext* ctx, float x, float y, const char* string, const char* end, float* bounds); + // Measures the specified multi-text string. Parameter bounds should be a pointer to float[4], // if the bounding box of the text should be returned. The bounds value are [xmin,ymin, xmax,ymax] // Measured values are returned in local coordinate space. From 6a17caed84c0ba9c890d4cfdd1a1486287aea31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 1 Sep 2026 16:10:14 -0700 Subject: [PATCH 06/16] Canvas: update CaptureRGBA blit to TextureRegion API after bgfx bump. Master #1857 changed encoder.blit to TextureRegion pairs; Context.cpp still used the old 14-arg form and broke CI after merging master. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601 --- Polyfills/Canvas/Source/Context.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 66ae074d01..03477e68c4 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -934,16 +934,12 @@ namespace Babylon::Polyfills::Internal } // maxViews-1 is reserved for readback blits (see DeviceImpl::FlushViewsIfNeeded). - const bgfx::ViewId blitView = static_cast(bgfx::getCaps()->limits.maxViews - 1); - encoder->blit( - blitView, - blitTexture, - /*dstMip*/ 0, /*dstX*/ 0, /*dstY*/ 0, /*dstZ*/ 0, - srcTexture, - /*srcMip*/ 0, /*srcX*/ 0, /*srcY*/ 0, /*srcZ*/ 0, - static_cast(width), - static_cast(height), - /*depth*/ 0); + const bgfx::ViewId blitView = static_cast(bgfx::getCaps()->limits.maxViews - 1); + bgfx::TextureRegion dstRegion{}; + dstRegion.init(blitTexture, 0, 0, static_cast(width), static_cast(height)); + bgfx::TextureRegion srcRegion{}; + srcRegion.init(srcTexture, 0, 0, static_cast(width), static_cast(height)); + encoder->blit(blitView, dstRegion, srcRegion); std::mutex mutex; std::condition_variable cv; From ac9e8a4d18487ba5d4f9b47745e0d1de59ea71a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 1 Sep 2026 18:08:08 -0700 Subject: [PATCH 07/16] Guard NanoVG bgfx destroys with isValid on context dispose. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UBSan on MacOS_Sanitizers aborted during Canvas Context GC because nvgRenderDelete used `if (gl->prog.idx)` — idx 0 is valid and BGFX_INVALID_HANDLE is 65535, so never-created programs still called destroyProgram and indexed ProgramRef[65535]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601 --- .../Canvas/Source/nanovg/nanovg_babylon.cpp | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index 43592cac58..7881d46ca0 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -1344,26 +1344,60 @@ namespace return; } - // gl->prog.idx can be 0 is a context is destroyed without a call to flush - if (gl->prog.idx) + // BGFX_INVALID_HANDLE is UINT16_MAX (not 0). idx==0 is a valid first program. + // Guard every destroy so a partially-initialized or never-flushed context is safe. + if (bgfx::isValid(gl->prog)) { bgfx::destroy(gl->prog); } - bgfx::destroy(gl->texMissing); - - bgfx::destroy(gl->u_scissorMat); - bgfx::destroy(gl->u_paintMat); - bgfx::destroy(gl->u_innerCol); - bgfx::destroy(gl->u_outerCol); - bgfx::destroy(gl->u_scissorExtScale); - bgfx::destroy(gl->u_extentRadius); - bgfx::destroy(gl->u_params); - bgfx::destroy(gl->u_sdf); - bgfx::destroy(gl->s_tex); - bgfx::destroy(gl->s_tex2); + if (bgfx::isValid(gl->texMissing)) + { + bgfx::destroy(gl->texMissing); + } + + if (bgfx::isValid(gl->u_scissorMat)) + { + bgfx::destroy(gl->u_scissorMat); + } + if (bgfx::isValid(gl->u_paintMat)) + { + bgfx::destroy(gl->u_paintMat); + } + if (bgfx::isValid(gl->u_innerCol)) + { + bgfx::destroy(gl->u_innerCol); + } + if (bgfx::isValid(gl->u_outerCol)) + { + bgfx::destroy(gl->u_outerCol); + } + if (bgfx::isValid(gl->u_scissorExtScale)) + { + bgfx::destroy(gl->u_scissorExtScale); + } + if (bgfx::isValid(gl->u_extentRadius)) + { + bgfx::destroy(gl->u_extentRadius); + } + if (bgfx::isValid(gl->u_params)) + { + bgfx::destroy(gl->u_params); + } + if (bgfx::isValid(gl->u_sdf)) + { + bgfx::destroy(gl->u_sdf); + } + if (bgfx::isValid(gl->s_tex)) + { + bgfx::destroy(gl->s_tex); + } + if (bgfx::isValid(gl->s_tex2)) + { + bgfx::destroy(gl->s_tex2); + } nanovg_filterstack::DisposeBgfx(); - if (bgfx::isValid(gl->u_halfTexel) ) + if (bgfx::isValid(gl->u_halfTexel)) { bgfx::destroy(gl->u_halfTexel); } From 62832167a441ea4ac117c95b0536c680389ab1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 1 Sep 2026 18:28:34 -0700 Subject: [PATCH 08/16] Initialize NanoVG bgfx handles to BGFX_INVALID_HANDLE after zero-init. memSet left prog.idx at 0, which bgfx::isValid treats as a real handle. Dispose then called destroyProgram(0) and asserted on Win32. Pair that with isValid for lazy program create on first flush (replacing the old !prog.idx gate). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601 --- .../Canvas/Source/nanovg/nanovg_babylon.cpp | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index 7881d46ca0..e17878d6bd 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -1016,7 +1016,7 @@ namespace // The canvas framebuffer is bound with a fresh view by Context::Flush before this // flush runs, so the first draw call can reuse that view without re-binding. gl->canvasViewNeedsRefresh = false; - if (!gl->prog.idx) + if (!bgfx::isValid(gl->prog)) { bgfx::RendererType::Enum type = bgfx::getRendererType(); gl->prog = bgfx::createProgram( @@ -1344,7 +1344,7 @@ namespace return; } - // BGFX_INVALID_HANDLE is UINT16_MAX (not 0). idx==0 is a valid first program. + // Handles are initialized to BGFX_INVALID_HANDLE; prog is lazy-created on flush. // Guard every destroy so a partially-initialized or never-flushed context is safe. if (bgfx::isValid(gl->prog)) { @@ -1437,6 +1437,23 @@ NVGcontext* nvgCreate(int32_t _edgeaa, bx::AllocatorI* _allocator) } bx::memSet(gl, 0, sizeof(struct GLNVGcontext) ); + // memSet leaves handle idx at 0; bgfx treats 0 as a valid first handle, so mark + // resources invalid until create* runs (prog is also lazy-created on first flush). + gl->prog = BGFX_INVALID_HANDLE; + gl->u_scissorMat = BGFX_INVALID_HANDLE; + gl->u_paintMat = BGFX_INVALID_HANDLE; + gl->u_innerCol = BGFX_INVALID_HANDLE; + gl->u_outerCol = BGFX_INVALID_HANDLE; + gl->u_scissorExtScale = BGFX_INVALID_HANDLE; + gl->u_extentRadius = BGFX_INVALID_HANDLE; + gl->u_params = BGFX_INVALID_HANDLE; + gl->u_halfTexel = BGFX_INVALID_HANDLE; + gl->u_sdf = BGFX_INVALID_HANDLE; + gl->s_tex = BGFX_INVALID_HANDLE; + gl->s_tex2 = BGFX_INVALID_HANDLE; + gl->th = BGFX_INVALID_HANDLE; + gl->th2 = BGFX_INVALID_HANDLE; + gl->texMissing = BGFX_INVALID_HANDLE; bx::memSet(¶ms, 0, sizeof(params) ); params.renderCreate = nvgRenderCreate; From 0be41b62ad848d0f7df5102247ffa8800aa9c5cf Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Mon, 14 Sep 2026 15:19:16 -0700 Subject: [PATCH 09/16] Fix Canvas readback lifetime and drawImage rectangle semantics Hold the frame completion scope before NanoVG flush through GPU readback, and retain temporary image uploads until queued draws have been submitted. Apply floating-point source and destination rectangles, normalize negative extents, and proportionally clip source bounds while preserving draw state. Fall back to PNG for unsupported toDataURL types, complete fallback text metrics, and remove atlas border and blur padding from reported ink bounds. Add destination-GPU regressions and native font-metric coverage, regenerate the committed JavaScript bundle, and document existing golden divergences. Normalize the added C++ and test indentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09 --- Apps/UnitTests/CMakeLists.txt | 3 + .../JavaScript/dist/tests.javaScript.all.js | 455 ++++++++++++++++- .../JavaScript/src/tests.javaScript.all.ts | 474 ++++++++++++++++-- .../Source/Tests.Canvas.TextMetrics.cpp | 65 +++ .../Babylon/Graphics/DeviceContext.h | 6 +- Core/Graphics/Source/DeviceContext.cpp | 8 +- Core/Graphics/Source/DeviceImpl.cpp | 62 +-- Core/Graphics/Source/DeviceImpl.h | 10 +- Polyfills/Canvas/Readme.md | 31 ++ Polyfills/Canvas/Source/Canvas.cpp | 14 +- Polyfills/Canvas/Source/Canvas.h | 2 +- Polyfills/Canvas/Source/Context.cpp | 356 ++++++++----- Polyfills/Canvas/Source/Context.h | 27 +- Polyfills/Canvas/Source/MeasureText.cpp | 36 +- Polyfills/Canvas/Source/nanovg/fontstash.h | 23 +- .../Source/nanovg/nanovg_filterstack.cpp | 4 +- 16 files changed, 1321 insertions(+), 255 deletions(-) create mode 100644 Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 1da47150db..5f56d74cd1 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -25,6 +25,7 @@ set_source_files_properties(${TEST_SCRIPTS} PROPERTIES HEADER_FILE_ONLY TRUE) set(SOURCES "Source/App.h" "Source/App.cpp" + "Source/Tests.Canvas.TextMetrics.cpp" "Source/Tests.Device.FrameEncoder.cpp" "Source/Tests.ExternalTexture.cpp" "Source/Tests.ExternalTexture.Lifecycle.cpp" @@ -73,6 +74,8 @@ endif() add_executable(UnitTests ${BABYLONJS_ASSETS} ${BABYLONJS_MATERIALS_ASSETS} ${TEST_ASSETS} ${TEST_SCRIPTS} ${SOURCES}) target_include_directories(UnitTests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../Plugins/NativeEngine/Source") +target_include_directories(UnitTests PRIVATE "${BGFX_DIR}/3rdparty") +target_compile_definitions(UnitTests PRIVATE CANVAS_TEST_FONT_PATH="${BGFX_DIR}/examples/runtime/font/droidsans.ttf") target_link_libraries(UnitTests PRIVATE AppRuntime PRIVATE Blob diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index bfabd4d653..cbc19219ab 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28384,6 +28384,50 @@ describe("Canvas2D", function () { return canvas.getContext("2d"); } + function createCanvas(width, height) { + var canvas = new _native.Canvas(); + canvas.width = width; + canvas.height = height; + return { canvas: canvas, context: canvas.getContext("2d") }; + } + + function disposeCanvas(resource) { + resource.context.dispose(); + resource.canvas.dispose(); + } + + function captureGpuPixels(canvas) { + var probe = createCanvas(canvas.width, canvas.height); + try { + // drawImage(canvas) captures the source framebuffer, while getImageData only + // reads the probe's CPU mirror. This makes assertions observe the source GPU. + probe.context.drawImage(canvas, 0, 0); + return probe.context.getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; + } finally { + disposeCanvas(probe); + } + } + + function pixelAt( + pixels, + width, + x, + y) + { + var offset = (y * width + x) * 4; + return [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3]]; + + } + it("round-trips a string fillStyle and strokeStyle", function () { var ctx = createContext(); ctx.fillStyle = "#ff0000"; @@ -28727,20 +28771,407 @@ describe("Canvas2D", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(data.height).to.equal(3); }); - it("accepts another Canvas as drawImage source and supports toDataURL png", function () { - var srcCanvas = new _native.Canvas(); - srcCanvas.width = 32; - srcCanvas.height = 32; - var srcCtx = srcCanvas.getContext("2d"); - srcCtx.fillStyle = "#ff0000"; - srcCtx.fillRect(0, 0, 32, 32); + it("renders a semi-transparent Canvas source through the destination GPU", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(8, 8); + try { + source.context.fillStyle = "rgba(240, 120, 60, 0.5)"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 0, 0); + + var sample = pixelAt( + captureGpuPixels(destination.canvas), + destination.canvas.width, + 4, + 4 + ); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[0]).to.be.within(220, 255); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[1]).to.be.within(100, 140); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[2]).to.be.within(40, 80); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[3]).to.be.within(110, 145); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("applies fractional source crops and destination rectangles on the GPU", function () { + var source = createCanvas(12, 8); + var destination = createCanvas(30, 22); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 6, 4); + source.context.fillStyle = "#00ff00"; + source.context.fillRect(6, 0, 6, 4); + source.context.fillStyle = "#0000ff"; + source.context.fillRect(0, 4, 6, 4); + source.context.fillStyle = "#ffff00"; + source.context.fillRect(6, 4, 6, 4); + + // A narrow crop straddling x=6 amplifies fractional-coordinate handling. + destination.context.drawImage( + source.canvas, + 5.99, + 0, + 1.01, + 4, + 2.75, + 1.25, + 24.5, + 8.5 + ); + // Do the same across y=4 in a separate destination region. + destination.context.drawImage( + source.canvas, + 0, + 3.99, + 6, + 1.01, + 1.25, + 11.75, + 12.5, + 8.5 + ); + + var pixels = captureGpuPixels(destination.canvas); + // Pixel (12,3), sampled at its center (12.5,3.5), maps to source + // x = 5.99 + (12.5 - 2.75) * 1.01 / 24.5 = 6.39194. + // Between red texel center 5.5 and green center 6.5, ideal bilinear + // weights are 10.8% red / 89.2% green (R~28, G~227). + // Integer truncation maps x to 5.4375; ignoring the crop maps x to + // 5.25. Both negative controls are solid red at this sample. + var green = pixelAt(pixels, destination.canvas.width, 12, 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(green[1] - green[0]).to.be.greaterThan(100); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(green[3]).to.be.greaterThan(200); + + // Pixel (3,14), sampled at (3.5,14.5), maps to source + // y = 3.99 + (14.5 - 11.75) * 1.01 / 8.5 = 4.31676. + // That is 18.3% top red / 81.7% bottom blue (R~47, B~208). + // Integer truncation maps y to 3.4375 and ignoring the crop maps y + // to 3.5, both solid red rather than blue. + var blue = pixelAt(pixels, destination.canvas.width, 3, 14); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(blue[2] - blue[0]).to.be.greaterThan(80); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(blue[3]).to.be.greaterThan(200); + + var fractionalEdge = pixelAt( + pixels, + destination.canvas.width, + 26, + 3 + ); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fractionalEdge[0]).to.be.lessThan(80); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fractionalEdge[1]).to.be.greaterThan(180); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fractionalEdge[2]).to.be.lessThan(50); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fractionalEdge[3]).to.be.greaterThan(150); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("normalizes negative source and destination extents", function () { + var source = createCanvas(12, 8); + var destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 12, 8); + source.context.fillStyle = "#ffff00"; + source.context.fillRect(6, 4, 4, 3); + + destination.context.drawImage( + source.canvas, + 10, + 7, + -4, + -3, + 12, + 10, + -8, + -6 + ); + + var pixels = captureGpuPixels(destination.canvas); + var inside = pixelAt(pixels, destination.canvas.width, 7, 7); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[0]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[1]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[2]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[3]).to.be.greaterThan(220); + + var outside = pixelAt(pixels, destination.canvas.width, 2, 7); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[3]).to.equal(0); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("clips the source rectangle and adjusts the destination proportionally", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(16, 16); + try { + source.context.fillStyle = "#00ffff"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage( + source.canvas, + -2, + -2, + 4, + 4, + 2, + 2, + 12, + 12 + ); + + var pixels = captureGpuPixels(destination.canvas); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, destination.canvas.width, 5, 5)[3]).to.equal(0); + + var inside = pixelAt(pixels, destination.canvas.width, 10, 10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[0]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[1]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[2]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(inside[3]).to.be.greaterThan(220); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("keeps temporary Canvas images alive until queued draws flush", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(16, 8); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 0, 0, 8, 8); + + source.context.fillStyle = "#0000ff"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 8, 0, 8, 8); + + var pixels = captureGpuPixels(destination.canvas); + var first = pixelAt(pixels, destination.canvas.width, 4, 4); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(first[0]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(first[1]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(first[2]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(first[3]).to.be.greaterThan(220); + + var second = pixelAt(pixels, destination.canvas.width, 12, 4); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(second[0]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(second[1]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(second[2]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(second[3]).to.be.greaterThan(220); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("keeps ImageData uploads alive through putImageData and drawImage", function () { + var destination = createCanvas(12, 6); + try { + var putData = destination.context.createImageData(4, 4); + var drawData = destination.context.createImageData(4, 4); + for (var i = 0; i < putData.data.length; i += 4) { + putData.data[i] = 240; + putData.data[i + 1] = 96; + putData.data[i + 2] = 32; + putData.data[i + 3] = 255; + + drawData.data[i] = 96; + drawData.data[i + 1] = 48; + drawData.data[i + 2] = 240; + drawData.data[i + 3] = 255; + } + + destination.context.putImageData(putData, 1, 1); + destination.context.drawImage( + { + data: drawData.data, + width: drawData.width, + height: drawData.height, + // NativeEngine image bitmaps expose bimg::TextureFormat::RGBA8. + format: 74 + }, + 7, + 1 + ); + + var pixels = captureGpuPixels(destination.canvas); + var putSample = pixelAt(pixels, destination.canvas.width, 2, 2); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(putSample[0]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(putSample[1]).to.be.within(75, 115); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(putSample[2]).to.be.within(15, 50); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(putSample[3]).to.be.greaterThan(240); + + var drawSample = pixelAt(pixels, destination.canvas.width, 8, 2); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(drawSample[0]).to.be.within(75, 115); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(drawSample[1]).to.be.within(30, 70); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(drawSample[2]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(drawSample[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + } + }); + + it("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { + var source = createCanvas(20, 8); + var destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#00ffff"; + source.context.fillRect(12, 0, 8, 8); + + destination.context.fillStyle = "#000000"; + destination.context.fillRect(0, 0, 16, 12); + destination.context.beginPath(); + destination.context.rect(4, 2, 6, 6); + destination.context.clip(); + destination.context.translate(2, 1); + destination.context.globalAlpha = 0.5; + destination.context.drawImage( + source.canvas, + 12, + 0, + 4, + 8, + 0, + 0, + 8, + 8 + ); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(destination.context.globalAlpha).to.equal(0.5); + var transform = destination.context.getTransform(); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(transform.e).to.equal(2); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(transform.f).to.equal(1); + + var pixels = captureGpuPixels(destination.canvas); + var cropped = pixelAt(pixels, destination.canvas.width, 5, 4); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(cropped[0]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(cropped[1]).to.be.within(100, 155); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(cropped[2]).to.be.within(100, 155); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(cropped[3]).to.be.greaterThan(240); + + var translatedEdge = pixelAt( + pixels, + destination.canvas.width, + 8, + 4 + ); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(translatedEdge[0]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(translatedEdge[1]).to.be.within(100, 155); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(translatedEdge[2]).to.be.within(100, 155); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(translatedEdge[3]).to.be.greaterThan(240); + + var clippedOut = pixelAt(pixels, destination.canvas.width, 5, 8); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(clippedOut[0]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(clippedOut[1]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(clippedOut[2]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(clippedOut[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); - var dstCtx = createContext(); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {dstCtx.drawImage(srcCanvas, 0, 0);}).to.not.throw(); + it("intersects repeated Canvas draws with a retained non-rectangular clip", function () { + var source = createCanvas(4, 4); + var destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 4, 4); + + destination.context.fillStyle = "#000000"; + destination.context.fillRect(0, 0, 16, 12); + destination.context.beginPath(); + destination.context.moveTo(1, 1); + destination.context.lineTo(13, 1); + destination.context.lineTo(1, 11); + destination.context.closePath(); + destination.context.clip(); + destination.context.drawImage(source.canvas, 2, 2, 4, 4); + destination.context.drawImage(source.canvas, 10, 8, 4, 4); + + var pixels = captureGpuPixels(destination.canvas); + var firstDraw = pixelAt(pixels, destination.canvas.width, 3, 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(firstDraw[0]).to.be.greaterThan(220); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(firstDraw[1]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(firstDraw[2]).to.be.lessThan(30); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(firstDraw[3]).to.be.greaterThan(240); - var url = srcCanvas.toDataURL("image/png"); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.indexOf("data:image/png;base64,")).to.equal(0); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.length).to.be.greaterThan(32); + [ + // Inside the triangle but outside both destination rectangles. + pixelAt(pixels, destination.canvas.width, 2, 8), + // Inside the second destination rectangle but outside the triangle. + pixelAt(pixels, destination.canvas.width, 12, 9)]. + forEach(function (outside) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[0]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[1]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[2]).to.be.lessThan(10); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[3]).to.be.greaterThan(240); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("keeps fractional drawImage edges out of the CPU mirror", function () { + var source = createCanvas(2, 2); + var destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#ffffff"; + source.context.fillRect(0, 0, 2, 2); + destination.context.drawImage(source.canvas, 1.75, 1.75, 2.5, 2.5); + + var pixels = destination.context.getImageData(0, 0, 8, 8).data; + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, destination.canvas.width, 2, 2)[3]).to.equal(255); + [ + pixelAt(pixels, destination.canvas.width, 1, 2), + pixelAt(pixels, destination.canvas.width, 4, 2), + pixelAt(pixels, destination.canvas.width, 2, 1), + pixelAt(pixels, destination.canvas.width, 2, 4)]. + forEach(function (outside) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[3]).to.equal(0); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("falls back to PNG for default, empty, case-variant, and unsupported MIME types", function () { + var canvas = new _native.Canvas(); + canvas.width = 2; + canvas.height = 2; + try { + [ + canvas.toDataURL(), + canvas.toDataURL(""), + canvas.toDataURL("image/png"), + canvas.toDataURL("IMAGE/PNG"), + canvas.toDataURL("image/jpeg")]. + forEach(function (url) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.indexOf("data:image/png;base64,")).to.equal(0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(url.length).to.be.greaterThan(32); + }); + } finally { + canvas.dispose(); + } + }); + + it("returns ascent and descent in no-font text metrics", function () { + var resource = createCanvas(8, 8); + try { + resource.context.font = "20px MissingFontForCanvasMetrics"; + var metrics = resource.context.measureText("test"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(metrics).to.have.property("actualBoundingBoxAscent"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(metrics).to.have.property("actualBoundingBoxDescent"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(metrics.actualBoundingBoxAscent).to.equal(15); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(metrics.actualBoundingBoxDescent).to.equal(5); + } finally { + disposeCanvas(resource); + } }); }); diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index 7592b71fc4..c610087728 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -153,6 +153,50 @@ describe("Canvas2D", function () { return canvas.getContext("2d"); } + function createCanvas(width: number, height: number): any { + const canvas = new _native.Canvas(); + canvas.width = width; + canvas.height = height; + return { canvas, context: canvas.getContext("2d") }; + } + + function disposeCanvas(resource: any): void { + resource.context.dispose(); + resource.canvas.dispose(); + } + + function captureGpuPixels(canvas: any): any { + const probe = createCanvas(canvas.width, canvas.height); + try { + // drawImage(canvas) captures the source framebuffer, while getImageData only + // reads the probe's CPU mirror. This makes assertions observe the source GPU. + probe.context.drawImage(canvas, 0, 0); + return probe.context.getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; + } finally { + disposeCanvas(probe); + } + } + + function pixelAt( + pixels: any, + width: number, + x: number, + y: number + ): number[] { + const offset = (y * width + x) * 4; + return [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + } + it("round-trips a string fillStyle and strokeStyle", function () { const ctx = createContext(); ctx.fillStyle = "#ff0000"; @@ -496,36 +540,410 @@ describe("Canvas2D", function () { expect(data.height).to.equal(3); }); - it("accepts another Canvas as drawImage source and supports toDataURL png", function () { - const srcCanvas = new _native.Canvas(); - srcCanvas.width = 32; - srcCanvas.height = 32; - const srcCtx = srcCanvas.getContext("2d"); - // Semi-transparent fill exercises GPU readback + unpremultiply (opaque red alone - // would still pass if the source were transparent/stale or double-premultiplied). - srcCtx.fillStyle = "rgba(255, 0, 0, 0.5)"; - srcCtx.fillRect(0, 0, 32, 32); - - const dstCanvas = new _native.Canvas(); - dstCanvas.width = 32; - dstCanvas.height = 32; - const dstCtx = dstCanvas.getContext("2d"); - expect(function () { dstCtx.drawImage(srcCanvas, 0, 0); }).to.not.throw(); - - const url = srcCanvas.toDataURL("image/png"); - expect(url.indexOf("data:image/png;base64,")).to.equal(0); - expect(url.length).to.be.greaterThan(32); - - // Sample destination after drawImage(canvas) — must see straight-alpha red, not - // transparent black and not double-premultiplied (dark) red. - const sample = dstCtx.getImageData(16, 16, 1, 1).data; - expect(sample[0]).to.be.at.least(200); // R - expect(sample[1]).to.be.at.most(30); // G - expect(sample[2]).to.be.at.most(30); // B - expect(sample[3]).to.be.within(100, 160); // A ~128 - }); + it("renders a semi-transparent Canvas source through the destination GPU", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(8, 8); + try { + source.context.fillStyle = "rgba(240, 120, 60, 0.5)"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 0, 0); + + const sample = pixelAt( + captureGpuPixels(destination.canvas), + destination.canvas.width, + 4, + 4 + ); + expect(sample[0]).to.be.within(220, 255); + expect(sample[1]).to.be.within(100, 140); + expect(sample[2]).to.be.within(40, 80); + expect(sample[3]).to.be.within(110, 145); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("applies fractional source crops and destination rectangles on the GPU", function () { + const source = createCanvas(12, 8); + const destination = createCanvas(30, 22); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 6, 4); + source.context.fillStyle = "#00ff00"; + source.context.fillRect(6, 0, 6, 4); + source.context.fillStyle = "#0000ff"; + source.context.fillRect(0, 4, 6, 4); + source.context.fillStyle = "#ffff00"; + source.context.fillRect(6, 4, 6, 4); + + // A narrow crop straddling x=6 amplifies fractional-coordinate handling. + destination.context.drawImage( + source.canvas, + 5.99, + 0, + 1.01, + 4, + 2.75, + 1.25, + 24.5, + 8.5 + ); + // Do the same across y=4 in a separate destination region. + destination.context.drawImage( + source.canvas, + 0, + 3.99, + 6, + 1.01, + 1.25, + 11.75, + 12.5, + 8.5 + ); + + const pixels = captureGpuPixels(destination.canvas); + // Pixel (12,3), sampled at its center (12.5,3.5), maps to source + // x = 5.99 + (12.5 - 2.75) * 1.01 / 24.5 = 6.39194. + // Between red texel center 5.5 and green center 6.5, ideal bilinear + // weights are 10.8% red / 89.2% green (R~28, G~227). + // Integer truncation maps x to 5.4375; ignoring the crop maps x to + // 5.25. Both negative controls are solid red at this sample. + const green = pixelAt(pixels, destination.canvas.width, 12, 3); + expect(green[1] - green[0]).to.be.greaterThan(100); + expect(green[3]).to.be.greaterThan(200); + + // Pixel (3,14), sampled at (3.5,14.5), maps to source + // y = 3.99 + (14.5 - 11.75) * 1.01 / 8.5 = 4.31676. + // That is 18.3% top red / 81.7% bottom blue (R~47, B~208). + // Integer truncation maps y to 3.4375 and ignoring the crop maps y + // to 3.5, both solid red rather than blue. + const blue = pixelAt(pixels, destination.canvas.width, 3, 14); + expect(blue[2] - blue[0]).to.be.greaterThan(80); + expect(blue[3]).to.be.greaterThan(200); + + const fractionalEdge = pixelAt( + pixels, + destination.canvas.width, + 26, + 3 + ); + expect(fractionalEdge[0]).to.be.lessThan(80); + expect(fractionalEdge[1]).to.be.greaterThan(180); + expect(fractionalEdge[2]).to.be.lessThan(50); + expect(fractionalEdge[3]).to.be.greaterThan(150); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("normalizes negative source and destination extents", function () { + const source = createCanvas(12, 8); + const destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 12, 8); + source.context.fillStyle = "#ffff00"; + source.context.fillRect(6, 4, 4, 3); + + destination.context.drawImage( + source.canvas, + 10, + 7, + -4, + -3, + 12, + 10, + -8, + -6 + ); + + const pixels = captureGpuPixels(destination.canvas); + const inside = pixelAt(pixels, destination.canvas.width, 7, 7); + expect(inside[0]).to.be.greaterThan(220); + expect(inside[1]).to.be.greaterThan(220); + expect(inside[2]).to.be.lessThan(30); + expect(inside[3]).to.be.greaterThan(220); + + const outside = pixelAt(pixels, destination.canvas.width, 2, 7); + expect(outside[3]).to.equal(0); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("clips the source rectangle and adjusts the destination proportionally", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(16, 16); + try { + source.context.fillStyle = "#00ffff"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage( + source.canvas, + -2, + -2, + 4, + 4, + 2, + 2, + 12, + 12 + ); + + const pixels = captureGpuPixels(destination.canvas); + expect(pixelAt(pixels, destination.canvas.width, 5, 5)[3]).to.equal(0); + + const inside = pixelAt(pixels, destination.canvas.width, 10, 10); + expect(inside[0]).to.be.lessThan(30); + expect(inside[1]).to.be.greaterThan(220); + expect(inside[2]).to.be.greaterThan(220); + expect(inside[3]).to.be.greaterThan(220); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("keeps temporary Canvas images alive until queued draws flush", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(16, 8); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 0, 0, 8, 8); + + source.context.fillStyle = "#0000ff"; + source.context.fillRect(0, 0, 8, 8); + destination.context.drawImage(source.canvas, 8, 0, 8, 8); + + const pixels = captureGpuPixels(destination.canvas); + const first = pixelAt(pixels, destination.canvas.width, 4, 4); + expect(first[0]).to.be.greaterThan(220); + expect(first[1]).to.be.lessThan(30); + expect(first[2]).to.be.lessThan(30); + expect(first[3]).to.be.greaterThan(220); + + const second = pixelAt(pixels, destination.canvas.width, 12, 4); + expect(second[0]).to.be.lessThan(30); + expect(second[1]).to.be.lessThan(30); + expect(second[2]).to.be.greaterThan(220); + expect(second[3]).to.be.greaterThan(220); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("keeps ImageData uploads alive through putImageData and drawImage", function () { + const destination = createCanvas(12, 6); + try { + const putData = destination.context.createImageData(4, 4); + const drawData = destination.context.createImageData(4, 4); + for (let i = 0; i < putData.data.length; i += 4) { + putData.data[i] = 240; + putData.data[i + 1] = 96; + putData.data[i + 2] = 32; + putData.data[i + 3] = 255; + + drawData.data[i] = 96; + drawData.data[i + 1] = 48; + drawData.data[i + 2] = 240; + drawData.data[i + 3] = 255; + } + + destination.context.putImageData(putData, 1, 1); + destination.context.drawImage( + { + data: drawData.data, + width: drawData.width, + height: drawData.height, + // NativeEngine image bitmaps expose bimg::TextureFormat::RGBA8. + format: 74, + }, + 7, + 1 + ); + + const pixels = captureGpuPixels(destination.canvas); + const putSample = pixelAt(pixels, destination.canvas.width, 2, 2); + expect(putSample[0]).to.be.greaterThan(220); + expect(putSample[1]).to.be.within(75, 115); + expect(putSample[2]).to.be.within(15, 50); + expect(putSample[3]).to.be.greaterThan(240); + + const drawSample = pixelAt(pixels, destination.canvas.width, 8, 2); + expect(drawSample[0]).to.be.within(75, 115); + expect(drawSample[1]).to.be.within(30, 70); + expect(drawSample[2]).to.be.greaterThan(220); + expect(drawSample[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + } + }); + + it("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { + const source = createCanvas(20, 8); + const destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#00ffff"; + source.context.fillRect(12, 0, 8, 8); + + destination.context.fillStyle = "#000000"; + destination.context.fillRect(0, 0, 16, 12); + destination.context.beginPath(); + destination.context.rect(4, 2, 6, 6); + destination.context.clip(); + destination.context.translate(2, 1); + destination.context.globalAlpha = 0.5; + destination.context.drawImage( + source.canvas, + 12, + 0, + 4, + 8, + 0, + 0, + 8, + 8 + ); + + expect(destination.context.globalAlpha).to.equal(0.5); + const transform = destination.context.getTransform(); + expect(transform.e).to.equal(2); + expect(transform.f).to.equal(1); + + const pixels = captureGpuPixels(destination.canvas); + const cropped = pixelAt(pixels, destination.canvas.width, 5, 4); + expect(cropped[0]).to.be.lessThan(30); + expect(cropped[1]).to.be.within(100, 155); + expect(cropped[2]).to.be.within(100, 155); + expect(cropped[3]).to.be.greaterThan(240); + + const translatedEdge = pixelAt( + pixels, + destination.canvas.width, + 8, + 4 + ); + expect(translatedEdge[0]).to.be.lessThan(30); + expect(translatedEdge[1]).to.be.within(100, 155); + expect(translatedEdge[2]).to.be.within(100, 155); + expect(translatedEdge[3]).to.be.greaterThan(240); + + const clippedOut = pixelAt(pixels, destination.canvas.width, 5, 8); + expect(clippedOut[0]).to.be.lessThan(10); + expect(clippedOut[1]).to.be.lessThan(10); + expect(clippedOut[2]).to.be.lessThan(10); + expect(clippedOut[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("intersects repeated Canvas draws with a retained non-rectangular clip", function () { + const source = createCanvas(4, 4); + const destination = createCanvas(16, 12); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 4, 4); + + destination.context.fillStyle = "#000000"; + destination.context.fillRect(0, 0, 16, 12); + destination.context.beginPath(); + destination.context.moveTo(1, 1); + destination.context.lineTo(13, 1); + destination.context.lineTo(1, 11); + destination.context.closePath(); + destination.context.clip(); + destination.context.drawImage(source.canvas, 2, 2, 4, 4); + destination.context.drawImage(source.canvas, 10, 8, 4, 4); + + const pixels = captureGpuPixels(destination.canvas); + const firstDraw = pixelAt(pixels, destination.canvas.width, 3, 3); + expect(firstDraw[0]).to.be.greaterThan(220); + expect(firstDraw[1]).to.be.lessThan(30); + expect(firstDraw[2]).to.be.lessThan(30); + expect(firstDraw[3]).to.be.greaterThan(240); + + [ + // Inside the triangle but outside both destination rectangles. + pixelAt(pixels, destination.canvas.width, 2, 8), + // Inside the second destination rectangle but outside the triangle. + pixelAt(pixels, destination.canvas.width, 12, 9), + ].forEach(function (outside) { + expect(outside[0]).to.be.lessThan(10); + expect(outside[1]).to.be.lessThan(10); + expect(outside[2]).to.be.lessThan(10); + expect(outside[3]).to.be.greaterThan(240); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } }); + it("keeps fractional drawImage edges out of the CPU mirror", function () { + const source = createCanvas(2, 2); + const destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#ffffff"; + source.context.fillRect(0, 0, 2, 2); + destination.context.drawImage(source.canvas, 1.75, 1.75, 2.5, 2.5); + + const pixels = destination.context.getImageData(0, 0, 8, 8).data; + expect(pixelAt(pixels, destination.canvas.width, 2, 2)[3]).to.equal(255); + [ + pixelAt(pixels, destination.canvas.width, 1, 2), + pixelAt(pixels, destination.canvas.width, 4, 2), + pixelAt(pixels, destination.canvas.width, 2, 1), + pixelAt(pixels, destination.canvas.width, 2, 4), + ].forEach(function (outside) { + expect(outside[3]).to.equal(0); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("falls back to PNG for default, empty, case-variant, and unsupported MIME types", function () { + const canvas = new _native.Canvas(); + canvas.width = 2; + canvas.height = 2; + try { + [ + canvas.toDataURL(), + canvas.toDataURL(""), + canvas.toDataURL("image/png"), + canvas.toDataURL("IMAGE/PNG"), + canvas.toDataURL("image/jpeg"), + ].forEach(function (url) { + expect(url.indexOf("data:image/png;base64,")).to.equal(0); + expect(url.length).to.be.greaterThan(32); + }); + } finally { + canvas.dispose(); + } + }); + + it("returns ascent and descent in no-font text metrics", function () { + const resource = createCanvas(8, 8); + try { + resource.context.font = "20px MissingFontForCanvasMetrics"; + const metrics = resource.context.measureText("test"); + expect(metrics).to.have.property("actualBoundingBoxAscent"); + expect(metrics).to.have.property("actualBoundingBoxDescent"); + expect(metrics.actualBoundingBoxAscent).to.equal(15); + expect(metrics.actualBoundingBoxDescent).to.equal(5); + } finally { + disposeCanvas(resource); + } + }); +}); + function createSceneAndWait(callback: (engine: NativeEngine, scene: Scene) => void, done: () => void) { const engine = new NativeEngine(); const scene = new Scene(engine); diff --git a/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp new file mode 100644 index 0000000000..c04a235da5 --- /dev/null +++ b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp @@ -0,0 +1,65 @@ +#include + +#include "../../../Polyfills/Canvas/Source/nanovg/fontstash.h" +#include + +#include +#include +#include +#include +#include + +TEST(CanvasTextMetrics, ExcludesAtlasBorderAndBlurPaddingFromInkBounds) +{ + std::ifstream file{CANVAS_TEST_FONT_PATH, std::ios::binary}; + ASSERT_TRUE(file.is_open()); + std::vector bytes{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; + ASSERT_FALSE(bytes.empty()); + stbtt_fontinfo font{}; + ASSERT_NE(stbtt_InitFont(&font, bytes.data(), 0), 0); + + for (const auto flags : {FONS_ZERO_TOPLEFT, FONS_ZERO_BOTTOMLEFT}) + { + FONSparams params{}; + params.width = 512; + params.height = 512; + params.flags = static_cast(flags); + std::unique_ptr stash{fonsCreateInternal(¶ms), fonsDeleteInternal}; + ASSERT_NE(stash, nullptr); + const int face = fonsAddFontMem(stash.get(), "test", bytes.data(), static_cast(bytes.size()), 0); + ASSERT_NE(face, FONS_INVALID); + fonsSetFont(stash.get(), face); + fonsSetAlign(stash.get(), FONS_ALIGN_LEFT | FONS_ALIGN_BASELINE); + + for (const float size : {24.0f, 48.0f}) + { + fonsSetSize(stash.get(), size); + const float scale = stbtt_ScaleForMappingEmToPixels(&font, size); + for (const char character : {'A', 'j', 'g'}) + { + const char text[]{character, '\0'}; + int x0{}, y0{}, x1{}, y1{}; + stbtt_GetCodepointBitmapBox(&font, character, scale, scale, &x0, &y0, &x1, &y1); + const std::array expected{ + 100.0f + static_cast(x0), + flags == FONS_ZERO_TOPLEFT ? 100.0f + static_cast(y0) : 100.0f - static_cast(y1), + 100.0f + static_cast(x1), + flags == FONS_ZERO_TOPLEFT ? 100.0f + static_cast(y1) : 100.0f - static_cast(y0)}; + float unblurredAdvance{}; + for (const float blur : {0.0f, 4.0f, 20.0f, 40.0f}) + { + SCOPED_TRACE(::testing::Message() << flags << ", " << size << ", " << character << ", blur=" << blur); + fonsSetBlur(stash.get(), blur); + std::array actual{}; + const float advance = fonsTextBounds(stash.get(), 100.0f, 100.0f, text, nullptr, actual.data()); + EXPECT_EQ(actual, expected); + if (blur == 0.0f) + { + unblurredAdvance = advance; + } + EXPECT_FLOAT_EQ(advance, unblurredAdvance); + } + } + } + } +} diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h index 83e56392cf..eb7f438825 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h @@ -129,9 +129,9 @@ namespace Babylon::Graphics // continue within the same logical frame. Call at draw/clear op boundaries. void FlushViewsIfNeeded(); - // Force a mid-frame flush when a FrameCompletionScope is held so pending - // bgfx::readTexture requests can complete (Canvas toDataURL / drawImage). - void ForceMidFrameFlush(); + // Force a mid-frame flush when a FrameCompletionScope is held so pending + // bgfx::readTexture requests can complete (Canvas toDataURL / drawImage). + void ForceMidFrameFlush(); // TODO: find a different way to get the texture info for frame capture void AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format); diff --git a/Core/Graphics/Source/DeviceContext.cpp b/Core/Graphics/Source/DeviceContext.cpp index 4b16299437..e7b0e7d357 100644 --- a/Core/Graphics/Source/DeviceContext.cpp +++ b/Core/Graphics/Source/DeviceContext.cpp @@ -147,10 +147,10 @@ namespace Babylon::Graphics m_graphicsImpl.FlushViewsIfNeeded(); } - void DeviceContext::ForceMidFrameFlush() - { - m_graphicsImpl.ForceMidFrameFlush(); - } + void DeviceContext::ForceMidFrameFlush() + { + m_graphicsImpl.ForceMidFrameFlush(); + } void DeviceContext::AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format) { diff --git a/Core/Graphics/Source/DeviceImpl.cpp b/Core/Graphics/Source/DeviceImpl.cpp index 4ebbcc17ce..069d2843e2 100644 --- a/Core/Graphics/Source/DeviceImpl.cpp +++ b/Core/Graphics/Source/DeviceImpl.cpp @@ -588,43 +588,43 @@ namespace Babylon::Graphics return; } - ForceMidFrameFlush(); - } + ForceMidFrameFlush(); + } - void DeviceImpl::ForceMidFrameFlush() + void DeviceImpl::ForceMidFrameFlush() + { + // The flush advances a bgfx frame, which must happen on the render (bgfx API) + // thread. This method is only expected to be called from the JS thread while + // the render thread is parked in FinishRenderingCurrentFrame. If we're on the + // render thread (or affinity is unset), there is nothing safe to do here. + if (m_renderThreadAffinity.check()) { - // The flush advances a bgfx frame, which must happen on the render (bgfx API) - // thread. This method is only expected to be called from the JS thread while - // the render thread is parked in FinishRenderingCurrentFrame. If we're on the - // render thread (or affinity is unset), there is nothing safe to do here. - if (m_renderThreadAffinity.check()) - { - return; - } - - std::unique_lock lock{m_frameSyncMutex}; + return; + } - // The mid-frame flush advances a bgfx frame on the render thread, which - // can only be serviced while the render thread is parked in - // FinishRenderingCurrentFrame waiting for frame scopes to drain. That is - // only guaranteed while at least one FrameCompletionScope is active (the - // normal requestAnimationFrame render path). When a snippet drives frames - // manually (e.g. setInterval + engine.beginFrame/scene.render/ - // engine.endFrame) there is no frame scope, the render thread is not - // parked to service the request, and parking the JS thread on - // m_flushCompleteCV would deadlock. Skip the flush in that case; the hard - // cap in AcquireNewViewId remains as a backstop. Likewise skip if the gate - // is currently closed (bgfx::frame() in progress). - if (m_frameBlocked || m_pendingFrameScopes == 0) - { - return; - } + std::unique_lock lock{m_frameSyncMutex}; - m_flushRequested = true; - m_frameSyncCV.notify_all(); - m_flushCompleteCV.wait(lock, [this] { return !m_flushRequested; }); + // The mid-frame flush advances a bgfx frame on the render thread, which + // can only be serviced while the render thread is parked in + // FinishRenderingCurrentFrame waiting for frame scopes to drain. That is + // only guaranteed while at least one FrameCompletionScope is active (the + // normal requestAnimationFrame render path). When a snippet drives frames + // manually (e.g. setInterval + engine.beginFrame/scene.render/ + // engine.endFrame) there is no frame scope, the render thread is not + // parked to service the request, and parking the JS thread on + // m_flushCompleteCV would deadlock. Skip the flush in that case; the hard + // cap in AcquireNewViewId remains as a backstop. Likewise skip if the gate + // is currently closed (bgfx::frame() in progress). + if (m_frameBlocked || m_pendingFrameScopes == 0) + { + return; } + m_flushRequested = true; + m_frameSyncCV.notify_all(); + m_flushCompleteCV.wait(lock, [this] { return !m_flushRequested; }); + } + // Called on the render thread from FinishRenderingCurrentFrame while holding // m_frameSyncMutex, with the requesting JS thread parked in FlushViewsIfNeeded // (so the frame encoder is idle). End the current encoder, advance a non-presenting diff --git a/Core/Graphics/Source/DeviceImpl.h b/Core/Graphics/Source/DeviceImpl.h index f5fa8dc939..4b18a716fe 100644 --- a/Core/Graphics/Source/DeviceImpl.h +++ b/Core/Graphics/Source/DeviceImpl.h @@ -104,11 +104,11 @@ namespace Babylon::Graphics // draw/clear operation boundaries where no encoder work is pending. void FlushViewsIfNeeded(); - // Unconditionally request a mid-frame bgfx flush when a FrameCompletionScope - // is active (same handshake as FlushViewsIfNeeded). Used by Canvas GPU - // readback so bgfx::readTexture can complete without waiting for the end of - // the logical frame. No-ops when the render thread cannot service it. - void ForceMidFrameFlush(); + // Unconditionally request a mid-frame bgfx flush when a FrameCompletionScope + // is active (same handshake as FlushViewsIfNeeded). Used by Canvas GPU + // readback so bgfx::readTexture can complete without waiting for the end of + // the logical frame. No-ops when the render thread cannot service it. + void ForceMidFrameFlush(); // Frame completion scope support void IncrementPendingFrameScopes(); diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index b034a2a5be..c264aa381c 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -1,6 +1,37 @@ # Canvas Implements parts of the 2D Canvas API using bgfx. Still a very early WIP; many methods are not yet implemented. +## Readback and text metrics + +`toDataURL()` encodes PNG from the rendered canvas. Empty, case-variant, and +unsupported media types use the same PNG fallback; JPEG/WebP encoding is not +implemented. `drawImage(canvas, ...)` snapshots rendered source pixels, not only +the CPU pixel mirror. + +`measureText()` uses em-scaled font sizes and glyph ink bounds. Atlas SDF padding, +the interpolation border, and fontstash blur padding are excluded from those +bounds. The no-font fallback exposes the same metric properties, but its values +are estimates rather than measurements of a loaded face. + +## Native Canvas visual baseline + +The `native-canvas.png` reference for Playground `#TKVFSA#8` is a Native regression +baseline, not a claim of browser Canvas parity. The em-size correction is +incremental, and the current reference also records these known divergences: + +- Linear-gradient colors repeat past their end instead of clamping to the final + stop as Babylon.js/browser Canvas does. +- The projected canvas texture has an additional dark perimeter. +- The Native path uses Droid Sans, so the font inputs are not matched with the + browser path. + +Follow-up work must isolate gradient extension and texture-edge sampling/alpha, +and run both paths with the same font bytes, dimensions, device-pixel ratio, and +scene inputs. Compare each intended correction against Babylon.js before updating +its reference. Do not regenerate a golden from current Native output merely to +make the comparison pass. The readback/rectangle and text-padding fixes do not +regenerate this baseline. + # Nanovg This project contains a fork of Nanovg code and shaders found in bgfx repo. This fork features new filters stack to allow shadow, blur to be enabled in nanovg rendering (nanovg_filterstack.*). Also, the rendering backend of Nanovg is defined in nanovg_babylon.*. It implements nanovg rendering using bgfx with an extension to allow blending of 2 textures (used for gradient mixing) whereas default implementation only allow 1 texture. Shaders are modified accordingly. diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index 22f0ca153e..f6071e1a30 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -249,20 +249,9 @@ namespace Babylon::Polyfills::Internal return Napi::Pointer::Create(info.Env(), m_texture.get()); } - Napi::Value NativeCanvas::ToDataURL(const Napi::CallbackInfo& info) { - // toDataURL([type]) — only image/png is supported; other types throw. - std::string type{"image/png"}; - if (info.Length() >= 1 && info[0].IsString()) - { - type = info[0].As().Utf8Value(); - } - if (type != "image/png" && type != "image/PNG") - { - throw Napi::TypeError::New(info.Env(), "Canvas.toDataURL: only image/png is supported on Native."); - } - + // PNG is also the Canvas fallback for empty or unsupported media types. const uint32_t width = m_width; const uint32_t height = m_height; std::vector rgba(static_cast(width) * height * 4, 0); @@ -299,6 +288,7 @@ namespace Babylon::Polyfills::Internal return Napi::String::New(info.Env(), "data:image/png;base64," + encoded); #endif } + Napi::Value NativeCanvas::ParseColor(const Napi::CallbackInfo& info) { const auto colorString = info[0].As().Utf8Value(); diff --git a/Polyfills/Canvas/Source/Canvas.h b/Polyfills/Canvas/Source/Canvas.h index ee1ed61a5a..6cc504a90f 100644 --- a/Polyfills/Canvas/Source/Canvas.h +++ b/Polyfills/Canvas/Source/Canvas.h @@ -78,7 +78,7 @@ namespace Babylon::Polyfills::Internal FrameBufferPool m_frameBufferPool; // Blit view reserved by Context::Flush for CopyTexture ordering. - void SetBlitViewId(bgfx::ViewId viewId, uint32_t generation) + void SetBlitViewId(bgfx::ViewId viewId, uint32_t generation) { m_blitViewId = viewId; m_blitViewIdGeneration = generation; diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 03477e68c4..5cbcd78ffb 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -152,15 +152,15 @@ namespace Babylon::Polyfills::Internal { m_canvas->SetBoundContext(nullptr); } - m_canvas = nullptr; - Dispose(); - m_cancellationSource->cancel(); - } + m_canvas = nullptr; + Dispose(); + m_cancellationSource->cancel(); + } - void Context::DetachCanvas() - { - m_canvas = nullptr; - } + void Context::DetachCanvas() + { + m_canvas = nullptr; + } void Context::Dispose(const Napi::CallbackInfo&) { @@ -176,6 +176,7 @@ namespace Babylon::Polyfills::Internal { if (m_nvg) { + ReleaseImagesAfterFlush(); for (auto& image : m_nvgImageIndices) { nvgDeleteImage(*m_nvg, image.second); @@ -705,6 +706,8 @@ namespace Babylon::Polyfills::Internal obj.Set("height", Napi::Value::From(info.Env(), ascent + descent)); obj.Set("actualBoundingBoxLeft", Napi::Value::From(info.Env(), 0.f)); obj.Set("actualBoundingBoxRight", Napi::Value::From(info.Env(), width)); + obj.Set("actualBoundingBoxAscent", Napi::Value::From(info.Env(), ascent)); + obj.Set("actualBoundingBoxDescent", Napi::Value::From(info.Env(), descent)); obj.Set("fontBoundingBoxAscent", Napi::Value::From(info.Env(), ascent)); obj.Set("fontBoundingBoxDescent", Napi::Value::From(info.Env(), descent)); return obj.As(); @@ -867,6 +870,7 @@ namespace Babylon::Polyfills::Internal nvgSetFrameBufferAndEncoder(*m_nvg, frameBuffer, encoder); nvgSetFrameBufferPool(*m_nvg, { acquire, release }); nvgEndFrame(*m_nvg); + ReleaseImagesAfterFlush(); frameBuffer.Unbind(); // Reserve the view id for the eventual canvas->texture blit NOW, while we are @@ -895,6 +899,11 @@ namespace Babylon::Polyfills::Internal return rgba; } + // Capture can run with a non-null encoder but no JS-owned scope (notably in + // unit-test hosts). Acquire before FlushCore so both NanoVG submission and + // the readback remain in a frame the render thread cannot close underneath us. + auto scope = m_graphicsContext.AcquireFrameCompletionScope(); + // Push pending NanoVG draws into the canvas RT first. FlushCore(); if (!m_canvas->HasFrameBuffer()) @@ -902,12 +911,6 @@ namespace Babylon::Polyfills::Internal return rgba; } - // Hold a frame scope so ForceMidFrameFlush can complete the readback even when - // the unit-test host (or RAF path) already has the frame encoder open without - // any JS-held scopes (pendingFrameScopes must be > 0 for the handshake). - std::optional scope{ - m_graphicsContext.AcquireFrameCompletionScope()}; - bgfx::Encoder* encoder = m_graphicsContext.GetActiveEncoder(); if (encoder == nullptr) { @@ -934,12 +937,12 @@ namespace Babylon::Polyfills::Internal } // maxViews-1 is reserved for readback blits (see DeviceImpl::FlushViewsIfNeeded). - const bgfx::ViewId blitView = static_cast(bgfx::getCaps()->limits.maxViews - 1); - bgfx::TextureRegion dstRegion{}; - dstRegion.init(blitTexture, 0, 0, static_cast(width), static_cast(height)); - bgfx::TextureRegion srcRegion{}; - srcRegion.init(srcTexture, 0, 0, static_cast(width), static_cast(height)); - encoder->blit(blitView, dstRegion, srcRegion); + const bgfx::ViewId blitView = static_cast(bgfx::getCaps()->limits.maxViews - 1); + bgfx::TextureRegion dstRegion{}; + dstRegion.init(blitTexture, 0, 0, static_cast(width), static_cast(height)); + bgfx::TextureRegion srcRegion{}; + srcRegion.init(srcTexture, 0, 0, static_cast(width), static_cast(height)); + encoder->blit(blitView, dstRegion, srcRegion); std::mutex mutex; std::condition_variable cv; @@ -990,8 +993,6 @@ namespace Babylon::Polyfills::Internal } } - scope.reset(); - if (bgfx::isValid(blitTexture)) { bgfx::destroy(blitTexture); @@ -1166,6 +1167,7 @@ namespace Babylon::Polyfills::Internal { throw Napi::Error::New(env, "Context2D.putImageData: failed to create the source image."); } + RetainImageUntilFlush(imageIndex); // putImageData bypasses the transform, the clip region, globalAlpha and // the composite operation, and replaces the destination pixels outright. @@ -1182,7 +1184,6 @@ namespace Babylon::Polyfills::Internal nvgFill(*m_nvg); nvgRestore(*m_nvg); - nvgDeleteImage(*m_nvg, imageIndex); // Keep the CPU mirror that getImageData() reads from in sync. BlitPixelsToCpu(patch.data(), copyWidth, copyHeight, 0, 0, copyWidth, copyHeight, @@ -1222,9 +1223,11 @@ namespace Babylon::Polyfills::Internal } } - void Context::BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, int32_t sx, int32_t sy, uint32_t sw, uint32_t sh, int32_t dx, int32_t dy, uint32_t dw, uint32_t dh) + void Context::BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, double sx, double sy, double sw, double sh, double dx, double dy, double dw, double dh) { - if (src == nullptr || dw == 0 || dh == 0 || sw == 0 || sh == 0 || srcWidth == 0 || srcHeight == 0) + if (src == nullptr || dw <= 0.0 || dh <= 0.0 || sw <= 0.0 || sh <= 0.0 || srcWidth == 0 || srcHeight == 0 || + !std::isfinite(sx) || !std::isfinite(sy) || !std::isfinite(sw) || !std::isfinite(sh) || + !std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dw) || !std::isfinite(dh)) { return; } @@ -1235,37 +1238,62 @@ namespace Babylon::Polyfills::Internal return; } - // Clamp the iteration range to the destination rect's intersection with the canvas up - // front. The destination size is caller-controlled (and reaches us as an unsigned value, - // so a negative width wraps to ~4e9), and iterating the full rect just to reject every - // pixel would stall the JS thread. int64_t keeps dx/dy + dw/dh from overflowing. - const int64_t iBegin = std::max(0, -static_cast(dx)); - const int64_t iEnd = std::min(dw, static_cast(m_cpuWidth) - dx); - const int64_t jBegin = std::max(0, -static_cast(dy)); - const int64_t jEnd = std::min(dh, static_cast(m_cpuHeight) - dy); + const double destRight = dx + dw; + const double destBottom = dy + dh; + if (!std::isfinite(destRight) || !std::isfinite(destBottom)) + { + return; + } - for (int64_t j = jBegin; j < jEnd; ++j) + const double clippedLeft = std::max(0.0, dx); + const double clippedTop = std::max(0.0, dy); + const double clippedRight = std::min(static_cast(m_cpuWidth), destRight); + const double clippedBottom = std::min(static_cast(m_cpuHeight), destBottom); + if (clippedRight <= clippedLeft || clippedBottom <= clippedTop) { - // Nearest-neighbor sample of the source row (exact when dh == sh). - const int64_t srcY = sy + static_cast(j) * sh / dh; - if (srcY < 0 || srcY >= static_cast(srcHeight)) + return; + } + + const uint32_t xBegin = static_cast(std::floor(clippedLeft)); + const uint32_t yBegin = static_cast(std::floor(clippedTop)); + const uint32_t xEnd = std::min(m_cpuWidth, static_cast(std::ceil(clippedRight))); + const uint32_t yEnd = std::min(m_cpuHeight, static_cast(std::ceil(clippedBottom))); + + for (uint32_t destY = yBegin; destY < yEnd; ++destY) + { + const double pixelCenterY = static_cast(destY) + 0.5; + if (pixelCenterY < dy || pixelCenterY >= destBottom) { continue; } - const size_t destRow = static_cast(dy + j) * m_cpuWidth; + const double sourceY = sy + (pixelCenterY - dy) * sh / dh; + if (sourceY < 0.0 || sourceY >= static_cast(srcHeight)) + { + continue; + } + + const auto srcY = static_cast(sourceY); + const size_t destRow = static_cast(destY) * m_cpuWidth; const size_t srcRow = static_cast(srcY) * srcWidth; - for (int64_t i = iBegin; i < iEnd; ++i) + for (uint32_t destX = xBegin; destX < xEnd; ++destX) { - const int64_t srcX = sx + static_cast(i) * sw / dw; - if (srcX < 0 || srcX >= static_cast(srcWidth)) + const double pixelCenterX = static_cast(destX) + 0.5; + if (pixelCenterX < dx || pixelCenterX >= destRight) + { + continue; + } + + const double sourceX = sx + (pixelCenterX - dx) * sw / dw; + if (sourceX < 0.0 || sourceX >= static_cast(srcWidth)) { continue; } - const size_t srcIndex = (srcRow + static_cast(srcX)) * 4; - const size_t destIndex = (destRow + static_cast(dx + i)) * 4; + const auto srcX = static_cast(sourceX); + const size_t srcIndex = (srcRow + srcX) * 4; + const size_t destIndex = (destRow + destX) * 4; m_cpuPixels[destIndex + 0] = src[srcIndex + 0]; m_cpuPixels[destIndex + 1] = src[srcIndex + 1]; m_cpuPixels[destIndex + 2] = src[srcIndex + 2]; @@ -1381,8 +1409,12 @@ namespace Babylon::Polyfills::Internal } const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); + if (imageIndex == 0) + { + throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); + } + RetainImageUntilFlush(imageIndex); DrawImageCommon(info, imageIndex, rgba.data(), width, height); - nvgDeleteImage(*m_nvg, imageIndex); return; #else throw Napi::Error::New(info.Env(), "drawImage: image loading disabled in this build."); @@ -1418,8 +1450,12 @@ namespace Babylon::Polyfills::Internal } const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); + if (imageIndex == 0) + { + throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); + } + RetainImageUntilFlush(imageIndex); DrawImageCommon(info, imageIndex, rgba.data(), width, height); - nvgDeleteImage(*m_nvg, imageIndex); return; } @@ -1443,103 +1479,189 @@ namespace Babylon::Polyfills::Internal void Context::DrawImageCommon(const Napi::CallbackInfo& info, int imageIndex, const uint8_t* srcPixels, uint32_t srcWidth, uint32_t srcHeight) { - const auto imgWidth = static_cast(srcWidth); - const auto imgHeight = static_cast(srcHeight); - + double sx{0.0}; + double sy{0.0}; + double sWidth{static_cast(srcWidth)}; + double sHeight{static_cast(srcHeight)}; + double dx{}; + double dy{}; + double dWidth{sWidth}; + double dHeight{sHeight}; if (info.Length() == 3) { - const auto dx = static_cast(info[1].As().Int32Value()); - const auto dy = static_cast(info[2].As().Int32Value()); - - // Anchor the pattern at the destination, not the canvas origin: the - // rect below is drawn at (dx,dy), so a pattern based at (0,0) makes - // it sample from outside the image and clamp to the edge texels, - // which silently draws nothing for any dx/dy other than (0,0). - NVGpaint imagePaint = nvgImagePattern(*m_nvg, dx, dy, imgWidth, imgHeight, 0.f, imageIndex, 1.f); + dx = info[1].ToNumber().DoubleValue(); + dy = info[2].ToNumber().DoubleValue(); + } + else if (info.Length() == 5) + { + dx = info[1].ToNumber().DoubleValue(); + dy = info[2].ToNumber().DoubleValue(); + dWidth = info[3].ToNumber().DoubleValue(); + dHeight = info[4].ToNumber().DoubleValue(); + } + else if (info.Length() == 9) + { + sx = info[1].ToNumber().DoubleValue(); + sy = info[2].ToNumber().DoubleValue(); + sWidth = info[3].ToNumber().DoubleValue(); + sHeight = info[4].ToNumber().DoubleValue(); + dx = info[5].ToNumber().DoubleValue(); + dy = info[6].ToNumber().DoubleValue(); + dWidth = info[7].ToNumber().DoubleValue(); + dHeight = info[8].ToNumber().DoubleValue(); + } + else + { + throw Napi::Error::New(info.Env(), "Invalid number of parameters for DrawImage"); + } - // See FillRect: clipping is a scissor, so the path must always be reset. - ResetPathState(); + if (srcWidth == 0 || srcHeight == 0 || + !std::isfinite(sx) || !std::isfinite(sy) || !std::isfinite(sWidth) || !std::isfinite(sHeight) || + !std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dWidth) || !std::isfinite(dHeight)) + { + return; + } - nvgRect(*m_nvg, dx, dy, imgWidth, imgHeight); - nvgFillPaint(*m_nvg, imagePaint); - SetFilterStack(); - nvgFill(*m_nvg); + // Negative extents grow in the opposite direction without flipping the pixels. + if (sWidth < 0.0) + { + sx += sWidth; + sWidth = -sWidth; + } + if (sHeight < 0.0) + { + sy += sHeight; + sHeight = -sHeight; + } + if (dWidth < 0.0) + { + dx += dWidth; + dWidth = -dWidth; + } + if (dHeight < 0.0) + { + dy += dHeight; + dHeight = -dHeight; + } - BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, 0, 0, srcWidth, srcHeight, - static_cast(dx), static_cast(dy), srcWidth, srcHeight); + if (sWidth == 0.0 || sHeight == 0.0 || dWidth == 0.0 || dHeight == 0.0) + { + return; } - else if (info.Length() == 5) + + const double sourceRight = sx + sWidth; + const double sourceBottom = sy + sHeight; + if (!std::isfinite(sourceRight) || !std::isfinite(sourceBottom)) { - const auto dxInt = info[1].As().Int32Value(); - const auto dyInt = info[2].As().Int32Value(); - const auto dWidthInt = info[3].As().Int32Value(); - const auto dHeightInt = info[4].As().Int32Value(); + return; + } - // Parse the extents as signed: read via Uint32Value, a negative width wraps to ~4e9, - // which would queue an enormous nvgRect. A non-positive extent draws nothing. - if (dWidthInt <= 0 || dHeightInt <= 0) - { - return; - } + const double clippedSx = std::max(0.0, sx); + const double clippedSy = std::max(0.0, sy); + const double clippedSourceRight = std::min(static_cast(srcWidth), sourceRight); + const double clippedSourceBottom = std::min(static_cast(srcHeight), sourceBottom); + const double clippedSWidth = clippedSourceRight - clippedSx; + const double clippedSHeight = clippedSourceBottom - clippedSy; + if (clippedSWidth <= 0.0 || clippedSHeight <= 0.0) + { + return; + } - const auto dx = static_cast(dxInt); - const auto dy = static_cast(dyInt); - const auto dWidth = static_cast(dWidthInt); - const auto dHeight = static_cast(dHeightInt); + const double scaleX = dWidth / sWidth; + const double scaleY = dHeight / sHeight; + dx += (clippedSx - sx) * scaleX; + dy += (clippedSy - sy) * scaleY; + dWidth = clippedSWidth * scaleX; + dHeight = clippedSHeight * scaleY; + sx = clippedSx; + sy = clippedSy; + sWidth = clippedSWidth; + sHeight = clippedSHeight; + + // Map the full source image into paint space, then fill only the clipped + // destination rectangle. This crops through UVs without changing the current + // transform, scissor/clip, or global alpha. + const double patternX = dx - sx * scaleX; + const double patternY = dy - sy * scaleY; + const double patternWidth = static_cast(srcWidth) * scaleX; + const double patternHeight = static_cast(srcHeight) * scaleY; + const double floatLimit = std::numeric_limits::max(); + const auto fitsFloat = [floatLimit](double value) { + return std::isfinite(value) && value >= -floatLimit && value <= floatLimit; + }; + if (!fitsFloat(dx) || !fitsFloat(dy) || !fitsFloat(dWidth) || !fitsFloat(dHeight) || + !fitsFloat(patternX) || !fitsFloat(patternY) || !fitsFloat(patternWidth) || !fitsFloat(patternHeight)) + { + return; + } - NVGpaint imagePaint = nvgImagePattern(*m_nvg, dx, dy, dWidth, dHeight, 0.f, imageIndex, 1.f); + const float drawX = static_cast(dx); + const float drawY = static_cast(dy); + const float drawWidth = static_cast(dWidth); + const float drawHeight = static_cast(dHeight); + const float paintWidth = static_cast(patternWidth); + const float paintHeight = static_cast(patternHeight); + if (drawWidth <= 0.f || drawHeight <= 0.f || paintWidth <= 0.f || paintHeight <= 0.f) + { + return; + } - // See FillRect: clipping is a scissor, so the path must always be reset. - ResetPathState(); + NVGpaint imagePaint = nvgImagePattern( + *m_nvg, + static_cast(patternX), + static_cast(patternY), + paintWidth, + paintHeight, + 0.f, + imageIndex, + 1.f); - nvgRect(*m_nvg, dx, dy, dWidth, dHeight); + if (m_isClipped) + { + // The current path is the emulated non-rectangular clip. Restrict it to + // the destination rectangle with a temporary scissor instead of appending + // the rectangle to the path, which would fill and retain their union. + nvgSave(*m_nvg); + nvgIntersectScissor(*m_nvg, drawX, drawY, drawWidth, drawHeight); nvgFillPaint(*m_nvg, imagePaint); SetFilterStack(); nvgFill(*m_nvg); - - BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, 0, 0, srcWidth, srcHeight, - dxInt, dyInt, static_cast(dWidthInt), static_cast(dHeightInt)); + nvgRestore(*m_nvg); } - else if (info.Length() == 9) + else { - const auto sx = info[1].As().Int32Value(); - const auto sy = info[2].As().Int32Value(); - const auto sWidthInt = info[3].As().Int32Value(); - const auto sHeightInt = info[4].As().Int32Value(); - const auto dxInt = info[5].As().Int32Value(); - const auto dyInt = info[6].As().Int32Value(); - const auto dWidthInt = info[7].As().Int32Value(); - const auto dHeightInt = info[8].As().Int32Value(); - - if (sWidthInt <= 0 || sHeightInt <= 0 || dWidthInt <= 0 || dHeightInt <= 0) - { - return; - } - - const auto sWidth = static_cast(sWidthInt); - const auto sHeight = static_cast(sHeightInt); - const auto dx = static_cast(dxInt); - const auto dy = static_cast(dyInt); - const auto dWidth = static_cast(dWidthInt); - const auto dHeight = static_cast(dHeightInt); - - NVGpaint imagePaint = nvgImagePattern(*m_nvg, dx, dy, dWidth, dHeight, 0.f, imageIndex, 1.f); - - // See FillRect: clipping is a scissor, so the path must always be reset. + // Rectangular clips live in NanoVG's scissor state and survive resetting + // the temporary draw path. Keep the wrapper's path flags in sync as well. ResetPathState(); - - nvgRect(*m_nvg, dx, dy, dWidth, dHeight); + nvgRect(*m_nvg, drawX, drawY, drawWidth, drawHeight); nvgFillPaint(*m_nvg, imagePaint); SetFilterStack(); nvgFill(*m_nvg); + } + + BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); + } - BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, sx, sy, sWidth, sHeight, - dxInt, dyInt, static_cast(dWidthInt), static_cast(dHeightInt)); + void Context::RetainImageUntilFlush(int imageIndex) + { + try + { + m_imagesPendingFlush.push_back(imageIndex); } - else + catch (...) { - throw Napi::Error::New(info.Env(), "Invalid number of parameters for DrawImage"); + nvgDeleteImage(*m_nvg, imageIndex); + throw; + } + } + + void Context::ReleaseImagesAfterFlush() + { + for (const int imageIndex : m_imagesPendingFlush) + { + nvgDeleteImage(*m_nvg, imageIndex); } + m_imagesPendingFlush.clear(); } Napi::Value Context::CreateImageData(const Napi::CallbackInfo& info) diff --git a/Polyfills/Canvas/Source/Context.h b/Polyfills/Canvas/Source/Context.h index 5de25be830..83daa5104a 100644 --- a/Polyfills/Canvas/Source/Context.h +++ b/Polyfills/Canvas/Source/Context.h @@ -33,15 +33,15 @@ namespace Babylon::Polyfills::Internal // Out-of-range pixels are written as zero. Used to implement getImageData without a GPU readback. void ReadPixels(int32_t sx, int32_t sy, uint32_t w, uint32_t h, uint8_t* dst); - // Flush pending NanoVG draws to the canvas framebuffer, then read the full - // RGBA8 contents back from the GPU. Used by toDataURL and drawImage(canvas). - std::vector CaptureRGBA(); + // Flush pending NanoVG draws to the canvas framebuffer, then read the full + // RGBA8 contents back from the GPU. Used by toDataURL and drawImage(canvas). + std::vector CaptureRGBA(); - // Called by NativeCanvas when the canvas wrapper is destroyed so this context - // does not keep a dangling m_canvas pointer across JS finalizer ordering. - void DetachCanvas(); + // Called by NativeCanvas when the canvas wrapper is destroyed so this context + // does not keep a dangling m_canvas pointer across JS finalizer ordering. + void DetachCanvas(); - private: + private: void FillRect(const Napi::CallbackInfo&); Napi::Value MeasureText(const Napi::CallbackInfo&); void FillText(const Napi::CallbackInfo&); @@ -111,10 +111,12 @@ namespace Babylon::Polyfills::Internal bool SetFontFaceId(); void EnsureFontsLoaded(); void Flush(const Napi::CallbackInfo&); - // Shared body of Flush() for C++ callers (CaptureRGBA). Throws std::exception on failure. - void FlushCore(); + // Shared body of Flush() for C++ callers (CaptureRGBA). Throws std::exception on failure. + void FlushCore(); + void RetainImageUntilFlush(int imageIndex); + void ReleaseImagesAfterFlush(); - NativeCanvas* m_canvas; + NativeCanvas* m_canvas; std::shared_ptr m_nvg; // A gradient style holds the assigned JavaScript object, not a bare CanvasGradient*. @@ -194,6 +196,9 @@ namespace Babylon::Polyfills::Internal JsRuntimeScheduler m_runtimeScheduler; std::unordered_map m_nvgImageIndices; + // Transient ImageData/canvas snapshots stay alive until nvgEndFrame has + // consumed every queued draw that references them. + std::vector m_imagesPendingFlush; void BindFillStyle(const Napi::CallbackInfo& info); void BindStrokeStyle(const Napi::CallbackInfo& info); void FlushGraphicResources() override; @@ -215,7 +220,7 @@ namespace Babylon::Polyfills::Internal uint32_t m_cpuHeight{0}; void EnsureCpuBuffer(); // Core RGBA8 blit used by both the NativeCanvasImage and (plain) ImageBitmap drawImage paths. - void BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, int32_t sx, int32_t sy, uint32_t sw, uint32_t sh, int32_t dx, int32_t dy, uint32_t dw, uint32_t dh); + void BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, double sx, double sy, double sw, double sh, double dx, double dy, double dw, double dh); // Shared drawImage body: draws the nanovg image (arity 3/5/9) and mirrors it to the CPU buffer. void DrawImageCommon(const Napi::CallbackInfo& info, int imageIndex, const uint8_t* srcPixels, uint32_t srcWidth, uint32_t srcHeight); diff --git a/Polyfills/Canvas/Source/MeasureText.cpp b/Polyfills/Canvas/Source/MeasureText.cpp index bc31bfa887..8465e626e8 100644 --- a/Polyfills/Canvas/Source/MeasureText.cpp +++ b/Polyfills/Canvas/Source/MeasureText.cpp @@ -19,28 +19,28 @@ namespace Babylon::Polyfills::Internal { Napi::Value MeasureText::CreateInstance(Napi::Env env, Context* context, const std::string& text) { - // Ink extents (not the full line box): nvgTextBounds replaces ymin/ymax with - // fonsLineBounds, which would make height/actualBoundingBox* the em line box. - float bounds[4] = {0, 0, 0, 0}; - const float advance = nvgTextBoundsInk(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); + // Ink extents (not the full line box): nvgTextBounds replaces ymin/ymax with + // fonsLineBounds, which would make height/actualBoundingBox* the em line box. + float bounds[4] = {0, 0, 0, 0}; + const float advance = nvgTextBoundsInk(context->GetNVGContext(), 0, 0, text.c_str(), nullptr, bounds); float textMetrics[3] = {0, 0, 0}; nvgTextMetrics(context->GetNVGContext(), &textMetrics[0], &textMetrics[1], &textMetrics[2]); - // CSS TextMetrics: positive ascent is above the alignment baseline (y=0 here). - const float inkAscent = bounds[1] < 0.f ? -bounds[1] : 0.f; - const float inkDescent = bounds[3] > 0.f ? bounds[3] : 0.f; + // CSS TextMetrics: positive ascent is above the alignment baseline (y=0 here). + const float inkAscent = bounds[1] < 0.f ? -bounds[1] : 0.f; + const float inkDescent = bounds[3] > 0.f ? bounds[3] : 0.f; - auto obj{Napi::Object::New(env)}; - obj.Set("width", Napi::Value::From(env, advance)); - obj.Set("height", Napi::Value::From(env, bounds[3] - bounds[1])); - // actualBoundingBoxLeft is positive left of the alignment point. - obj.Set("actualBoundingBoxLeft", Napi::Value::From(env, -bounds[0])); - obj.Set("actualBoundingBoxRight", Napi::Value::From(env, bounds[2])); - obj.Set("actualBoundingBoxAscent", Napi::Value::From(env, inkAscent)); - obj.Set("actualBoundingBoxDescent", Napi::Value::From(env, inkDescent)); - obj.Set("fontBoundingBoxAscent", Napi::Value::From(env, textMetrics[0])); - obj.Set("fontBoundingBoxDescent", Napi::Value::From(env, -textMetrics[1])); + auto obj{Napi::Object::New(env)}; + obj.Set("width", Napi::Value::From(env, advance)); + obj.Set("height", Napi::Value::From(env, bounds[3] - bounds[1])); + // actualBoundingBoxLeft is positive left of the alignment point. + obj.Set("actualBoundingBoxLeft", Napi::Value::From(env, -bounds[0])); + obj.Set("actualBoundingBoxRight", Napi::Value::From(env, bounds[2])); + obj.Set("actualBoundingBoxAscent", Napi::Value::From(env, inkAscent)); + obj.Set("actualBoundingBoxDescent", Napi::Value::From(env, inkDescent)); + obj.Set("fontBoundingBoxAscent", Napi::Value::From(env, textMetrics[0])); + obj.Set("fontBoundingBoxDescent", Napi::Value::From(env, -textMetrics[1])); - return obj.As(); + return obj.As(); } } diff --git a/Polyfills/Canvas/Source/nanovg/fontstash.h b/Polyfills/Canvas/Source/nanovg/fontstash.h index 171229c723..c3b6edd6e8 100644 --- a/Polyfills/Canvas/Source/nanovg/fontstash.h +++ b/Polyfills/Canvas/Source/nanovg/fontstash.h @@ -995,7 +995,7 @@ int fonsAddFontMem(FONScontext* stash, const char* name, unsigned char* data, in // Store normalized line height. The real line height is got // by multiplying the lineh by font size. // Normalize metrics against em square (matches pixel height scale). - fons__tt_getFontVMetrics( &font->font, &ascent, &descent, &lineGap); + fons__tt_getFontVMetrics( &font->font, &ascent, &descent, &lineGap); fh = ascent - descent; em = fons__tt_getEmUnits(&font->font); if (em <= 0.0f) em = (float)fh; @@ -1531,8 +1531,8 @@ void fonsDrawDebug(FONScontext* stash, float x, float y) fons__flush(stash); } -// Atlas SDF padding; subtracted from reported ink bounds. -#ifdef FONS_SDF_PADDING +// Atlas SDF padding; interpolation/blur padding is stored on each glyph. +#if defined(FONS_SDF_PADDING) && !defined(FONS_USE_FREETYPE) #define FONS_TEXT_BOUNDS_PADDING ((float)FONS_SDF_PADDING) #else #define FONS_TEXT_BOUNDS_PADDING 0.0f @@ -1568,7 +1568,7 @@ float fonsTextBounds(FONScontext* stash, y += fons__getVertAlign(stash, font, state->align, isize); // Init bounds from pen only as empty-run fallback; extents come from glyphs (LSB). - minx = maxx = x; + minx = maxx = x; miny = maxy = y; startx = x; @@ -1581,26 +1581,27 @@ float fonsTextBounds(FONScontext* stash, glyph = fons__getGlyph(stash, font, codepoint, isize, iblur, FONS_GLYPH_BITMAP_OPTIONAL); if (glyph != NULL) { float qx0, qx1, qy0, qy1; + const float padding = FONS_TEXT_BOUNDS_PADDING + (float)glyph->blur + 1.0f; fons__getQuad(stash, font, prevGlyphIndex, glyph, scale, state->spacing, &x, &y, &q); - // Drop atlas SDF padding from ink extents. - qx0 = q.x0 + FONS_TEXT_BOUNDS_PADDING; - qx1 = q.x1 - FONS_TEXT_BOUNDS_PADDING; + // getQuad removes one of getGlyph's (blur + 2) border pixels. + qx0 = q.x0 + padding; + qx1 = q.x1 - padding; if (qx1 < qx0) qx0 = qx1 = (q.x0 + q.x1) * 0.5f; if (!hasGlyph) { minx = qx0; maxx = qx1; } if (qx0 < minx) minx = qx0; if (qx1 > maxx) maxx = qx1; if (stash->params.flags & FONS_ZERO_TOPLEFT) { // q.y0 is the top edge and q.y1 the bottom edge. - qy0 = q.y0 + FONS_TEXT_BOUNDS_PADDING; - qy1 = q.y1 - FONS_TEXT_BOUNDS_PADDING; + qy0 = q.y0 + padding; + qy1 = q.y1 - padding; if (qy1 < qy0) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; if (!hasGlyph) { miny = qy0; maxy = qy1; } if (qy0 < miny) miny = qy0; if (qy1 > maxy) maxy = qy1; } else { // y grows upwards: q.y0 is the top edge and q.y1 the bottom edge. - qy0 = q.y0 - FONS_TEXT_BOUNDS_PADDING; - qy1 = q.y1 + FONS_TEXT_BOUNDS_PADDING; + qy0 = q.y0 - padding; + qy1 = q.y1 + padding; if (qy0 < qy1) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; if (!hasGlyph) { miny = qy1; maxy = qy0; } if (qy1 < miny) miny = qy1; diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp index 0727795ff0..9a19aaa873 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp @@ -57,7 +57,7 @@ void nanovg_filterstack::InitBgfx() m_uniforms.u_direction = bgfx::createUniform("u_direction", bgfx::UniformType::Vec4); m_uniforms.u_weights = bgfx::createUniform("u_weights", bgfx::UniformType::Vec4, BLUR_UNIFORM_SIZE); - bgfx::RendererType::Enum type = bgfx::getRendererType(); + bgfx::RendererType::Enum type = bgfx::getRendererType(); s_gaussBlurProg = bgfx::createProgram( bgfx::createEmbeddedShader(s_embeddedShadersFilterStack, type, "vs_fspass") , bgfx::createEmbeddedShader(s_embeddedShadersFilterStack, type, "fs_gaussblur") @@ -80,7 +80,7 @@ void nanovg_filterstack::DisposeBgfx() return; } - if (bgfx::isValid(m_uniforms.u_strength)) + if (bgfx::isValid(m_uniforms.u_strength)) { bgfx::destroy(m_uniforms.u_strength); m_uniforms.u_strength = BGFX_INVALID_HANDLE; From ba8ec747b53c3a51e33f36099aa57e955f038f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Mon, 14 Sep 2026 16:11:40 -0700 Subject: [PATCH 10/16] Fix Canvas readback and text metric edge cases Report unserviceable mid-frame flushes, keep pending readback resources alive, separate NanoVG image IDs from bgfx handles, prioritize Canvas branding, and preserve signed and zero-ink text bounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- .../JavaScript/dist/tests.javaScript.all.js | 28 ++++ .../JavaScript/src/tests.javaScript.all.ts | 28 ++++ .../Source/Tests.Canvas.TextMetrics.cpp | 29 ++++ .../Babylon/Graphics/DeviceContext.h | 3 +- Core/Graphics/Source/DeviceContext.cpp | 4 +- Core/Graphics/Source/DeviceImpl.cpp | 7 +- Core/Graphics/Source/DeviceImpl.h | 4 +- Polyfills/Canvas/Source/Context.cpp | 143 ++++++++++-------- Polyfills/Canvas/Source/MeasureText.cpp | 7 +- Polyfills/Canvas/Source/nanovg/fontstash.h | 31 ++-- .../Canvas/Source/nanovg/nanovg_babylon.cpp | 18 ++- 11 files changed, 211 insertions(+), 91 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index cbc19219ab..b1910ac565 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28795,6 +28795,34 @@ describe("Canvas2D", function () { } }); + it("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#20c060"; + source.context.fillRect(0, 0, 8, 8); + Object.defineProperty(source.canvas, "data", { + value: new Uint8Array(8 * 8 * 4) + }); + + destination.context.drawImage(source.canvas, 0, 0); + + var sample = pixelAt( + captureGpuPixels(destination.canvas), + destination.canvas.width, + 4, + 4 + ); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[0]).to.be.within(15, 50); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[1]).to.be.within(175, 210); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[2]).to.be.within(75, 115); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(sample[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + it("applies fractional source crops and destination rectangles on the GPU", function () { var source = createCanvas(12, 8); var destination = createCanvas(30, 22); diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index c610087728..7146a00eee 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -564,6 +564,34 @@ describe("Canvas2D", function () { } }); + it("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#20c060"; + source.context.fillRect(0, 0, 8, 8); + Object.defineProperty(source.canvas, "data", { + value: new Uint8Array(8 * 8 * 4), + }); + + destination.context.drawImage(source.canvas, 0, 0); + + const sample = pixelAt( + captureGpuPixels(destination.canvas), + destination.canvas.width, + 4, + 4 + ); + expect(sample[0]).to.be.within(15, 50); + expect(sample[1]).to.be.within(175, 210); + expect(sample[2]).to.be.within(75, 115); + expect(sample[3]).to.be.greaterThan(240); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + it("applies fractional source crops and destination rectangles on the GPU", function () { const source = createCanvas(12, 8); const destination = createCanvas(30, 22); diff --git a/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp index c04a235da5..19a95b61ba 100644 --- a/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp +++ b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp @@ -60,6 +60,35 @@ TEST(CanvasTextMetrics, ExcludesAtlasBorderAndBlurPaddingFromInkBounds) EXPECT_FLOAT_EQ(advance, unblurredAdvance); } } + } } } + +TEST(CanvasTextMetrics, WhitespaceAdvancesWithoutContributingInkBounds) +{ + std::ifstream file{CANVAS_TEST_FONT_PATH, std::ios::binary}; + ASSERT_TRUE(file.is_open()); + std::vector bytes{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; + ASSERT_FALSE(bytes.empty()); + + for (const auto flags : {FONS_ZERO_TOPLEFT, FONS_ZERO_BOTTOMLEFT}) + { + FONSparams params{}; + params.width = 512; + params.height = 512; + params.flags = static_cast(flags); + std::unique_ptr stash{fonsCreateInternal(¶ms), fonsDeleteInternal}; + ASSERT_NE(stash, nullptr); + const int face = fonsAddFontMem(stash.get(), "test", bytes.data(), static_cast(bytes.size()), 0); + ASSERT_NE(face, FONS_INVALID); + fonsSetFont(stash.get(), face); + fonsSetAlign(stash.get(), FONS_ALIGN_LEFT | FONS_ALIGN_BASELINE); + fonsSetSize(stash.get(), 48.0f); + + std::array bounds{}; + const float advance = fonsTextBounds(stash.get(), 100.0f, 100.0f, " ", nullptr, bounds.data()); + EXPECT_GT(advance, 0.0f); + EXPECT_EQ(bounds, (std::array{100.0f, 100.0f, 100.0f, 100.0f})); + } +} diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h index eb7f438825..bc5e15411d 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h @@ -131,7 +131,8 @@ namespace Babylon::Graphics // Force a mid-frame flush when a FrameCompletionScope is held so pending // bgfx::readTexture requests can complete (Canvas toDataURL / drawImage). - void ForceMidFrameFlush(); + // Returns false when the render thread cannot service the request. + bool ForceMidFrameFlush(); // TODO: find a different way to get the texture info for frame capture void AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format); diff --git a/Core/Graphics/Source/DeviceContext.cpp b/Core/Graphics/Source/DeviceContext.cpp index e7b0e7d357..ca4ac320f1 100644 --- a/Core/Graphics/Source/DeviceContext.cpp +++ b/Core/Graphics/Source/DeviceContext.cpp @@ -147,9 +147,9 @@ namespace Babylon::Graphics m_graphicsImpl.FlushViewsIfNeeded(); } - void DeviceContext::ForceMidFrameFlush() + bool DeviceContext::ForceMidFrameFlush() { - m_graphicsImpl.ForceMidFrameFlush(); + return m_graphicsImpl.ForceMidFrameFlush(); } void DeviceContext::AddTexture(bgfx::TextureHandle handle, uint16_t width, uint16_t height, bool hasMips, uint16_t numLayers, bgfx::TextureFormat::Enum format) diff --git a/Core/Graphics/Source/DeviceImpl.cpp b/Core/Graphics/Source/DeviceImpl.cpp index 069d2843e2..80011dc0c2 100644 --- a/Core/Graphics/Source/DeviceImpl.cpp +++ b/Core/Graphics/Source/DeviceImpl.cpp @@ -591,7 +591,7 @@ namespace Babylon::Graphics ForceMidFrameFlush(); } - void DeviceImpl::ForceMidFrameFlush() + bool DeviceImpl::ForceMidFrameFlush() { // The flush advances a bgfx frame, which must happen on the render (bgfx API) // thread. This method is only expected to be called from the JS thread while @@ -599,7 +599,7 @@ namespace Babylon::Graphics // render thread (or affinity is unset), there is nothing safe to do here. if (m_renderThreadAffinity.check()) { - return; + return false; } std::unique_lock lock{m_frameSyncMutex}; @@ -617,12 +617,13 @@ namespace Babylon::Graphics // is currently closed (bgfx::frame() in progress). if (m_frameBlocked || m_pendingFrameScopes == 0) { - return; + return false; } m_flushRequested = true; m_frameSyncCV.notify_all(); m_flushCompleteCV.wait(lock, [this] { return !m_flushRequested; }); + return true; } // Called on the render thread from FinishRenderingCurrentFrame while holding diff --git a/Core/Graphics/Source/DeviceImpl.h b/Core/Graphics/Source/DeviceImpl.h index 4b18a716fe..3bc34ba971 100644 --- a/Core/Graphics/Source/DeviceImpl.h +++ b/Core/Graphics/Source/DeviceImpl.h @@ -107,8 +107,8 @@ namespace Babylon::Graphics // Unconditionally request a mid-frame bgfx flush when a FrameCompletionScope // is active (same handshake as FlushViewsIfNeeded). Used by Canvas GPU // readback so bgfx::readTexture can complete without waiting for the end of - // the logical frame. No-ops when the render thread cannot service it. - void ForceMidFrameFlush(); + // the logical frame. Returns false when the render thread cannot service it. + bool ForceMidFrameFlush(); // Frame completion scope support void IncrementPendingFrameScopes(); diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 5cbcd78ffb..52334f12fe 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -944,23 +945,43 @@ namespace Babylon::Polyfills::Internal srcRegion.init(srcTexture, 0, 0, static_cast(width), static_cast(height)); encoder->blit(blitView, dstRegion, srcRegion); - std::mutex mutex; - std::condition_variable cv; - bool done = false; - std::exception_ptr error; + struct ReadbackState + { + explicit ReadbackState(std::vector&& pixels, bgfx::TextureHandle texture) + : Pixels(std::move(pixels)) + , Texture(texture) + { + } - m_graphicsContext.ReadTextureAsync(blitTexture, rgba) + ~ReadbackState() + { + if (bgfx::isValid(Texture)) + { + bgfx::destroy(Texture); + } + } + + std::vector Pixels; + bgfx::TextureHandle Texture; + std::mutex Mutex; + std::condition_variable Cv; + bool Done{}; + std::exception_ptr Error; + }; + + auto readback = std::make_shared(std::move(rgba), blitTexture); + m_graphicsContext.ReadTextureAsync(blitTexture, readback->Pixels) .then(arcana::inline_scheduler, arcana::cancellation::none(), - [&](const arcana::expected& result) { + [readback](const arcana::expected& result) { { - std::lock_guard lock{mutex}; + std::lock_guard lock{readback->Mutex}; if (result.has_error()) { - error = result.error(); + readback->Error = result.error(); } - done = true; + readback->Done = true; } - cv.notify_one(); + readback->Cv.notify_one(); }); // Drive mid-frame flushes until the readback completes. Each flush ends the @@ -974,34 +995,33 @@ namespace Babylon::Polyfills::Internal for (;;) { { - std::lock_guard lock{mutex}; - if (done) + std::lock_guard lock{readback->Mutex}; + if (readback->Done) { break; } } - m_graphicsContext.ForceMidFrameFlush(); + if (!m_graphicsContext.ForceMidFrameFlush()) + { + throw std::runtime_error{"Canvas.CaptureRGBA: the render thread cannot service GPU readback."}; + } { - std::unique_lock lock{mutex}; - if (done) + std::unique_lock lock{readback->Mutex}; + if (readback->Done) { break; } // Yield briefly so a render thread that is between Finish/Start // (unit-test pump) can enter Finish and service flushRequested. - cv.wait_for(lock, std::chrono::milliseconds{1}, [&] { return done; }); + readback->Cv.wait_for(lock, std::chrono::milliseconds{1}, [&] { return readback->Done; }); } } - if (bgfx::isValid(blitTexture)) - { - bgfx::destroy(blitTexture); - } - - if (error) + if (readback->Error) { - std::rethrow_exception(error); + std::rethrow_exception(readback->Error); } + rgba = std::move(readback->Pixels); if (bgfx::getCaps()->originBottomLeft && height > 1) { @@ -1346,6 +1366,45 @@ namespace Babylon::Polyfills::Internal { Napi::Object imageObj = info[0].As(); + // Check the nominal Canvas type before the structural ImageBitmap shape. + // Canvas objects are extensible, so user code may legitimately add a typed + // `data` property without changing the object passed to drawImage. + const auto canvasCtorVal = JsRuntime::NativeObject::GetFromJavaScript(info.Env()).Get("Canvas"); + if (canvasCtorVal.IsFunction() && imageObj.InstanceOf(canvasCtorVal.As())) + { + NativeCanvas* const srcCanvas = NativeCanvas::Unwrap(imageObj); + const uint32_t width = srcCanvas->GetWidth(); + const uint32_t height = srcCanvas->GetHeight(); + if (width == 0 || height == 0) + { + return; + } + + // GPU readback of the source canvas (flush + RT blit + readTexture) so + // NanoVG draws (fillRect/text/paths) are included — not just the CPU mirror. + std::vector rgba(static_cast(width) * height * 4, 0); + if (Context* const srcContext = srcCanvas->GetBoundContext(); srcContext != nullptr) + { + try + { + rgba = srcContext->CaptureRGBA(); + } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } + } + + const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); + if (imageIndex == 0) + { + throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); + } + RetainImageUntilFlush(imageIndex); + DrawImageCommon(info, imageIndex, rgba.data(), width, height); + return; + } + // NativeEngine.createImageBitmap() returns a plain object carrying the raw decoded pixels // ({data, width, height, format}) rather than a wrapped NativeCanvasImage. Because Babylon // Native sets forceBitmapOverHTMLImageElement, LoadImage delivers these bitmaps to drawImage @@ -1421,44 +1480,6 @@ namespace Babylon::Polyfills::Internal #endif } - // drawImage(canvas): InstanceOf gate then Unwrap (real canvases only; deliberate - // prototype spoofs are out of scope — see #1844 / JsRuntimeHost type tags). - const auto canvasCtorVal = JsRuntime::NativeObject::GetFromJavaScript(info.Env()).Get("Canvas"); - if (canvasCtorVal.IsFunction() && imageObj.InstanceOf(canvasCtorVal.As())) - { - NativeCanvas* const srcCanvas = NativeCanvas::Unwrap(imageObj); - const uint32_t width = srcCanvas->GetWidth(); - const uint32_t height = srcCanvas->GetHeight(); - if (width == 0 || height == 0) - { - return; - } - - // GPU readback of the source canvas (flush + RT blit + readTexture) so - // NanoVG draws (fillRect/text/paths) are included — not just the CPU mirror. - std::vector rgba(static_cast(width) * height * 4, 0); - if (Context* const srcContext = srcCanvas->GetBoundContext(); srcContext != nullptr) - { - try - { - rgba = srcContext->CaptureRGBA(); - } - catch (const std::exception& ex) - { - throw Napi::Error::New(info.Env(), ex.what()); - } - } - - const int imageIndex = nvgCreateImageRGBA(*m_nvg, static_cast(width), static_cast(height), 0, rgba.data()); - if (imageIndex == 0) - { - throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); - } - RetainImageUntilFlush(imageIndex); - DrawImageCommon(info, imageIndex, rgba.data(), width, height); - return; - } - const NativeCanvasImage* canvasImage = NativeCanvasImage::Unwrap(imageObj); int imageIndex{-1}; diff --git a/Polyfills/Canvas/Source/MeasureText.cpp b/Polyfills/Canvas/Source/MeasureText.cpp index 8465e626e8..dc7d782ab2 100644 --- a/Polyfills/Canvas/Source/MeasureText.cpp +++ b/Polyfills/Canvas/Source/MeasureText.cpp @@ -26,9 +26,10 @@ namespace Babylon::Polyfills::Internal float textMetrics[3] = {0, 0, 0}; nvgTextMetrics(context->GetNVGContext(), &textMetrics[0], &textMetrics[1], &textMetrics[2]); - // CSS TextMetrics: positive ascent is above the alignment baseline (y=0 here). - const float inkAscent = bounds[1] < 0.f ? -bounds[1] : 0.f; - const float inkDescent = bounds[3] > 0.f ? bounds[3] : 0.f; + // CSS TextMetrics distances are signed when all ink lies on the opposite side + // of the alignment baseline. + const float inkAscent = -bounds[1]; + const float inkDescent = bounds[3]; auto obj{Napi::Object::New(env)}; obj.Set("width", Napi::Value::From(env, advance)); diff --git a/Polyfills/Canvas/Source/nanovg/fontstash.h b/Polyfills/Canvas/Source/nanovg/fontstash.h index c3b6edd6e8..cd78be0dd0 100644 --- a/Polyfills/Canvas/Source/nanovg/fontstash.h +++ b/Polyfills/Canvas/Source/nanovg/fontstash.h @@ -1555,7 +1555,7 @@ float fonsTextBounds(FONScontext* stash, FONSfont* font; float startx, advance; float minx, miny, maxx, maxy; - int hasGlyph = 0; + int hasInk = 0; if (stash == NULL) return 0; if (state->font < 0 || state->font >= stash->nfonts) return 0; @@ -1586,28 +1586,31 @@ float fonsTextBounds(FONScontext* stash, // getQuad removes one of getGlyph's (blur + 2) border pixels. qx0 = q.x0 + padding; qx1 = q.x1 - padding; - if (qx1 < qx0) qx0 = qx1 = (q.x0 + q.x1) * 0.5f; - if (!hasGlyph) { minx = qx0; maxx = qx1; } - if (qx0 < minx) minx = qx0; - if (qx1 > maxx) maxx = qx1; if (stash->params.flags & FONS_ZERO_TOPLEFT) { // q.y0 is the top edge and q.y1 the bottom edge. qy0 = q.y0 + padding; qy1 = q.y1 - padding; - if (qy1 < qy0) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; - if (!hasGlyph) { miny = qy0; maxy = qy1; } - if (qy0 < miny) miny = qy0; - if (qy1 > maxy) maxy = qy1; + if (qx1 > qx0 && qy1 > qy0) { + if (!hasInk) { minx = qx0; maxx = qx1; miny = qy0; maxy = qy1; } + if (qx0 < minx) minx = qx0; + if (qx1 > maxx) maxx = qx1; + if (qy0 < miny) miny = qy0; + if (qy1 > maxy) maxy = qy1; + hasInk = 1; + } } else { // y grows upwards: q.y0 is the top edge and q.y1 the bottom edge. qy0 = q.y0 - padding; qy1 = q.y1 + padding; - if (qy0 < qy1) qy0 = qy1 = (q.y0 + q.y1) * 0.5f; - if (!hasGlyph) { miny = qy1; maxy = qy0; } - if (qy1 < miny) miny = qy1; - if (qy0 > maxy) maxy = qy0; + if (qx1 > qx0 && qy0 > qy1) { + if (!hasInk) { minx = qx0; maxx = qx1; miny = qy1; maxy = qy0; } + if (qx0 < minx) minx = qx0; + if (qx1 > maxx) maxx = qx1; + if (qy1 < miny) miny = qy1; + if (qy0 > maxy) maxy = qy0; + hasInk = 1; + } } - hasGlyph = 1; } prevGlyphIndex = glyph != NULL ? glyph->index : -1; } diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index e17878d6bd..63388f07c8 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -158,6 +158,7 @@ namespace struct GLNVGtexture { + int image; bgfx::TextureHandle id; int width, height; int type; @@ -332,7 +333,7 @@ namespace int i; for (i = 0; i < gl->ntextures; i++) { - if (gl->textures[i].id.idx == id) + if (gl->textures[i].image == id) { return &gl->textures[i]; } @@ -345,7 +346,7 @@ namespace { for (int ii = 0; ii < gl->ntextures; ii++) { - if (gl->textures[ii].id.idx == id) + if (gl->textures[ii].image == id) { if (bgfx::isValid(gl->textures[ii].id) && (gl->textures[ii].flags & NVG_IMAGE_NODELETE) == 0) @@ -449,7 +450,13 @@ namespace ); } - return bgfx::isValid(tex->id) ? tex->id.idx : 0; + if (!bgfx::isValid(tex->id)) + { + return 0; + } + + tex->image = ++gl->textureId; + return tex->image; } static int nvgRenderDeleteTexture(void* _userPtr, int image) @@ -1514,7 +1521,7 @@ bgfx::TextureHandle nvglImageHandle(NVGcontext* _ctx, int32_t _image) { GLNVGcontext* gl = (GLNVGcontext*)nvgInternalParams(_ctx)->userPtr; GLNVGtexture* tex = glnvg__findTexture(gl, _image); - return tex->id; + return tex != nullptr ? tex->id : bgfx::TextureHandle{bgfx::kInvalidHandle}; } int nvgCreateImageFromHandle(NVGcontext* _ctx, bgfx::TextureHandle _handle, int _width, int _height, int _flags) @@ -1537,5 +1544,6 @@ int nvgCreateImageFromHandle(NVGcontext* _ctx, bgfx::TextureHandle _handle, int // Do not destroy the caller's texture when nvgDeleteImage runs. tex->flags = _flags | NVG_IMAGE_NODELETE; tex->id = _handle; - return static_cast(tex->id.idx); + tex->image = ++gl->textureId; + return tex->image; } From 8b7d19ba53b2d7618de96d90d608a551bcc342a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Mon, 14 Sep 2026 17:03:36 -0700 Subject: [PATCH 11/16] Preserve NanoVG texture IDs across backends Keep NanoVG-created image IDs aligned with their existing bgfx handles while assigning externally registered handles IDs above the bgfx handle range. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index 63388f07c8..22077a015a 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -455,7 +455,9 @@ namespace return 0; } - tex->image = ++gl->textureId; + // NanoVG-created textures retain their existing bgfx-index image IDs. + // nvgCreateImageFromHandle uses a separate negative ID namespace below. + tex->image = static_cast(tex->id.idx); return tex->image; } @@ -1461,6 +1463,7 @@ NVGcontext* nvgCreate(int32_t _edgeaa, bx::AllocatorI* _allocator) gl->th = BGFX_INVALID_HANDLE; gl->th2 = BGFX_INVALID_HANDLE; gl->texMissing = BGFX_INVALID_HANDLE; + gl->textureId = static_cast(bgfx::kInvalidHandle); bx::memSet(¶ms, 0, sizeof(params) ); params.renderCreate = nvgRenderCreate; @@ -1544,6 +1547,8 @@ int nvgCreateImageFromHandle(NVGcontext* _ctx, bgfx::TextureHandle _handle, int // Do not destroy the caller's texture when nvgDeleteImage runs. tex->flags = _flags | NVG_IMAGE_NODELETE; tex->id = _handle; + // Keep externally registered handles above the uint16_t bgfx handle range, + // away from NanoVG's zero sentinel and NanoVG-created texture IDs. tex->image = ++gl->textureId; return tex->image; } From 253cf3099ad470048452637d4e0fa0b8a5b39484 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Tue, 15 Sep 2026 10:05:09 -0700 Subject: [PATCH 12/16] Skip Canvas pixel tests on the no-op renderer Expose the active bgfx renderer's GPU capability to JavaScript and mark only GPU-dependent Canvas regressions pending on no-op CI builds. Preserve real-GPU coverage, regenerate the test bundle, and document the configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85ec8d5f-d470-4fcd-8a7e-40cc926df07d --- .../JavaScript/dist/tests.javaScript.all.js | 24 +++++++++++-------- .../JavaScript/src/tests.javaScript.all.ts | 24 +++++++++++-------- Apps/UnitTests/Source/Tests.JavaScript.cpp | 2 ++ Polyfills/Canvas/Readme.md | 7 ++++++ 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index b1910ac565..934760c112 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28263,6 +28263,7 @@ mocha__WEBPACK_IMPORTED_MODULE_2__.reporter("spec"); + describe("RequestFile", function () { this.timeout(0); it("should throw when requesting a URL with no protocol", function () { @@ -28377,6 +28378,9 @@ describe("ColorParsing", function () { }); describe("Canvas2D", function () { + // No-op renderers accept GPU commands but cannot produce pixels for readback. + var itWithGpu = hasGpuRendering ? it : it.skip; + function createContext() { var canvas = new _native.Canvas(); canvas.width = 64; @@ -28771,7 +28775,7 @@ describe("Canvas2D", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(data.height).to.equal(3); }); - it("renders a semi-transparent Canvas source through the destination GPU", function () { + itWithGpu("renders a semi-transparent Canvas source through the destination GPU", function () { var source = createCanvas(8, 8); var destination = createCanvas(8, 8); try { @@ -28795,7 +28799,7 @@ describe("Canvas2D", function () { } }); - it("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { + itWithGpu("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { var source = createCanvas(8, 8); var destination = createCanvas(8, 8); try { @@ -28823,7 +28827,7 @@ describe("Canvas2D", function () { } }); - it("applies fractional source crops and destination rectangles on the GPU", function () { + itWithGpu("applies fractional source crops and destination rectangles on the GPU", function () { var source = createCanvas(12, 8); var destination = createCanvas(30, 22); try { @@ -28897,7 +28901,7 @@ describe("Canvas2D", function () { } }); - it("normalizes negative source and destination extents", function () { + itWithGpu("normalizes negative source and destination extents", function () { var source = createCanvas(12, 8); var destination = createCanvas(16, 12); try { @@ -28933,7 +28937,7 @@ describe("Canvas2D", function () { } }); - it("clips the source rectangle and adjusts the destination proportionally", function () { + itWithGpu("clips the source rectangle and adjusts the destination proportionally", function () { var source = createCanvas(8, 8); var destination = createCanvas(16, 16); try { @@ -28965,7 +28969,7 @@ describe("Canvas2D", function () { } }); - it("keeps temporary Canvas images alive until queued draws flush", function () { + itWithGpu("keeps temporary Canvas images alive until queued draws flush", function () { var source = createCanvas(8, 8); var destination = createCanvas(16, 8); try { @@ -28995,7 +28999,7 @@ describe("Canvas2D", function () { } }); - it("keeps ImageData uploads alive through putImageData and drawImage", function () { + itWithGpu("keeps ImageData uploads alive through putImageData and drawImage", function () { var destination = createCanvas(12, 6); try { var putData = destination.context.createImageData(4, 4); @@ -29042,7 +29046,7 @@ describe("Canvas2D", function () { } }); - it("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { + itWithGpu("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { var source = createCanvas(20, 8); var destination = createCanvas(16, 12); try { @@ -29102,7 +29106,7 @@ describe("Canvas2D", function () { } }); - it("intersects repeated Canvas draws with a retained non-rectangular clip", function () { + itWithGpu("intersects repeated Canvas draws with a retained non-rectangular clip", function () { var source = createCanvas(4, 4); var destination = createCanvas(16, 12); try { @@ -29144,7 +29148,7 @@ describe("Canvas2D", function () { } }); - it("keeps fractional drawImage edges out of the CPU mirror", function () { + itWithGpu("keeps fractional drawImage edges out of the CPU mirror", function () { var source = createCanvas(2, 2); var destination = createCanvas(8, 8); try { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index 7146a00eee..e558099daf 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -29,6 +29,7 @@ Mocha.setup("bdd"); Mocha.reporter("spec"); declare const hostPlatform: string; +declare const hasGpuRendering: boolean; declare const setExitCode: (code: number) => void; declare const _native: any; @@ -146,6 +147,9 @@ describe("ColorParsing", function () { }); describe("Canvas2D", function () { + // No-op renderers accept GPU commands but cannot produce pixels for readback. + const itWithGpu = hasGpuRendering ? it : it.skip; + function createContext(): any { const canvas = new _native.Canvas(); canvas.width = 64; @@ -540,7 +544,7 @@ describe("Canvas2D", function () { expect(data.height).to.equal(3); }); - it("renders a semi-transparent Canvas source through the destination GPU", function () { + itWithGpu("renders a semi-transparent Canvas source through the destination GPU", function () { const source = createCanvas(8, 8); const destination = createCanvas(8, 8); try { @@ -564,7 +568,7 @@ describe("Canvas2D", function () { } }); - it("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { + itWithGpu("recognizes a Canvas source before ImageBitmap-shaped own properties", function () { const source = createCanvas(8, 8); const destination = createCanvas(8, 8); try { @@ -592,7 +596,7 @@ describe("Canvas2D", function () { } }); - it("applies fractional source crops and destination rectangles on the GPU", function () { + itWithGpu("applies fractional source crops and destination rectangles on the GPU", function () { const source = createCanvas(12, 8); const destination = createCanvas(30, 22); try { @@ -666,7 +670,7 @@ describe("Canvas2D", function () { } }); - it("normalizes negative source and destination extents", function () { + itWithGpu("normalizes negative source and destination extents", function () { const source = createCanvas(12, 8); const destination = createCanvas(16, 12); try { @@ -702,7 +706,7 @@ describe("Canvas2D", function () { } }); - it("clips the source rectangle and adjusts the destination proportionally", function () { + itWithGpu("clips the source rectangle and adjusts the destination proportionally", function () { const source = createCanvas(8, 8); const destination = createCanvas(16, 16); try { @@ -734,7 +738,7 @@ describe("Canvas2D", function () { } }); - it("keeps temporary Canvas images alive until queued draws flush", function () { + itWithGpu("keeps temporary Canvas images alive until queued draws flush", function () { const source = createCanvas(8, 8); const destination = createCanvas(16, 8); try { @@ -764,7 +768,7 @@ describe("Canvas2D", function () { } }); - it("keeps ImageData uploads alive through putImageData and drawImage", function () { + itWithGpu("keeps ImageData uploads alive through putImageData and drawImage", function () { const destination = createCanvas(12, 6); try { const putData = destination.context.createImageData(4, 4); @@ -811,7 +815,7 @@ describe("Canvas2D", function () { } }); - it("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { + itWithGpu("preserves transform, rectangular clip, and globalAlpha for Canvas crops", function () { const source = createCanvas(20, 8); const destination = createCanvas(16, 12); try { @@ -871,7 +875,7 @@ describe("Canvas2D", function () { } }); - it("intersects repeated Canvas draws with a retained non-rectangular clip", function () { + itWithGpu("intersects repeated Canvas draws with a retained non-rectangular clip", function () { const source = createCanvas(4, 4); const destination = createCanvas(16, 12); try { @@ -913,7 +917,7 @@ describe("Canvas2D", function () { } }); - it("keeps fractional drawImage edges out of the CPU mirror", function () { + itWithGpu("keeps fractional drawImage edges out of the CPU mirror", function () { const source = createCanvas(2, 2); const destination = createCanvas(8, 8); try { diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 3eb048ca64..5602fef6da 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -17,6 +17,7 @@ #endif #include +#include #include extern Babylon::Graphics::Configuration g_deviceConfig; @@ -74,6 +75,7 @@ TEST(JavaScript, All) runtime.Dispatch([&exitCodePromise, &device, &nativeCanvas](Napi::Env env) { device.AddToJavaScript(env); + env.Global().Set("hasGpuRendering", bgfx::getRendererType() != bgfx::RendererType::Noop); Babylon::Polyfills::XMLHttpRequest::Initialize(env); Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index c264aa381c..87aaf9b81a 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -13,6 +13,13 @@ the interpolation border, and fontstash blur padding are excluded from those bounds. The no-font fallback exposes the same metric properties, but its values are estimates rather than measurements of a loaded face. +The Canvas pixel regressions in `JavaScript.All` require a rendering GPU backend. +The test host exposes `hasGpuRendering` from the active bgfx renderer; only those +regressions are reported as pending with the no-op renderer used by macOS CI +(`BABYLON_NATIVE_TESTS_USE_NOOP_METAL_DEVICE=ON`). API, PNG fallback, and text +metrics tests still run. Configure that option `OFF` on a Metal-capable Mac to +run the pixel regressions as well. + ## Native Canvas visual baseline The `native-canvas.png` reference for Playground `#TKVFSA#8` is a Native regression From 2379eb396f04e516d9927a2b4028ffae5a375ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 15 Sep 2026 10:52:00 -0700 Subject: [PATCH 13/16] Remove private bgfx dependency from installed test sources Use the existing USE_NOOP_METAL_DEVICE test configuration to expose the Canvas GPU-test capability instead of including bgfx in Tests.JavaScript.cpp. The installation consumer only has public BabylonNative headers, so that include broke both the Windows and Linux installation jobs. Keep the pixel assertions enabled on Windows, Linux, and real Metal devices. Document the explicit no-op configuration rather than a runtime renderer query. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48fa7ec8-0cf6-48a0-b836-10b7ace38c1a --- Apps/UnitTests/Source/Tests.JavaScript.cpp | 7 +++++-- Polyfills/Canvas/Readme.md | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 5602fef6da..1808cfaf98 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -17,7 +17,6 @@ #endif #include -#include #include extern Babylon::Graphics::Configuration g_deviceConfig; @@ -75,7 +74,11 @@ TEST(JavaScript, All) runtime.Dispatch([&exitCodePromise, &device, &nativeCanvas](Napi::Env env) { device.AddToJavaScript(env); - env.Global().Set("hasGpuRendering", bgfx::getRendererType() != bgfx::RendererType::Noop); +#ifdef USE_NOOP_METAL_DEVICE + env.Global().Set("hasGpuRendering", false); +#else + env.Global().Set("hasGpuRendering", true); +#endif Babylon::Polyfills::XMLHttpRequest::Initialize(env); Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index 87aaf9b81a..45176b01ac 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -14,11 +14,14 @@ bounds. The no-font fallback exposes the same metric properties, but its values are estimates rather than measurements of a loaded face. The Canvas pixel regressions in `JavaScript.All` require a rendering GPU backend. -The test host exposes `hasGpuRendering` from the active bgfx renderer; only those -regressions are reported as pending with the no-op renderer used by macOS CI -(`BABYLON_NATIVE_TESTS_USE_NOOP_METAL_DEVICE=ON`). API, PNG fallback, and text -metrics tests still run. Configure that option `OFF` on a Metal-capable Mac to -run the pixel regressions as well. +The test host sets `hasGpuRendering` to false only for the explicit no-op Metal +test configuration (`BABYLON_NATIVE_TESTS_USE_NOOP_METAL_DEVICE=ON`), using the +same `USE_NOOP_METAL_DEVICE` definition as the Apple test host. This keeps the +shared tests compatible with installed public headers without exposing bgfx. +Only the pixel regressions are reported as pending in that configuration; API, +PNG fallback, and text metrics tests still run. Configure that option `OFF` on a +Metal-capable Mac to run the pixel regressions as well. Windows and Linux retain +the pixel assertions. ## Native Canvas visual baseline From ea742744f2697737da07b7b91bf78ade1ca6267b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 15 Sep 2026 13:36:49 -0700 Subject: [PATCH 14/16] Address Canvas readback and drawImage review feedback Read getImageData regions from the rendered framebuffer rather than the partial CPU mirror. Preserve straight alpha, transparent out-of-bounds pixels, resize behavior, and wide normalized region coordinates; surface readback errors as JavaScript exceptions. Validate drawImage arity and normalize geometry before source readback or uploads. Coerce coordinates once before observing source dimensions, and retain images referenced by queued draws through the NanoVG flush. Keep PNG encoding available independently of input-image loading and correct the imported texture-ID namespace comment. Replace probe-canvas assertions with direct getImageData coverage and add native no-op allocation/readback and PNG encoding regressions. Verify the native tests with image loading enabled and disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48fa7ec8-0cf6-48a0-b836-10b7ace38c1a --- Apps/UnitTests/CMakeLists.txt | 1 + .../JavaScript/dist/tests.javaScript.all.js | 113 ++++++-- .../JavaScript/src/tests.javaScript.all.ts | 113 ++++++-- .../Source/Tests.Canvas.Readback.cpp | 139 +++++++++ Polyfills/Canvas/CMakeLists.txt | 6 +- Polyfills/Canvas/Readme.md | 18 +- Polyfills/Canvas/Source/Canvas.cpp | 6 - Polyfills/Canvas/Source/Context.cpp | 266 ++++++------------ Polyfills/Canvas/Source/Context.h | 31 +- Polyfills/Canvas/Source/Image.cpp | 11 - Polyfills/Canvas/Source/Image.h | 3 - Polyfills/Canvas/Source/ImageData.cpp | 16 +- Polyfills/Canvas/Source/ImageData.h | 2 +- .../Canvas/Source/nanovg/nanovg_babylon.cpp | 2 +- 14 files changed, 455 insertions(+), 272 deletions(-) create mode 100644 Apps/UnitTests/Source/Tests.Canvas.Readback.cpp diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 5f56d74cd1..ec4a95d185 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -26,6 +26,7 @@ set(SOURCES "Source/App.h" "Source/App.cpp" "Source/Tests.Canvas.TextMetrics.cpp" + "Source/Tests.Canvas.Readback.cpp" "Source/Tests.Device.FrameEncoder.cpp" "Source/Tests.ExternalTexture.cpp" "Source/Tests.ExternalTexture.Lifecycle.cpp" diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 934760c112..7110a68169 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28401,20 +28401,12 @@ describe("Canvas2D", function () { } function captureGpuPixels(canvas) { - var probe = createCanvas(canvas.width, canvas.height); - try { - // drawImage(canvas) captures the source framebuffer, while getImageData only - // reads the probe's CPU mirror. This makes assertions observe the source GPU. - probe.context.drawImage(canvas, 0, 0); - return probe.context.getImageData( - 0, - 0, - canvas.width, - canvas.height - ).data; - } finally { - disposeCanvas(probe); - } + return canvas.getContext("2d").getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; } function pixelAt( @@ -28699,8 +28691,7 @@ describe("Canvas2D", function () { it("accepts two color stops at the same offset", function () { // Only checks that the insertion path accepts the duplicate offset the spec allows; // std::map::insert() dropped it by returning {it, false} rather than throwing, so this - // does not by itself prove the stop survives. Asserting that needs the rendered ramp, - // and gradient fills are not read back by getImageData. + // does not by itself prove the stop survives. That needs a separate rendered-ramp assertion. var ctx = createContext(); var gradient = ctx.createLinearGradient(0, 0, 64, 0); gradient.addColorStop(0, "red"); @@ -29148,7 +29139,7 @@ describe("Canvas2D", function () { } }); - itWithGpu("keeps fractional drawImage edges out of the CPU mirror", function () { + itWithGpu("reads fractional drawImage edge coverage from the framebuffer", function () { var source = createCanvas(2, 2); var destination = createCanvas(8, 8); try { @@ -29163,8 +29154,8 @@ describe("Canvas2D", function () { pixelAt(pixels, destination.canvas.width, 4, 2), pixelAt(pixels, destination.canvas.width, 2, 1), pixelAt(pixels, destination.canvas.width, 2, 4)]. - forEach(function (outside) { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(outside[3]).to.equal(0); + forEach(function (edge) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(edge[3]).to.be.within(40, 90); }); } finally { disposeCanvas(destination); @@ -29172,6 +29163,90 @@ describe("Canvas2D", function () { } }); + itWithGpu("reads new NanoVG draws after getImageData and toDataURL snapshots", function () { + var resource = createCanvas(8, 8); + try { + resource.context.fillStyle = "#ff0000"; + resource.context.fillRect(0, 0, 8, 8); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(captureGpuPixels(resource.canvas), 8, 6, 6)).to.deep.equal([255, 0, 0, 255]); + resource.canvas.toDataURL(); + + resource.context.fillStyle = "#0000ff"; + resource.context.fillRect(0, 0, 4, 8); + var pixels = captureGpuPixels(resource.canvas); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 2, 2)).to.deep.equal([0, 0, 255, 255]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 6, 6)).to.deep.equal([255, 0, 0, 255]); + } finally { + disposeCanvas(resource); + } + }); + + itWithGpu("reads filtered Canvas draws instead of unfiltered source pixels", function () { + var source = createCanvas(16, 16); + var destination = createCanvas(16, 16); + try { + source.context.fillStyle = "#ffffff"; + source.context.fillRect(4, 4, 8, 8); + destination.context.filter = "blur(2px)"; + destination.context.drawImage(source.canvas, 0, 0); + var pixels = captureGpuPixels(destination.canvas); + var fringe = pixelAt(pixels, 16, 3, 8); + var center = pixelAt(pixels, 16, 8, 8); + // The unfiltered CPU copy has zero alpha outside the source ink. + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fringe[0]).to.equal(fringe[1]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fringe[1]).to.equal(fringe[2]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(fringe[3]).to.be.greaterThan(0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(center[3]).to.be.greaterThan(fringe[3]); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + itWithGpu("reads source dimensions after coercing drawImage coordinates once", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + var conversions = 0; + destination.context.drawImage(source.canvas, { + valueOf: function valueOf() { + ++conversions; + source.canvas.width = 4; + return 0; + } + }, 0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(conversions).to.equal(1); + var pixels = captureGpuPixels(destination.canvas); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 2, 2)).to.deep.equal([255, 0, 0, 255]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 6, 2)).to.deep.equal([0, 0, 0, 0]); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + itWithGpu("clips framebuffer readback and normalizes negative region extents", function () { + var resource = createCanvas(8, 8); + try { + resource.context.fillStyle = "#ff0000"; + resource.context.fillRect(0, 0, 8, 8); + var positive = resource.context.getImageData(-1, -1, 3, 3); + var negative = resource.context.getImageData(2, 2, -3, -3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(negative.data)).to.deep.equal(Array.from(positive.data)); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(positive.data, 3, 0, 0)).to.deep.equal([0, 0, 0, 0]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(positive.data, 3, 1, 1)).to.deep.equal([255, 0, 0, 255]); + + var outside = resource.context.getImageData(-2147483648, 0, -2, 1); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(outside.data)).to.deep.equal([0, 0, 0, 0, 0, 0, 0, 0]); + resource.canvas.width = 4; + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(resource.context.getImageData(0, 0, 1, 1).data)).to.deep.equal([0, 0, 0, 0]); + } finally { + disposeCanvas(resource); + } + }); + it("falls back to PNG for default, empty, case-variant, and unsupported MIME types", function () { var canvas = new _native.Canvas(); canvas.width = 2; diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index e558099daf..ea4da1c9c7 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -170,20 +170,12 @@ describe("Canvas2D", function () { } function captureGpuPixels(canvas: any): any { - const probe = createCanvas(canvas.width, canvas.height); - try { - // drawImage(canvas) captures the source framebuffer, while getImageData only - // reads the probe's CPU mirror. This makes assertions observe the source GPU. - probe.context.drawImage(canvas, 0, 0); - return probe.context.getImageData( - 0, - 0, - canvas.width, - canvas.height - ).data; - } finally { - disposeCanvas(probe); - } + return canvas.getContext("2d").getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; } function pixelAt( @@ -468,8 +460,7 @@ describe("Canvas2D", function () { it("accepts two color stops at the same offset", function () { // Only checks that the insertion path accepts the duplicate offset the spec allows; // std::map::insert() dropped it by returning {it, false} rather than throwing, so this - // does not by itself prove the stop survives. Asserting that needs the rendered ramp, - // and gradient fills are not read back by getImageData. + // does not by itself prove the stop survives. That needs a separate rendered-ramp assertion. const ctx = createContext(); const gradient = ctx.createLinearGradient(0, 0, 64, 0); gradient.addColorStop(0, "red"); @@ -917,7 +908,7 @@ describe("Canvas2D", function () { } }); - itWithGpu("keeps fractional drawImage edges out of the CPU mirror", function () { + itWithGpu("reads fractional drawImage edge coverage from the framebuffer", function () { const source = createCanvas(2, 2); const destination = createCanvas(8, 8); try { @@ -932,8 +923,8 @@ describe("Canvas2D", function () { pixelAt(pixels, destination.canvas.width, 4, 2), pixelAt(pixels, destination.canvas.width, 2, 1), pixelAt(pixels, destination.canvas.width, 2, 4), - ].forEach(function (outside) { - expect(outside[3]).to.equal(0); + ].forEach(function (edge) { + expect(edge[3]).to.be.within(40, 90); }); } finally { disposeCanvas(destination); @@ -941,6 +932,90 @@ describe("Canvas2D", function () { } }); + itWithGpu("reads new NanoVG draws after getImageData and toDataURL snapshots", function () { + const resource = createCanvas(8, 8); + try { + resource.context.fillStyle = "#ff0000"; + resource.context.fillRect(0, 0, 8, 8); + expect(pixelAt(captureGpuPixels(resource.canvas), 8, 6, 6)).to.deep.equal([255, 0, 0, 255]); + resource.canvas.toDataURL(); + + resource.context.fillStyle = "#0000ff"; + resource.context.fillRect(0, 0, 4, 8); + const pixels = captureGpuPixels(resource.canvas); + expect(pixelAt(pixels, 8, 2, 2)).to.deep.equal([0, 0, 255, 255]); + expect(pixelAt(pixels, 8, 6, 6)).to.deep.equal([255, 0, 0, 255]); + } finally { + disposeCanvas(resource); + } + }); + + itWithGpu("reads filtered Canvas draws instead of unfiltered source pixels", function () { + const source = createCanvas(16, 16); + const destination = createCanvas(16, 16); + try { + source.context.fillStyle = "#ffffff"; + source.context.fillRect(4, 4, 8, 8); + destination.context.filter = "blur(2px)"; + destination.context.drawImage(source.canvas, 0, 0); + const pixels = captureGpuPixels(destination.canvas); + const fringe = pixelAt(pixels, 16, 3, 8); + const center = pixelAt(pixels, 16, 8, 8); + // The unfiltered CPU copy has zero alpha outside the source ink. + expect(fringe[0]).to.equal(fringe[1]); + expect(fringe[1]).to.equal(fringe[2]); + expect(fringe[3]).to.be.greaterThan(0); + expect(center[3]).to.be.greaterThan(fringe[3]); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + itWithGpu("reads source dimensions after coercing drawImage coordinates once", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(8, 8); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + let conversions = 0; + destination.context.drawImage(source.canvas, { + valueOf() { + ++conversions; + source.canvas.width = 4; + return 0; + }, + }, 0); + expect(conversions).to.equal(1); + const pixels = captureGpuPixels(destination.canvas); + expect(pixelAt(pixels, 8, 2, 2)).to.deep.equal([255, 0, 0, 255]); + expect(pixelAt(pixels, 8, 6, 2)).to.deep.equal([0, 0, 0, 0]); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + itWithGpu("clips framebuffer readback and normalizes negative region extents", function () { + const resource = createCanvas(8, 8); + try { + resource.context.fillStyle = "#ff0000"; + resource.context.fillRect(0, 0, 8, 8); + const positive = resource.context.getImageData(-1, -1, 3, 3); + const negative = resource.context.getImageData(2, 2, -3, -3); + expect(Array.from(negative.data)).to.deep.equal(Array.from(positive.data)); + expect(pixelAt(positive.data, 3, 0, 0)).to.deep.equal([0, 0, 0, 0]); + expect(pixelAt(positive.data, 3, 1, 1)).to.deep.equal([255, 0, 0, 255]); + + const outside = resource.context.getImageData(-2147483648, 0, -2, 1); + expect(Array.from(outside.data)).to.deep.equal([0, 0, 0, 0, 0, 0, 0, 0]); + resource.canvas.width = 4; + expect(Array.from(resource.context.getImageData(0, 0, 1, 1).data)).to.deep.equal([0, 0, 0, 0]); + } finally { + disposeCanvas(resource); + } + }); + it("falls back to PNG for default, empty, case-variant, and unsupported MIME types", function () { const canvas = new _native.Canvas(); canvas.width = 2; diff --git a/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp new file mode 100644 index 0000000000..0a8d887c3d --- /dev/null +++ b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp @@ -0,0 +1,139 @@ +#include + +#include +#include +#include +#include "../../../Polyfills/Canvas/Source/Canvas.h" +#include "../../../Polyfills/Canvas/Source/Context.h" +#include "../../../Polyfills/Canvas/Source/nanovg/nanovg.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern Babylon::Graphics::Configuration g_deviceConfig; + +namespace +{ + template + void RunCanvasTest(CallbackT callback) + { + Babylon::Graphics::Device device{g_deviceConfig}; + device.StartRenderingCurrentFrame(); + std::optional canvas; + Babylon::AppRuntime runtime{}; + std::promise completed; + auto future = completed.get_future(); + runtime.Dispatch([&](Napi::Env env) { + std::string error; + try + { + device.AddToJavaScript(env); + canvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); + callback(env); + } + catch (const std::exception& ex) + { + error = ex.what(); + } + completed.set_value(std::move(error)); + }); + + while (future.wait_for(std::chrono::milliseconds{16}) != std::future_status::ready) + { + device.FinishRenderingCurrentFrame(); + device.StartRenderingCurrentFrame(); + } + EXPECT_EQ(future.get(), ""); + canvas.reset(); + device.FinishRenderingCurrentFrame(); + } +} + +TEST(CanvasReadback, DrawImageRejectsArityAndNoOpGeometryBeforeReadbackOrUpload) +{ + RunCanvasTest([](Napi::Env env) { + const auto constructor = Babylon::JsRuntime::NativeObject::GetFromJavaScript(env).Get("Canvas").As(); + const auto createCanvas = [&] { + auto canvas = constructor.New({}); + canvas.Set("width", 8); + canvas.Set("height", 8); + canvas.Get("getContext").As().Call(canvas, {Napi::String::New(env, "2d")}); + return canvas; + }; + auto source = createCanvas(); + auto destination = createCanvas(); + auto* sourceCanvas = Babylon::Polyfills::Internal::NativeCanvas::Unwrap(source); + auto* destinationCanvas = Babylon::Polyfills::Internal::NativeCanvas::Unwrap(destination); + auto context = destination.Get("_context").As(); + auto drawImage = context.Get("drawImage").As(); + + auto bitmap = Napi::Object::New(env); + bitmap.Set("width", 8); + bitmap.Set("height", 8); + bitmap.Set("format", 74); + bitmap.Set("data", Napi::Uint8Array::New(env, 8 * 8 * 4)); + + auto* params = nvgInternalParams(destinationCanvas->GetBoundContext()->GetNVGContext()); + const auto restore = [createTexture = params->renderCreateTexture](NVGparams* value) { + value->renderCreateTexture = createTexture; + }; + std::unique_ptr restoreTextureCallback{params, restore}; + // A no-op must never reach an upload, even when texture allocation would fail. + params->renderCreateTexture = [](void*, int, int, int, int, const unsigned char*) -> int { + ADD_FAILURE() << "drawImage attempted a texture upload for an invalid or no-op call"; + return 0; + }; + + EXPECT_THROW(drawImage.Call(context, {}), Napi::Error); + const std::vector> noOpArguments{ + {std::numeric_limits::quiet_NaN(), 0}, + {0, std::numeric_limits::infinity()}, + {0, 0, 0, 4}, + {0, 0, 4, 0}, + {0, 0, 0, 4, 0, 0, 4, 4}, + {0, 0, 4, 0, 0, 0, 4, 4}, + {20, 0, 4, 4, 0, 0, 4, 4}, + {0, 20, 4, 4, 0, 0, 4, 4}, + {0, 0, 1e300, 4}, + {0, 0, 1e-300, 4}, + }; + for (const auto& image : std::array{source, bitmap}) + { + EXPECT_THROW(drawImage.Call(context, {image, Napi::Number::New(env, 0)}), Napi::Error); + for (const auto& numbers : noOpArguments) + { + std::vector args{image}; + for (const double number : numbers) + { + args.push_back(Napi::Number::New(env, number)); + } + EXPECT_NO_THROW(drawImage.Call(context, args)); + } + } + EXPECT_FALSE(sourceCanvas->HasFrameBuffer()) << "no-op drawImage must not flush/read the source"; + EXPECT_FALSE(destinationCanvas->HasFrameBuffer()); + }); +} + +TEST(CanvasReadback, EncodesPngWithoutInputImageLoading) +{ + RunCanvasTest([](Napi::Env env) { + const auto constructor = Babylon::JsRuntime::NativeObject::GetFromJavaScript(env).Get("Canvas").As(); + auto canvas = constructor.New({}); + canvas.Set("width", 2); + canvas.Set("height", 2); + auto toDataURL = canvas.Get("toDataURL").As(); + for (const char* mime : {"", "image/png", "IMAGE/PNG", "image/jpeg"}) + { + const auto url = toDataURL.Call(canvas, {Napi::String::New(env, mime)}).As().Utf8Value(); + EXPECT_EQ(url.find("data:image/png;base64,iVBORw0KGgo"), 0u); + EXPECT_GT(url.size(), 32u); + } + }); +} diff --git a/Polyfills/Canvas/CMakeLists.txt b/Polyfills/Canvas/CMakeLists.txt index af127dc730..3103b004cf 100644 --- a/Polyfills/Canvas/CMakeLists.txt +++ b/Polyfills/Canvas/CMakeLists.txt @@ -51,14 +51,14 @@ target_link_libraries(Canvas PRIVATE JsRuntimeInternal PRIVATE GraphicsDeviceContext PRIVATE UrlLib - PRIVATE base-n) + PRIVATE base-n + PRIVATE bimg_encode) if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) target_compile_definitions(Canvas PRIVATE BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) target_link_libraries(Canvas - PRIVATE bimg - PRIVATE bimg_encode) + PRIVATE bimg) endif() if(SHADERC_PATH AND NOT IOS AND NOT VISIONOS AND NOT ANDROID) diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index 45176b01ac..386a4f2a66 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -3,10 +3,20 @@ Implements parts of the 2D Canvas API using bgfx. Still a very early WIP; many m ## Readback and text metrics -`toDataURL()` encodes PNG from the rendered canvas. Empty, case-variant, and -unsupported media types use the same PNG fallback; JPEG/WebP encoding is not -implemented. `drawImage(canvas, ...)` snapshots rendered source pixels, not only -the CPU pixel mirror. +`getImageData()` and `toDataURL()` flush pending draws and read the rendered +framebuffer, including transforms, clips, alpha, filters, and GPU sampling. +`getImageData()` returns straight-alpha RGBA8 and transparent black outside the +canvas; it does not use a CPU drawing mirror. `drawImage(canvas, ...)` uses the +same rendered source snapshot. + +`toDataURL()` always supports PNG encoding, independently of +`BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES`, which controls input-image +loading. Empty, case-variant, and unsupported media types use the same PNG +fallback; JPEG/WebP encoding is not implemented. + +`drawImage()` validates its arity and normalizes/clips its geometry before +readback or image upload. Calls that draw nothing do not create temporary images; +images referenced by queued draws remain alive until the NanoVG flush. `measureText()` uses em-scaled font sizes and glyph ink bounds. Atlas SDF padding, the interpolation border, and fontstash blur padding are excluded from those diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index f6071e1a30..4995c2c1b9 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -12,11 +12,9 @@ #include "Gradient.h" #include "Font.h" #include -#ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES #include #include #include -#endif namespace { @@ -268,9 +266,6 @@ namespace Babylon::Polyfills::Internal } } -#ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES - throw Napi::Error::New(info.Env(), "Canvas.toDataURL: image encoding is disabled in this build."); -#else bx::MemoryBlock memoryBlock{&Graphics::DeviceContext::GetDefaultAllocator()}; bx::MemoryWriter writer{&memoryBlock}; bx::Error err{}; @@ -286,7 +281,6 @@ namespace Babylon::Polyfills::Internal std::string encoded; bn::encode_b64(pngBytes, pngBytes + pngSize, std::back_inserter(encoded)); return Napi::String::New(info.Env(), "data:image/png;base64," + encoded); -#endif } Napi::Value NativeCanvas::ParseColor(const Napi::CallbackInfo& info) diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 52334f12fe..65cfe811df 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -1058,13 +1059,6 @@ namespace Babylon::Polyfills::Internal } } - // Keep the CPU mirror in sync so getImageData after toDataURL/drawImage sees GPU content. - EnsureCpuBuffer(); - if (!m_cpuPixels.empty() && m_cpuPixels.size() == rgba.size()) - { - m_cpuPixels = rgba; - } - return rgba; } @@ -1204,10 +1198,6 @@ namespace Babylon::Polyfills::Internal nvgFill(*m_nvg); nvgRestore(*m_nvg); - - // Keep the CPU mirror that getImageData() reads from in sync. - BlitPixelsToCpu(patch.data(), copyWidth, copyHeight, 0, 0, copyWidth, copyHeight, - destLeft, destTop, copyWidth, copyHeight); } void Context::Arc(const Napi::CallbackInfo& info) @@ -1222,149 +1212,47 @@ namespace Babylon::Polyfills::Internal nvgArc(*m_nvg, x, y, radius, startAngle, endAngle, winding); } - void Context::EnsureCpuBuffer() + void Context::ReadPixels(int64_t sx, int64_t sy, uint32_t w, uint32_t h, uint8_t* dst) { + const size_t total = static_cast(w) * h * 4; + std::memset(dst, 0, total); const uint32_t width = m_canvas != nullptr ? m_canvas->GetWidth() : 0; const uint32_t height = m_canvas != nullptr ? m_canvas->GetHeight() : 0; - if (width != m_cpuWidth || height != m_cpuHeight || m_cpuPixels.empty()) - { - m_cpuWidth = width; - m_cpuHeight = height; - - const uint64_t pixelCount = static_cast(width) * height; - if (pixelCount > std::numeric_limits::max() / 4) - { - // Leave the mirror empty; drawImage and getImageData both no-op safely on it. - m_cpuPixels.clear(); - return; - } - - m_cpuPixels.assign(static_cast(pixelCount) * 4, 0); - } - } - - void Context::BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, double sx, double sy, double sw, double sh, double dx, double dy, double dw, double dh) - { - if (src == nullptr || dw <= 0.0 || dh <= 0.0 || sw <= 0.0 || sh <= 0.0 || srcWidth == 0 || srcHeight == 0 || - !std::isfinite(sx) || !std::isfinite(sy) || !std::isfinite(sw) || !std::isfinite(sh) || - !std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dw) || !std::isfinite(dh)) - { - return; - } - - EnsureCpuBuffer(); - if (m_cpuPixels.empty()) + const int64_t left = std::max(0, sx); + const int64_t top = std::max(0, sy); + const int64_t right = std::min(width, sx + w); + const int64_t bottom = std::min(height, sy + h); + if (right <= left || bottom <= top) { return; } - const double destRight = dx + dw; - const double destBottom = dy + dh; - if (!std::isfinite(destRight) || !std::isfinite(destBottom)) + const auto rgba = CaptureRGBA(); + const size_t rowBytes = static_cast(right - left) * 4; + for (int64_t y = top; y < bottom; ++y) { - return; - } - - const double clippedLeft = std::max(0.0, dx); - const double clippedTop = std::max(0.0, dy); - const double clippedRight = std::min(static_cast(m_cpuWidth), destRight); - const double clippedBottom = std::min(static_cast(m_cpuHeight), destBottom); - if (clippedRight <= clippedLeft || clippedBottom <= clippedTop) - { - return; - } - - const uint32_t xBegin = static_cast(std::floor(clippedLeft)); - const uint32_t yBegin = static_cast(std::floor(clippedTop)); - const uint32_t xEnd = std::min(m_cpuWidth, static_cast(std::ceil(clippedRight))); - const uint32_t yEnd = std::min(m_cpuHeight, static_cast(std::ceil(clippedBottom))); - - for (uint32_t destY = yBegin; destY < yEnd; ++destY) - { - const double pixelCenterY = static_cast(destY) + 0.5; - if (pixelCenterY < dy || pixelCenterY >= destBottom) - { - continue; - } - - const double sourceY = sy + (pixelCenterY - dy) * sh / dh; - if (sourceY < 0.0 || sourceY >= static_cast(srcHeight)) - { - continue; - } - - const auto srcY = static_cast(sourceY); - const size_t destRow = static_cast(destY) * m_cpuWidth; - const size_t srcRow = static_cast(srcY) * srcWidth; - - for (uint32_t destX = xBegin; destX < xEnd; ++destX) - { - const double pixelCenterX = static_cast(destX) + 0.5; - if (pixelCenterX < dx || pixelCenterX >= destRight) - { - continue; - } - - const double sourceX = sx + (pixelCenterX - dx) * sw / dw; - if (sourceX < 0.0 || sourceX >= static_cast(srcWidth)) - { - continue; - } - - const auto srcX = static_cast(sourceX); - const size_t srcIndex = (srcRow + srcX) * 4; - const size_t destIndex = (destRow + destX) * 4; - m_cpuPixels[destIndex + 0] = src[srcIndex + 0]; - m_cpuPixels[destIndex + 1] = src[srcIndex + 1]; - m_cpuPixels[destIndex + 2] = src[srcIndex + 2]; - m_cpuPixels[destIndex + 3] = src[srcIndex + 3]; - } + const size_t srcIndex = (static_cast(y) * width + static_cast(left)) * 4; + const size_t destIndex = (static_cast(y - sy) * w + static_cast(left - sx)) * 4; + std::memcpy(dst + destIndex, rgba.data() + srcIndex, rowBytes); } } - void Context::ReadPixels(int32_t sx, int32_t sy, uint32_t w, uint32_t h, uint8_t* dst) + void Context::DrawImage(const Napi::CallbackInfo& info) { - const size_t total = static_cast(w) * h * 4; - std::memset(dst, 0, total); - - // Resync the mirror to the canvas first. Without this, a canvas resize followed by - // getImageData with no intervening drawImage would read the old buffer using the old - // dimensions and hand back stale pixels; EnsureCpuBuffer reallocates and zero-fills. - EnsureCpuBuffer(); - if (m_cpuPixels.empty()) + if (info.Length() != 3 && info.Length() != 5 && info.Length() != 9) { - return; + throw Napi::Error::New(info.Env(), "Invalid number of parameters for DrawImage"); } - for (uint32_t j = 0; j < h; ++j) + Napi::Object imageObj = info[0].As(); + // Numeric coercion can run JavaScript that resizes the source. Do it once, + // before reading source dimensions or creating any graphics resources. + std::array coordinates{}; + for (size_t index = 1; index < info.Length(); ++index) { - const int32_t srcY = sy + static_cast(j); - if (srcY < 0 || srcY >= static_cast(m_cpuHeight)) - { - continue; - } - - for (uint32_t i = 0; i < w; ++i) - { - const int32_t srcX = sx + static_cast(i); - if (srcX < 0 || srcX >= static_cast(m_cpuWidth)) - { - continue; - } - - const size_t srcIndex = (static_cast(srcY) * m_cpuWidth + srcX) * 4; - const size_t destIndex = (static_cast(j) * w + i) * 4; - dst[destIndex + 0] = m_cpuPixels[srcIndex + 0]; - dst[destIndex + 1] = m_cpuPixels[srcIndex + 1]; - dst[destIndex + 2] = m_cpuPixels[srcIndex + 2]; - dst[destIndex + 3] = m_cpuPixels[srcIndex + 3]; - } + coordinates[index - 1] = info[index].ToNumber().DoubleValue(); } - } - - void Context::DrawImage(const Napi::CallbackInfo& info) - { - Napi::Object imageObj = info[0].As(); + const std::span arguments{coordinates.data(), info.Length() - 1}; // Check the nominal Canvas type before the structural ImageBitmap shape. // Canvas objects are extensible, so user code may legitimately add a typed @@ -1375,13 +1263,14 @@ namespace Babylon::Polyfills::Internal NativeCanvas* const srcCanvas = NativeCanvas::Unwrap(imageObj); const uint32_t width = srcCanvas->GetWidth(); const uint32_t height = srcCanvas->GetHeight(); - if (width == 0 || height == 0) + const auto rectangles = ParseDrawImageRectangles(arguments, width, height); + if (!rectangles) { return; } // GPU readback of the source canvas (flush + RT blit + readTexture) so - // NanoVG draws (fillRect/text/paths) are included — not just the CPU mirror. + // NanoVG draws (fillRect/text/paths) are included. std::vector rgba(static_cast(width) * height * 4, 0); if (Context* const srcContext = srcCanvas->GetBoundContext(); srcContext != nullptr) { @@ -1401,7 +1290,7 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); } RetainImageUntilFlush(imageIndex); - DrawImageCommon(info, imageIndex, rgba.data(), width, height); + DrawImageCommon(imageIndex, *rectangles); return; } @@ -1410,19 +1299,20 @@ namespace Babylon::Polyfills::Internal // Native sets forceBitmapOverHTMLImageElement, LoadImage delivers these bitmaps to drawImage // (e.g. Mesh.applyDisplacementMap, height/flow maps). Unwrapping such a plain object as a // NativeCanvasImage would dereference garbage and crash, so handle it explicitly by - // converting the pixels to RGBA8 and drawing/blitting them directly. + // converting the pixels to RGBA8 and drawing them directly. if (imageObj.Has("data") && imageObj.Get("data").IsTypedArray()) { -#ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES - const auto data = imageObj.Get("data").As(); const uint32_t width = imageObj.Get("width").As().Uint32Value(); const uint32_t height = imageObj.Get("height").As().Uint32Value(); - const auto format = static_cast(imageObj.Get("format").As().Uint32Value()); - if (width == 0 || height == 0) + const auto rectangles = ParseDrawImageRectangles(arguments, width, height); + if (!rectangles) { return; } +#ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES + const auto data = imageObj.Get("data").As(); + const auto format = static_cast(imageObj.Get("format").As().Uint32Value()); // Everything below is caller-supplied. bimg::imageConvert reads the source and writes // width*height*4 bytes to the destination without knowing either buffer's real length, // so validate both before handing it any pointers. @@ -1473,7 +1363,7 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); } RetainImageUntilFlush(imageIndex); - DrawImageCommon(info, imageIndex, rgba.data(), width, height); + DrawImageCommon(imageIndex, *rectangles); return; #else throw Napi::Error::New(info.Env(), "drawImage: image loading disabled in this build."); @@ -1481,6 +1371,11 @@ namespace Babylon::Polyfills::Internal } const NativeCanvasImage* canvasImage = NativeCanvasImage::Unwrap(imageObj); + const auto rectangles = ParseDrawImageRectangles(arguments, canvasImage->GetWidth(), canvasImage->GetHeight()); + if (!rectangles) + { + return; + } int imageIndex{-1}; const auto nvgImageIter = m_nvgImageIndices.find(canvasImage); @@ -1495,10 +1390,10 @@ namespace Babylon::Polyfills::Internal } assert(imageIndex != -1); - DrawImageCommon(info, imageIndex, canvasImage->GetPixels(), canvasImage->GetWidth(), canvasImage->GetHeight()); + DrawImageCommon(imageIndex, *rectangles); } - void Context::DrawImageCommon(const Napi::CallbackInfo& info, int imageIndex, const uint8_t* srcPixels, uint32_t srcWidth, uint32_t srcHeight) + std::optional Context::ParseDrawImageRectangles(std::span arguments, uint32_t srcWidth, uint32_t srcHeight) { double sx{0.0}; double sy{0.0}; @@ -1508,39 +1403,35 @@ namespace Babylon::Polyfills::Internal double dy{}; double dWidth{sWidth}; double dHeight{sHeight}; - if (info.Length() == 3) - { - dx = info[1].ToNumber().DoubleValue(); - dy = info[2].ToNumber().DoubleValue(); - } - else if (info.Length() == 5) + if (arguments.size() == 2) { - dx = info[1].ToNumber().DoubleValue(); - dy = info[2].ToNumber().DoubleValue(); - dWidth = info[3].ToNumber().DoubleValue(); - dHeight = info[4].ToNumber().DoubleValue(); + dx = arguments[0]; + dy = arguments[1]; } - else if (info.Length() == 9) + else if (arguments.size() == 4) { - sx = info[1].ToNumber().DoubleValue(); - sy = info[2].ToNumber().DoubleValue(); - sWidth = info[3].ToNumber().DoubleValue(); - sHeight = info[4].ToNumber().DoubleValue(); - dx = info[5].ToNumber().DoubleValue(); - dy = info[6].ToNumber().DoubleValue(); - dWidth = info[7].ToNumber().DoubleValue(); - dHeight = info[8].ToNumber().DoubleValue(); + dx = arguments[0]; + dy = arguments[1]; + dWidth = arguments[2]; + dHeight = arguments[3]; } else { - throw Napi::Error::New(info.Env(), "Invalid number of parameters for DrawImage"); + sx = arguments[0]; + sy = arguments[1]; + sWidth = arguments[2]; + sHeight = arguments[3]; + dx = arguments[4]; + dy = arguments[5]; + dWidth = arguments[6]; + dHeight = arguments[7]; } if (srcWidth == 0 || srcHeight == 0 || !std::isfinite(sx) || !std::isfinite(sy) || !std::isfinite(sWidth) || !std::isfinite(sHeight) || !std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dWidth) || !std::isfinite(dHeight)) { - return; + return std::nullopt; } // Negative extents grow in the opposite direction without flipping the pixels. @@ -1567,14 +1458,14 @@ namespace Babylon::Polyfills::Internal if (sWidth == 0.0 || sHeight == 0.0 || dWidth == 0.0 || dHeight == 0.0) { - return; + return std::nullopt; } const double sourceRight = sx + sWidth; const double sourceBottom = sy + sHeight; if (!std::isfinite(sourceRight) || !std::isfinite(sourceBottom)) { - return; + return std::nullopt; } const double clippedSx = std::max(0.0, sx); @@ -1585,7 +1476,7 @@ namespace Babylon::Polyfills::Internal const double clippedSHeight = clippedSourceBottom - clippedSy; if (clippedSWidth <= 0.0 || clippedSHeight <= 0.0) { - return; + return std::nullopt; } const double scaleX = dWidth / sWidth; @@ -1596,8 +1487,6 @@ namespace Babylon::Polyfills::Internal dHeight = clippedSHeight * scaleY; sx = clippedSx; sy = clippedSy; - sWidth = clippedSWidth; - sHeight = clippedSHeight; // Map the full source image into paint space, then fill only the clipped // destination rectangle. This crops through UVs without changing the current @@ -1613,7 +1502,7 @@ namespace Babylon::Polyfills::Internal if (!fitsFloat(dx) || !fitsFloat(dy) || !fitsFloat(dWidth) || !fitsFloat(dHeight) || !fitsFloat(patternX) || !fitsFloat(patternY) || !fitsFloat(patternWidth) || !fitsFloat(patternHeight)) { - return; + return std::nullopt; } const float drawX = static_cast(dx); @@ -1624,15 +1513,22 @@ namespace Babylon::Polyfills::Internal const float paintHeight = static_cast(patternHeight); if (drawWidth <= 0.f || drawHeight <= 0.f || paintWidth <= 0.f || paintHeight <= 0.f) { - return; + return std::nullopt; } + return DrawImageRectangles{ + drawX, drawY, drawWidth, drawHeight, + static_cast(patternX), static_cast(patternY), paintWidth, paintHeight}; + } + + void Context::DrawImageCommon(int imageIndex, const DrawImageRectangles& rectangles) + { NVGpaint imagePaint = nvgImagePattern( *m_nvg, - static_cast(patternX), - static_cast(patternY), - paintWidth, - paintHeight, + rectangles.PatternX, + rectangles.PatternY, + rectangles.PatternWidth, + rectangles.PatternHeight, 0.f, imageIndex, 1.f); @@ -1643,7 +1539,7 @@ namespace Babylon::Polyfills::Internal // the destination rectangle with a temporary scissor instead of appending // the rectangle to the path, which would fill and retain their union. nvgSave(*m_nvg); - nvgIntersectScissor(*m_nvg, drawX, drawY, drawWidth, drawHeight); + nvgIntersectScissor(*m_nvg, rectangles.X, rectangles.Y, rectangles.Width, rectangles.Height); nvgFillPaint(*m_nvg, imagePaint); SetFilterStack(); nvgFill(*m_nvg); @@ -1654,13 +1550,11 @@ namespace Babylon::Polyfills::Internal // Rectangular clips live in NanoVG's scissor state and survive resetting // the temporary draw path. Keep the wrapper's path flags in sync as well. ResetPathState(); - nvgRect(*m_nvg, drawX, drawY, drawWidth, drawHeight); + nvgRect(*m_nvg, rectangles.X, rectangles.Y, rectangles.Width, rectangles.Height); nvgFillPaint(*m_nvg, imagePaint); SetFilterStack(); nvgFill(*m_nvg); } - - BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); } void Context::RetainImageUntilFlush(int imageIndex) @@ -1760,8 +1654,8 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "Context2D.getImageData: invalid number of parameters"); } - auto sx = info[0].As().Int32Value(); - auto sy = info[1].As().Int32Value(); + int64_t sx = info[0].As().Int32Value(); + int64_t sy = info[1].As().Int32Value(); const auto swInt = info[2].As().Int32Value(); const auto shInt = info[3].As().Int32Value(); diff --git a/Polyfills/Canvas/Source/Context.h b/Polyfills/Canvas/Source/Context.h index 83daa5104a..97655947c7 100644 --- a/Polyfills/Canvas/Source/Context.h +++ b/Polyfills/Canvas/Source/Context.h @@ -3,7 +3,6 @@ #include #include #include -#include "Image.h" #include "Path2D.h" #include "Font.h" #include "nanovg/nanovg_filterstack.h" @@ -11,12 +10,17 @@ #include #include #include +#include +#include +#include +#include struct NVGcontext; namespace Babylon::Polyfills::Internal { class CanvasGradient; + class NativeCanvasImage; class Context final : public Napi::ObjectWrap, Polyfills::Canvas::Impl::MonitoredResource { @@ -29,12 +33,12 @@ namespace Babylon::Polyfills::Internal NVGcontext* GetNVGContext() const { return *m_nvg.get(); } - // Copies a region of the CPU-side pixel mirror (populated by DrawImage) into dst (w*h*4 RGBA8 bytes). - // Out-of-range pixels are written as zero. Used to implement getImageData without a GPU readback. - void ReadPixels(int32_t sx, int32_t sy, uint32_t w, uint32_t h, uint8_t* dst); + // Flushes and reads the rendered region into dst (w*h*4 straight-alpha RGBA8 bytes). + // Pixels outside the canvas are transparent black. + void ReadPixels(int64_t sx, int64_t sy, uint32_t w, uint32_t h, uint8_t* dst); // Flush pending NanoVG draws to the canvas framebuffer, then read the full - // RGBA8 contents back from the GPU. Used by toDataURL and drawImage(canvas). + // RGBA8 contents back from the GPU. Shared by all Canvas pixel-readback APIs. std::vector CaptureRGBA(); // Called by NativeCanvas when the canvas wrapper is destroyed so this context @@ -213,16 +217,13 @@ namespace Babylon::Polyfills::Internal // take the emulated branch on what is now a plain rect. void ResetPathState(); - // CPU-side RGBA8 mirror of the canvas, sized to the canvas, populated by DrawImage so that - // getImageData can return the exact decoded pixels (the GPU nanovg framebuffer is not read back). - std::vector m_cpuPixels; - uint32_t m_cpuWidth{0}; - uint32_t m_cpuHeight{0}; - void EnsureCpuBuffer(); - // Core RGBA8 blit used by both the NativeCanvasImage and (plain) ImageBitmap drawImage paths. - void BlitPixelsToCpu(const uint8_t* src, uint32_t srcWidth, uint32_t srcHeight, double sx, double sy, double sw, double sh, double dx, double dy, double dw, double dh); - // Shared drawImage body: draws the nanovg image (arity 3/5/9) and mirrors it to the CPU buffer. - void DrawImageCommon(const Napi::CallbackInfo& info, int imageIndex, const uint8_t* srcPixels, uint32_t srcWidth, uint32_t srcHeight); + struct DrawImageRectangles + { + float X, Y, Width, Height; + float PatternX, PatternY, PatternWidth, PatternHeight; + }; + static std::optional ParseDrawImageRectangles(std::span arguments, uint32_t srcWidth, uint32_t srcHeight); + void DrawImageCommon(int imageIndex, const DrawImageRectangles& rectangles); friend class Canvas; }; diff --git a/Polyfills/Canvas/Source/Image.cpp b/Polyfills/Canvas/Source/Image.cpp index 4c8e74c580..8f115d9c0c 100644 --- a/Polyfills/Canvas/Source/Image.cpp +++ b/Polyfills/Canvas/Source/Image.cpp @@ -193,17 +193,6 @@ namespace Babylon::Polyfills::Internal m_onerrorHandlerRef = Napi::Persistent(eventHandler); } - const uint8_t* NativeCanvasImage::GetPixels() const - { -#ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES - if (m_imageContainer != nullptr) - { - return static_cast(m_imageContainer->m_data); - } -#endif - return nullptr; - } - int NativeCanvasImage::CreateNVGImageForContext(NVGcontext* nvgContext) const { #ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES diff --git a/Polyfills/Canvas/Source/Image.h b/Polyfills/Canvas/Source/Image.h index 83f88210a9..1d5b5db092 100644 --- a/Polyfills/Canvas/Source/Image.h +++ b/Polyfills/Canvas/Source/Image.h @@ -27,9 +27,6 @@ namespace Babylon::Polyfills::Internal uint32_t GetWidth() const { return m_width; } uint32_t GetHeight() const { return m_height; } - // Returns the decoded RGBA8 pixels (width*height*4 bytes), or nullptr if not loaded. - const uint8_t* GetPixels() const; - private: Napi::Value GetWidth(const Napi::CallbackInfo&); Napi::Value GetHeight(const Napi::CallbackInfo&); diff --git a/Polyfills/Canvas/Source/ImageData.cpp b/Polyfills/Canvas/Source/ImageData.cpp index 86cd3310d3..1f4e7e6d57 100644 --- a/Polyfills/Canvas/Source/ImageData.cpp +++ b/Polyfills/Canvas/Source/ImageData.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "Canvas.h" #include "Context.h" #include "ImageData.h" @@ -21,7 +22,7 @@ namespace Babylon::Polyfills::Internal { static constexpr auto JS_IMAGEDATA_CONSTRUCTOR_NAME = "ImageData"; - Napi::Value ImageData::CreateInstance(Napi::Env env, Context* context, int32_t sx, int32_t sy, uint32_t width, uint32_t height) + Napi::Value ImageData::CreateInstance(Napi::Env env, Context* context, int64_t sx, int64_t sy, uint32_t width, uint32_t height) { // No Napi::HandleScope here: the object created by func.New() is returned to the caller. // A plain HandleScope would free the handle on close, which under the reference-counted @@ -45,8 +46,8 @@ namespace Babylon::Polyfills::Internal : Napi::ObjectWrap{info} { auto context{info[0].As>().Data()}; - const auto sx{info[1].As().Int32Value()}; - const auto sy{info[2].As().Int32Value()}; + const auto sx{info[1].As().Int64Value()}; + const auto sy{info[2].As().Int64Value()}; m_width = info[3].As().Uint32Value(); m_height = info[4].As().Uint32Value(); @@ -66,7 +67,14 @@ namespace Babylon::Polyfills::Internal auto data{Napi::Uint8Array::New(info.Env(), byteLength, napi_uint8_clamped_array)}; if (context != nullptr && byteLength > 0) { - context->ReadPixels(sx, sy, m_width, m_height, data.Data()); + try + { + context->ReadPixels(sx, sy, m_width, m_height, data.Data()); + } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } } m_data = Napi::Persistent(data); diff --git a/Polyfills/Canvas/Source/ImageData.h b/Polyfills/Canvas/Source/ImageData.h index 51ae5e272a..ab28146034 100644 --- a/Polyfills/Canvas/Source/ImageData.h +++ b/Polyfills/Canvas/Source/ImageData.h @@ -9,7 +9,7 @@ namespace Babylon::Polyfills::Internal class ImageData final : public Napi::ObjectWrap { public: - static Napi::Value CreateInstance(Napi::Env env, Context* context, int32_t sx, int32_t sy, uint32_t width, uint32_t height); + static Napi::Value CreateInstance(Napi::Env env, Context* context, int64_t sx, int64_t sy, uint32_t width, uint32_t height); explicit ImageData(const Napi::CallbackInfo& info); diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index 22077a015a..6e4941740d 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -456,7 +456,7 @@ namespace } // NanoVG-created textures retain their existing bgfx-index image IDs. - // nvgCreateImageFromHandle uses a separate negative ID namespace below. + // nvgCreateImageFromHandle uses positive IDs above the uint16_t bgfx handle range. tex->image = static_cast(tex->id.idx); return tex->image; } From 60c8972e61e1a64ee0e20677a41e4c753c95fb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 15 Sep 2026 15:42:13 -0700 Subject: [PATCH 15/16] Fix untouched Canvas readback and retain drawImage source kind Allocate a missing render target even when the Canvas was never resized. Classify drawImage sources before numeric coercion, then read current dimensions and validate mutable ImageBitmap data before use. Cover both readback APIs on fresh canvases and source classification mutations during coordinate coercion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48fa7ec8-0cf6-48a0-b836-10b7ace38c1a --- .../JavaScript/dist/tests.javaScript.all.js | 60 +++++++++++++++++++ .../JavaScript/src/tests.javaScript.all.ts | 60 +++++++++++++++++++ .../Source/Tests.Canvas.Readback.cpp | 37 ++++++++++++ Polyfills/Canvas/Readme.md | 5 ++ Polyfills/Canvas/Source/Canvas.cpp | 2 +- Polyfills/Canvas/Source/Context.cpp | 28 +++++---- 6 files changed, 181 insertions(+), 11 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 7110a68169..8122620a13 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -29227,6 +29227,66 @@ describe("Canvas2D", function () { } }); + itWithGpu("retains the Canvas source kind across coordinate coercion", function () { + var source = createCanvas(8, 8); + var destination = createCanvas(8, 8); + var prototype = Object.getPrototypeOf(source.canvas); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + var conversions = 0; + destination.context.drawImage(source.canvas, { + valueOf: function valueOf() { + ++conversions; + source.canvas.width = 4; + Object.setPrototypeOf(source.canvas, null); + source.canvas.data = new Uint8Array(0); + return 0; + } + }, 0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(conversions).to.equal(1); + var pixels = captureGpuPixels(destination.canvas); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 2, 2)).to.deep.equal([255, 0, 0, 255]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(pixelAt(pixels, 8, 6, 2)).to.deep.equal([0, 0, 0, 0]); + } finally { + Object.setPrototypeOf(source.canvas, prototype); + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("retains the ImageBitmap source kind when coercion removes its data", function () { + var destination = createCanvas(8, 8); + try { + [false, true].forEach(function (noOp) { + var bitmap = { + width: 8, + height: 8, + format: 74, + data: new Uint8Array(8 * 8 * 4) + }; + var conversions = 0; + var draw = function draw() { + destination.context.drawImage(bitmap, { + valueOf: function valueOf() { + ++conversions; + delete bitmap.data; + return 0; + } + }, 0, noOp ? 0 : 8, 8); + }; + if (noOp) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(draw).not.to.throw(); + } else { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(draw).to.throw("drawImage: ImageBitmap data must be a typed array."); + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(conversions).to.equal(1); + }); + } finally { + disposeCanvas(destination); + } + }); + itWithGpu("clips framebuffer readback and normalizes negative region extents", function () { var resource = createCanvas(8, 8); try { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index ea4da1c9c7..00aba832cf 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -996,6 +996,66 @@ describe("Canvas2D", function () { } }); + itWithGpu("retains the Canvas source kind across coordinate coercion", function () { + const source = createCanvas(8, 8); + const destination = createCanvas(8, 8); + const prototype = Object.getPrototypeOf(source.canvas); + try { + source.context.fillStyle = "#ff0000"; + source.context.fillRect(0, 0, 8, 8); + let conversions = 0; + destination.context.drawImage(source.canvas, { + valueOf() { + ++conversions; + source.canvas.width = 4; + Object.setPrototypeOf(source.canvas, null); + source.canvas.data = new Uint8Array(0); + return 0; + }, + }, 0); + expect(conversions).to.equal(1); + const pixels = captureGpuPixels(destination.canvas); + expect(pixelAt(pixels, 8, 2, 2)).to.deep.equal([255, 0, 0, 255]); + expect(pixelAt(pixels, 8, 6, 2)).to.deep.equal([0, 0, 0, 0]); + } finally { + Object.setPrototypeOf(source.canvas, prototype); + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + it("retains the ImageBitmap source kind when coercion removes its data", function () { + const destination = createCanvas(8, 8); + try { + [false, true].forEach(function (noOp) { + const bitmap = { + width: 8, + height: 8, + format: 74, + data: new Uint8Array(8 * 8 * 4), + }; + let conversions = 0; + const draw = function () { + destination.context.drawImage(bitmap, { + valueOf() { + ++conversions; + delete bitmap.data; + return 0; + }, + }, 0, noOp ? 0 : 8, 8); + }; + if (noOp) { + expect(draw).not.to.throw(); + } else { + expect(draw).to.throw("drawImage: ImageBitmap data must be a typed array."); + } + expect(conversions).to.equal(1); + }); + } finally { + disposeCanvas(destination); + } + }); + itWithGpu("clips framebuffer readback and normalizes negative region extents", function () { const resource = createCanvas(8, 8); try { diff --git a/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp index 0a8d887c3d..ecfc2fb383 100644 --- a/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp +++ b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp @@ -121,6 +121,43 @@ TEST(CanvasReadback, DrawImageRejectsArityAndNoOpGeometryBeforeReadbackOrUpload) }); } +TEST(CanvasReadback, UntouchedCanvasReadbackCreatesRenderTarget) +{ + RunCanvasTest([](Napi::Env env) { + const auto constructor = Babylon::JsRuntime::NativeObject::GetFromJavaScript(env).Get("Canvas").As(); + for (const bool readImageData : {true, false}) + { + auto canvas = constructor.New({}); + const auto blankPng = canvas.Get("toDataURL").As().Call(canvas, {}).As().Utf8Value(); + auto context = canvas.Get("getContext").As().Call(canvas, {Napi::String::New(env, "2d")}).As(); + auto* nativeCanvas = Babylon::Polyfills::Internal::NativeCanvas::Unwrap(canvas); + ASSERT_FALSE(nativeCanvas->HasFrameBuffer()); + + if (readImageData) + { + const auto image = context.Get("getImageData").As().Call(context, { + Napi::Number::New(env, 0), Napi::Number::New(env, 0), + Napi::Number::New(env, 1), Napi::Number::New(env, 1)}).As(); + EXPECT_EQ(image.Get("width").As().Uint32Value(), 1u); + EXPECT_EQ(image.Get("height").As().Uint32Value(), 1u); + const auto pixels = image.Get("data").As(); + ASSERT_EQ(pixels.ElementLength(), 4u); + for (size_t index = 0; index < pixels.ElementLength(); ++index) + { + EXPECT_EQ(pixels[index], 0u); + } + } + else + { + const auto png = canvas.Get("toDataURL").As().Call(canvas, {}).As().Utf8Value(); + EXPECT_EQ(png.find("data:image/png;base64,iVBORw0KGgo"), 0u); + EXPECT_EQ(png, blankPng); + } + EXPECT_TRUE(nativeCanvas->HasFrameBuffer()); + } + }); +} + TEST(CanvasReadback, EncodesPngWithoutInputImageLoading) { RunCanvasTest([](Napi::Env env) { diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index 386a4f2a66..d9d32c03e4 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -5,6 +5,8 @@ Implements parts of the 2D Canvas API using bgfx. Still a very early WIP; many m `getImageData()` and `toDataURL()` flush pending draws and read the rendered framebuffer, including transforms, clips, alpha, filters, and GPU sampling. +An untouched Canvas with a 2D context lazily creates a transparent render target +on its first readback; no resize or drawing call is required. `getImageData()` returns straight-alpha RGBA8 and transparent black outside the canvas; it does not use a CPU drawing mirror. `drawImage(canvas, ...)` uses the same rendered source snapshot. @@ -17,6 +19,9 @@ fallback; JPEG/WebP encoding is not implemented. `drawImage()` validates its arity and normalizes/clips its geometry before readback or image upload. Calls that draw nothing do not create temporary images; images referenced by queued draws remain alive until the NanoVG flush. +The source kind is retained before numeric argument coercion, even if that +coercion changes its prototype or properties. Source dimensions are read after +coercion so a resize takes effect; changed ImageBitmap data is validated before use. `measureText()` uses em-scaled font sizes and glyph ink bounds. Atlas SDF padding, the interpolation border, and fontstash blur padding are excluded from those diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index 4995c2c1b9..0b8b08f901 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -180,7 +180,7 @@ namespace Babylon::Polyfills::Internal bool needClear = m_clear; m_clear = false; - if (m_dirty) + if (m_dirty || !m_frameBuffer) { // make sure render targets are filled with 0 : https://registry.khronos.org/webgl/specs/latest/1.0/#TEXIMAGE2D const bgfx::Memory* mem = bgfx::alloc(static_cast(m_width) * static_cast(m_height) * 4); diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index 65cfe811df..f3e2d74db4 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -1245,8 +1245,16 @@ namespace Babylon::Polyfills::Internal } Napi::Object imageObj = info[0].As(); - // Numeric coercion can run JavaScript that resizes the source. Do it once, - // before reading source dimensions or creating any graphics resources. + // Retain the source kind before coercion can change its prototype or properties. + // Canvas takes precedence over the structural ImageBitmap shape. + const auto canvasCtorVal = JsRuntime::NativeObject::GetFromJavaScript(info.Env()).Get("Canvas"); + NativeCanvas* const srcCanvas = canvasCtorVal.IsFunction() && imageObj.InstanceOf(canvasCtorVal.As()) + ? NativeCanvas::Unwrap(imageObj) + : nullptr; + const bool isImageBitmap = srcCanvas == nullptr && imageObj.Has("data") && imageObj.Get("data").IsTypedArray(); + + // Coercion can also resize the source. Do it once, before reading dimensions + // or creating any graphics resources. std::array coordinates{}; for (size_t index = 1; index < info.Length(); ++index) { @@ -1254,13 +1262,8 @@ namespace Babylon::Polyfills::Internal } const std::span arguments{coordinates.data(), info.Length() - 1}; - // Check the nominal Canvas type before the structural ImageBitmap shape. - // Canvas objects are extensible, so user code may legitimately add a typed - // `data` property without changing the object passed to drawImage. - const auto canvasCtorVal = JsRuntime::NativeObject::GetFromJavaScript(info.Env()).Get("Canvas"); - if (canvasCtorVal.IsFunction() && imageObj.InstanceOf(canvasCtorVal.As())) + if (srcCanvas != nullptr) { - NativeCanvas* const srcCanvas = NativeCanvas::Unwrap(imageObj); const uint32_t width = srcCanvas->GetWidth(); const uint32_t height = srcCanvas->GetHeight(); const auto rectangles = ParseDrawImageRectangles(arguments, width, height); @@ -1300,7 +1303,7 @@ namespace Babylon::Polyfills::Internal // (e.g. Mesh.applyDisplacementMap, height/flow maps). Unwrapping such a plain object as a // NativeCanvasImage would dereference garbage and crash, so handle it explicitly by // converting the pixels to RGBA8 and drawing them directly. - if (imageObj.Has("data") && imageObj.Get("data").IsTypedArray()) + if (isImageBitmap) { const uint32_t width = imageObj.Get("width").As().Uint32Value(); const uint32_t height = imageObj.Get("height").As().Uint32Value(); @@ -1311,7 +1314,12 @@ namespace Babylon::Polyfills::Internal } #ifdef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES - const auto data = imageObj.Get("data").As(); + const auto dataValue = imageObj.Get("data"); + if (!dataValue.IsTypedArray()) + { + throw Napi::TypeError::New(info.Env(), "drawImage: ImageBitmap data must be a typed array."); + } + const auto data = dataValue.As(); const auto format = static_cast(imageObj.Get("format").As().Uint32Value()); // Everything below is caller-supplied. bimg::imageConvert reads the source and writes // width*height*4 bytes to the destination without knowing either buffer's real length, From af07833856f25870d9020360fc4e82e8e1fcf207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 15 Sep 2026 16:22:05 -0700 Subject: [PATCH 16/16] Encode only written PNG bytes in Canvas data URLs Use the writer's output length instead of the memory block's rounded-up capacity. This prevents unused allocation bytes from being serialized and fixes the untouched Canvas PNG comparison on Windows and Linux CI. Add a deterministic regression checking that PNG chunks end exactly at IEND for multiple Canvas sizes, without weakening the readback assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 48fa7ec8-0cf6-48a0-b836-10b7ace38c1a --- .../JavaScript/dist/tests.javaScript.all.js | 74 +++++++++++++------ .../JavaScript/src/tests.javaScript.all.ts | 31 ++++++++ Polyfills/Canvas/Readme.md | 2 + Polyfills/Canvas/Source/Canvas.cpp | 5 +- 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 8122620a13..3b8ce1ff9d 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28245,8 +28245,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! mocha */ "../../node_modules/mocha/browser-entry.js"); /* harmony import */ var mocha__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(mocha__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); -/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); -/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__); +/* harmony import */ var buffer__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! buffer */ "../../node_modules/buffer/index.js"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_5__); function _createForOfIteratorHelper(r, e) {var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];if (!t) {if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) {t && (r = t);var _n = 0,F = function F() {};return { s: F, n: function n() {return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] };}, e: function e(r) {throw r;}, f: F };}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");}var o,a = !0,u = !1;return { s: function s() {t = t.call(r);}, n: function n() {var r = t.next();return a = r.done, r;}, e: function e(r) {u = !0, o = r;}, f: function f() {try {a || null == t.return || t.return();} finally {if (u) throw o;}} };}function _unsupportedIterableToArray(r, a) {if (r) {if ("string" == typeof r) return _arrayLikeToArray(r, a);var t = {}.toString.call(r).slice(8, -1);return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;}}function _arrayLikeToArray(r, a) {(null == a || a > r.length) && (a = r.length);for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];return n;} @@ -28255,6 +28256,7 @@ function _createForOfIteratorHelper(r, e) {var t = "undefined" != typeof Symbol + mocha__WEBPACK_IMPORTED_MODULE_2__.setup("bdd"); // @ts-ignore mocha__WEBPACK_IMPORTED_MODULE_2__.reporter("spec"); @@ -28268,7 +28270,7 @@ describe("RequestFile", function () { this.timeout(0); it("should throw when requesting a URL with no protocol", function () { function requestFile() { - (0,_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.RequestFile)("noprotocol.gltf", function () {}); + (0,_babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.RequestFile)("noprotocol.gltf", function () {}); } (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(requestFile).to.throw(); }); @@ -29327,6 +29329,36 @@ describe("Canvas2D", function () { } }); + it("ends PNG data URLs at IEND without trailing allocation bytes", function () { + [1, 2, 64].forEach(function (size) { + var canvas = new _native.Canvas(); + canvas.width = size; + canvas.height = size; + try { + var url = canvas.toDataURL(); + var png = buffer__WEBPACK_IMPORTED_MODULE_4__.Buffer.from(url.substring("data:image/png;base64,".length), "base64"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(png.subarray(0, 8))).to.deep.equal([137, 80, 78, 71, 13, 10, 26, 10]); + var offset = 8; + var foundEnd = false; + while (offset + 12 <= png.length) { + var length = png.readUInt32BE(offset); + var type = png.toString("ascii", offset + 4, offset + 8); + offset += length + 12; + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(offset).to.be.at.most(png.length); + if (type === "IEND") { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(length).to.equal(0); + foundEnd = true; + break; + } + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(foundEnd).to.equal(true); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(offset).to.equal(png.length); + } finally { + canvas.dispose(); + } + }); + }); + it("returns ascent and descent in no-font text metrics", function () { var resource = createCanvas(8, 8); try { @@ -29343,8 +29375,8 @@ describe("Canvas2D", function () { }); function createSceneAndWait(callback, done) { - var engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.NativeEngine(); - var scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Scene(engine); + var engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.NativeEngine(); + var scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Scene(engine); scene.createDefaultCamera(); callback(engine, scene); scene.executeWhenReady(function () { @@ -29357,7 +29389,7 @@ describe("Materials", function () { it("Empty ShaderMaterial should compile", function (done) { function createEmptyShaderMat() { createSceneAndWait(function (engine, scene) { - var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.MeshBuilder.CreateSphere( + var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.MeshBuilder.CreateSphere( "sphere", { diameter: 2, segments: 32 }, scene @@ -29366,7 +29398,7 @@ describe("Materials", function () { vertexSource: "void main() {}", fragmentSource: "void main() {}" }; - var mat = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ShaderMaterial("shader", scene, shaders, {}); + var mat = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ShaderMaterial("shader", scene, shaders, {}); sphere.material = mat; }, done); } @@ -29374,12 +29406,12 @@ describe("Materials", function () { }); it("GradientMaterial should compile", function (done) { createSceneAndWait(function (engine, scene) { - var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.MeshBuilder.CreateSphere( + var sphere = _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.MeshBuilder.CreateSphere( "sphere", { diameter: 2, segments: 32 }, scene ); - var gradientMaterial = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.GradientMaterial("grad", scene); + var gradientMaterial = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.GradientMaterial("grad", scene); sphere.material = gradientMaterial; }, done); }); @@ -29390,21 +29422,21 @@ describe("PostProcesses", function () { it("PassPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.PassPostProcess("Scene copy", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.PassPostProcess("Scene copy", 1.0, camera); }, done); }); it("BlackAndWhitePostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.BlackAndWhitePostProcess("bandw", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.BlackAndWhitePostProcess("bandw", 1.0, camera); }, done); }); it("BlurPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.BlurPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.BlurPostProcess( "Horizontal blur", - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Vector2(1.0, 0), + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Vector2(1.0, 0), 32, 0.25, camera @@ -29414,9 +29446,9 @@ describe("PostProcesses", function () { it("ConvolutionPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ConvolutionPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ConvolutionPostProcess( "Sepia", - _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ConvolutionPostProcess.EmbossKernel, + _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ConvolutionPostProcess.EmbossKernel, 1.0, camera ); @@ -29425,28 +29457,28 @@ describe("PostProcesses", function () { it("HighlightsPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.HighlightsPostProcess("highlights", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.HighlightsPostProcess("highlights", 1.0, camera); }, done); }); it("TonemapPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.TonemapPostProcess("tonemap", _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.TonemappingOperator.Hable, 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.TonemapPostProcess("tonemap", _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.TonemappingOperator.Hable, 1.0, camera); }, done); }); it("ImageProcessingPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.ImageProcessingPostProcess("processing", 1.0, camera); + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.ImageProcessingPostProcess("processing", 1.0, camera); }, done); }); it("RefractionPostProcess", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.RefractionPostProcess( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.RefractionPostProcess( "Refraction", "https://playground.babylonjs.com/textures/grass.jpg", - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.Color3(1.0, 1.0, 1.0), + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.Color3(1.0, 1.0, 1.0), 0.5, 0.5, 1.0, @@ -29457,7 +29489,7 @@ describe("PostProcesses", function () { it("DefaultPipeline", function (done) { createSceneAndWait(function (engine, scene) { var camera = scene._activeCamera; - new _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__.DefaultRenderingPipeline( + new _babylonjs_core__WEBPACK_IMPORTED_MODULE_5__.DefaultRenderingPipeline( "defaultPipeline", // The name of the pipeline true, // Do you want the pipeline to use HDR texture? scene, // The scene instance diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index 00aba832cf..023abb0b10 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -1,5 +1,6 @@ import * as Mocha from "mocha"; import { expect } from "chai"; +import { Buffer } from "buffer"; import { RequestFile, NativeEngine, @@ -1096,6 +1097,36 @@ describe("Canvas2D", function () { } }); + it("ends PNG data URLs at IEND without trailing allocation bytes", function () { + [1, 2, 64].forEach(function (size) { + const canvas = new _native.Canvas(); + canvas.width = size; + canvas.height = size; + try { + const url = canvas.toDataURL(); + const png = Buffer.from(url.substring("data:image/png;base64,".length), "base64"); + expect(Array.from(png.subarray(0, 8))).to.deep.equal([137, 80, 78, 71, 13, 10, 26, 10]); + let offset = 8; + let foundEnd = false; + while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + const type = png.toString("ascii", offset + 4, offset + 8); + offset += length + 12; + expect(offset).to.be.at.most(png.length); + if (type === "IEND") { + expect(length).to.equal(0); + foundEnd = true; + break; + } + } + expect(foundEnd).to.equal(true); + expect(offset).to.equal(png.length); + } finally { + canvas.dispose(); + } + }); + }); + it("returns ascent and descent in no-font text metrics", function () { const resource = createCanvas(8, 8); try { diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index d9d32c03e4..c7a06389fc 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -15,6 +15,8 @@ same rendered source snapshot. `BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES`, which controls input-image loading. Empty, case-variant, and unsupported media types use the same PNG fallback; JPEG/WebP encoding is not implemented. +The data URL contains only the written PNG bytes through `IEND`, not unused +encoding-buffer capacity. `drawImage()` validates its arity and normalizes/clips its geometry before readback or image upload. Calls that draw nothing do not create temporary images; diff --git a/Polyfills/Canvas/Source/Canvas.cpp b/Polyfills/Canvas/Source/Canvas.cpp index 0b8b08f901..3b78e41347 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -270,14 +270,15 @@ namespace Babylon::Polyfills::Internal bx::MemoryWriter writer{&memoryBlock}; bx::Error err{}; bimg::imageWritePng(&writer, width, height, width * 4, rgba.data(), bimg::TextureFormat::RGBA8, false, &err); - if (!err.isOk() || memoryBlock.getSize() == 0) + // MemoryBlock reports allocation capacity, not the number of encoded bytes. + const size_t pngSize = static_cast(bx::getSize(&writer)); + if (!err.isOk() || pngSize == 0) { throw Napi::Error::New(info.Env(), "Canvas.toDataURL: PNG encode failed."); } // more(0) returns the buffer start without growing (see bx::MemoryBlock). const char* pngBytes = static_cast(memoryBlock.more(0)); - const size_t pngSize = memoryBlock.getSize(); std::string encoded; bn::encode_b64(pngBytes, pngBytes + pngSize, std::back_inserter(encoded)); return Napi::String::New(info.Env(), "data:image/png;base64," + encoded);