diff --git a/Apps/Playground/ReferenceImages/native-canvas.png b/Apps/Playground/ReferenceImages/native-canvas.png index 57530a0526..b3c09f8afc 100644 Binary files a/Apps/Playground/ReferenceImages/native-canvas.png and b/Apps/Playground/ReferenceImages/native-canvas.png differ diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 1da47150db..ec4a95d185 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -25,6 +25,8 @@ 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.Canvas.Readback.cpp" "Source/Tests.Device.FrameEncoder.cpp" "Source/Tests.ExternalTexture.cpp" "Source/Tests.ExternalTexture.Lifecycle.cpp" @@ -73,6 +75,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 34c516c69f..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"); @@ -28263,11 +28265,12 @@ mocha__WEBPACK_IMPORTED_MODULE_2__.reporter("spec"); + 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(); }); @@ -28377,6 +28380,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; @@ -28384,6 +28390,42 @@ 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) { + return canvas.getContext("2d").getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; + } + + 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"; @@ -28651,8 +28693,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"); @@ -28726,11 +28767,616 @@ 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); }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + + [ + // 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); + } + }); + + itWithGpu("reads fractional drawImage edge coverage from the framebuffer", 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 (edge) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(edge[3]).to.be.within(40, 90); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + 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("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 { + 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; + 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("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 { + 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); + } + }); }); 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 () { @@ -28743,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 @@ -28752,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); } @@ -28760,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); }); @@ -28776,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 @@ -28800,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 ); @@ -28811,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, @@ -28843,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 aef949501f..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, @@ -29,6 +30,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 +148,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; @@ -153,6 +158,42 @@ 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 { + return canvas.getContext("2d").getImageData( + 0, + 0, + canvas.width, + canvas.height + ).data; + } + + 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"; @@ -420,8 +461,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"); @@ -495,6 +535,611 @@ describe("Canvas2D", function () { expect(data.width).to.equal(4); expect(data.height).to.equal(3); }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("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); + } + }); + + itWithGpu("reads fractional drawImage edge coverage from the framebuffer", 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 (edge) { + expect(edge[3]).to.be.within(40, 90); + }); + } finally { + disposeCanvas(destination); + disposeCanvas(source); + } + }); + + 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("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 { + 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; + 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("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 { + 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) { diff --git a/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp new file mode 100644 index 0000000000..ecfc2fb383 --- /dev/null +++ b/Apps/UnitTests/Source/Tests.Canvas.Readback.cpp @@ -0,0 +1,176 @@ +#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, 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) { + 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/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp new file mode 100644 index 0000000000..19a95b61ba --- /dev/null +++ b/Apps/UnitTests/Source/Tests.Canvas.TextMetrics.cpp @@ -0,0 +1,94 @@ +#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); + } + } + + } + } +} + +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/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 3eb048ca64..1808cfaf98 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -74,6 +74,11 @@ TEST(JavaScript, All) runtime.Dispatch([&exitCodePromise, &device, &nativeCanvas](Napi::Env env) { device.AddToJavaScript(env); +#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/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h index ba2f0da10a..bc5e15411d 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h @@ -129,6 +129,11 @@ 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). + // 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); void RemoveTexture(bgfx::TextureHandle handle); diff --git a/Core/Graphics/Source/DeviceContext.cpp b/Core/Graphics/Source/DeviceContext.cpp index cfd77781d3..ca4ac320f1 100644 --- a/Core/Graphics/Source/DeviceContext.cpp +++ b/Core/Graphics/Source/DeviceContext.cpp @@ -147,6 +147,11 @@ namespace Babylon::Graphics m_graphicsImpl.FlushViewsIfNeeded(); } + bool DeviceContext::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) { std::scoped_lock lock{m_textureHandleToInfoMutex}; diff --git a/Core/Graphics/Source/DeviceImpl.cpp b/Core/Graphics/Source/DeviceImpl.cpp index 7e5c54cb7f..80011dc0c2 100644 --- a/Core/Graphics/Source/DeviceImpl.cpp +++ b/Core/Graphics/Source/DeviceImpl.cpp @@ -588,13 +588,18 @@ namespace Babylon::Graphics return; } + 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 // 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; + return false; } std::unique_lock lock{m_frameSyncMutex}; @@ -612,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 @@ -640,18 +646,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..3bc34ba971 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. Returns false when the render thread cannot service it. + bool ForceMidFrameFlush(); + // Frame completion scope support void IncrementPendingFrameScopes(); void DecrementPendingFrameScopes(); diff --git a/Polyfills/Canvas/CMakeLists.txt b/Polyfills/Canvas/CMakeLists.txt index 23058b6dba..3103b004cf 100644 --- a/Polyfills/Canvas/CMakeLists.txt +++ b/Polyfills/Canvas/CMakeLists.txt @@ -51,7 +51,8 @@ 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 diff --git a/Polyfills/Canvas/Readme.md b/Polyfills/Canvas/Readme.md index b034a2a5be..c7a06389fc 100644 --- a/Polyfills/Canvas/Readme.md +++ b/Polyfills/Canvas/Readme.md @@ -1,6 +1,64 @@ # 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 + +`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. + +`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. +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; +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 +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 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 + +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 b0850cfdcf..3b78e41347 100644 --- a/Polyfills/Canvas/Source/Canvas.cpp +++ b/Polyfills/Canvas/Source/Canvas.cpp @@ -6,9 +6,15 @@ #include #include #include +#include +#include #include "Colors.h" #include "Gradient.h" #include "Font.h" +#include +#include +#include +#include namespace { @@ -33,6 +39,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 +56,13 @@ namespace Babylon::Polyfills::Internal NativeCanvas::~NativeCanvas() { + // 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(); } @@ -103,6 +117,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; @@ -162,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); @@ -229,6 +247,43 @@ namespace Babylon::Polyfills::Internal return Napi::Pointer::Create(info.Env(), m_texture.get()); } + Napi::Value NativeCanvas::ToDataURL(const Napi::CallbackInfo& info) + { + // 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); + if (m_context != nullptr && width > 0 && height > 0) + { + 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()); + } + } + + 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); + // 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)); + std::string encoded; + bn::encode_b64(pngBytes, pngBytes + pngSize, std::back_inserter(encoded)); + return Napi::String::New(info.Env(), "data:image/png;base64," + encoded); + } + 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..6cc504a90f 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,15 +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(); + bool HasFrameBuffer() const { return m_frameBuffer != nullptr; } 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. + // Blit view reserved by Context::Flush for CopyTexture ordering. void SetBlitViewId(bgfx::ViewId viewId, uint32_t generation) { m_blitViewId = viewId; @@ -92,10 +96,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 +106,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..f3e2d74db4 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -1,13 +1,20 @@ #include +#include #include #include +#include #include +#include #include +#include #include #include #include +#include +#include #include #include +#include #ifdef __GNUC__ #pragma GCC diagnostic push @@ -143,10 +150,20 @@ namespace Babylon::Polyfills::Internal Context::~Context() { + if (m_canvas != nullptr && m_canvas->GetBoundContext() == this) + { + m_canvas->SetBoundContext(nullptr); + } + m_canvas = nullptr; Dispose(); m_cancellationSource->cancel(); } + void Context::DetachCanvas() + { + m_canvas = nullptr; + } + void Context::Dispose(const Napi::CallbackInfo&) { Dispose(); @@ -161,6 +178,7 @@ namespace Babylon::Polyfills::Internal { if (m_nvg) { + ReleaseImagesAfterFlush(); for (auto& image : m_nvgImageIndices) { nvgDeleteImage(*m_nvg, image.second); @@ -673,17 +691,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(); @@ -697,6 +708,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(); @@ -764,6 +777,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(); @@ -806,67 +836,230 @@ 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()) + { + // 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(); + }; + + nvgBeginFrame(*m_nvg, float(width), float(height), 1.0f); + 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 + // 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; + } + + // 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()) + { + return rgba; + } + + 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)) { - // 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(); + throw std::runtime_error{"Canvas.CaptureRGBA: failed to create readback texture."}; + } - Graphics::FrameBuffer& frameBuffer = m_canvas->GetFrameBuffer(); + // 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); - frameBuffer.Bind(); - if (needClear) + struct ReadbackState + { + explicit ReadbackState(std::vector&& pixels, bgfx::TextureHandle texture) + : Pixels(std::move(pixels)) + , Texture(texture) { - 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()) + ~ReadbackState() { - // sanity check no buffers should have been acquired yet - assert(buffer.isAvailable == true); + if (bgfx::isValid(Texture)) + { + bgfx::destroy(Texture); + } } - 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()) + + 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(), + [readback](const arcana::expected& result) { + { + std::lock_guard lock{readback->Mutex}; + if (result.has_error()) + { + readback->Error = result.error(); + } + readback->Done = true; + } + readback->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 (;;) + { { - // sanity check no unreleased buffers - assert(buffer.isAvailable == true); + std::lock_guard lock{readback->Mutex}; + if (readback->Done) + { + break; + } + } + if (!m_graphicsContext.ForceMidFrameFlush()) + { + throw std::runtime_error{"Canvas.CaptureRGBA: the render thread cannot service GPU readback."}; + } + { + 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. + readback->Cv.wait_for(lock, std::chrono::milliseconds{1}, [&] { return readback->Done; }); } } - catch (const std::exception& ex) + + if (readback->Error) { - throw Napi::Error::New(info.Env(), ex.what()); + std::rethrow_exception(readback->Error); + } + rgba = std::move(readback->Pixels); + + 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) + { + 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); + } + } + + // 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)); + } } + + return rgba; } void Context::PutImageData(const Napi::CallbackInfo& info) @@ -988,6 +1181,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. @@ -1004,11 +1198,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, - destLeft, destTop, copyWidth, copyHeight); } void Context::Arc(const Napi::CallbackInfo& info) @@ -1023,141 +1212,115 @@ 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, int32_t sx, int32_t sy, uint32_t sw, uint32_t sh, int32_t dx, int32_t dy, uint32_t dw, uint32_t dh) - { - if (src == nullptr || dw == 0 || dh == 0 || sw == 0 || sh == 0 || srcWidth == 0 || srcHeight == 0) + 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; } - EnsureCpuBuffer(); - if (m_cpuPixels.empty()) + const auto rgba = CaptureRGBA(); + const size_t rowBytes = static_cast(right - left) * 4; + for (int64_t y = top; y < bottom; ++y) { - return; + 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); } + } - // 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); - - for (int64_t j = jBegin; j < jEnd; ++j) + void Context::DrawImage(const Napi::CallbackInfo& info) + { + if (info.Length() != 3 && info.Length() != 5 && info.Length() != 9) { - // 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)) - { - continue; - } - - const size_t destRow = static_cast(dy + j) * m_cpuWidth; - const size_t srcRow = static_cast(srcY) * srcWidth; - - for (int64_t i = iBegin; i < iEnd; ++i) - { - const int64_t srcX = sx + static_cast(i) * sw / dw; - if (srcX < 0 || srcX >= static_cast(srcWidth)) - { - continue; - } - - const size_t srcIndex = (srcRow + static_cast(srcX)) * 4; - const size_t destIndex = (destRow + static_cast(dx + i)) * 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]; - } + throw Napi::Error::New(info.Env(), "Invalid number of parameters for DrawImage"); } - } - void Context::ReadPixels(int32_t sx, int32_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); + Napi::Object imageObj = info[0].As(); + // 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(); - // 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()) + // 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) { - return; + coordinates[index - 1] = info[index].ToNumber().DoubleValue(); } + const std::span arguments{coordinates.data(), info.Length() - 1}; - for (uint32_t j = 0; j < h; ++j) + if (srcCanvas != nullptr) { - const int32_t srcY = sy + static_cast(j); - if (srcY < 0 || srcY >= static_cast(m_cpuHeight)) + const uint32_t width = srcCanvas->GetWidth(); + const uint32_t height = srcCanvas->GetHeight(); + const auto rectangles = ParseDrawImageRectangles(arguments, width, height); + if (!rectangles) { - continue; + return; } - for (uint32_t i = 0; i < w; ++i) + // GPU readback of the source canvas (flush + RT blit + readTexture) so + // NanoVG draws (fillRect/text/paths) are included. + std::vector rgba(static_cast(width) * height * 4, 0); + if (Context* const srcContext = srcCanvas->GetBoundContext(); srcContext != nullptr) { - const int32_t srcX = sx + static_cast(i); - if (srcX < 0 || srcX >= static_cast(m_cpuWidth)) + try { - continue; + rgba = srcContext->CaptureRGBA(); } + catch (const std::exception& ex) + { + throw Napi::Error::New(info.Env(), ex.what()); + } + } - 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]; + 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(imageIndex, *rectangles); + return; } - } - - void Context::DrawImage(const Napi::CallbackInfo& info) - { - Napi::Object imageObj = info[0].As(); // 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 // (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. - if (imageObj.Has("data") && imageObj.Get("data").IsTypedArray()) + // converting the pixels to RGBA8 and drawing them directly. + if (isImageBitmap) { -#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 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, // so validate both before handing it any pointers. @@ -1203,8 +1366,12 @@ namespace Babylon::Polyfills::Internal } 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); + if (imageIndex == 0) + { + throw Napi::Error::New(info.Env(), "drawImage: failed to create the source image."); + } + RetainImageUntilFlush(imageIndex); + DrawImageCommon(imageIndex, *rectangles); return; #else throw Napi::Error::New(info.Env(), "drawImage: image loading disabled in this build."); @@ -1212,6 +1379,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); @@ -1226,108 +1398,193 @@ 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) { - const auto imgWidth = static_cast(srcWidth); - const auto imgHeight = static_cast(srcHeight); - - if (info.Length() == 3) + 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 (arguments.size() == 2) { - const auto dx = static_cast(info[1].As().Int32Value()); - const auto dy = static_cast(info[2].As().Int32Value()); + dx = arguments[0]; + dy = arguments[1]; + } + else if (arguments.size() == 4) + { + dx = arguments[0]; + dy = arguments[1]; + dWidth = arguments[2]; + dHeight = arguments[3]; + } + else + { + sx = arguments[0]; + sy = arguments[1]; + sWidth = arguments[2]; + sHeight = arguments[3]; + dx = arguments[4]; + dy = arguments[5]; + dWidth = arguments[6]; + dHeight = arguments[7]; + } - // 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); + 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 std::nullopt; + } - // See FillRect: clipping is a scissor, so the path must always be reset. - ResetPathState(); + // 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; + } - nvgRect(*m_nvg, dx, dy, imgWidth, imgHeight); - nvgFillPaint(*m_nvg, imagePaint); - SetFilterStack(); - nvgFill(*m_nvg); + if (sWidth == 0.0 || sHeight == 0.0 || dWidth == 0.0 || dHeight == 0.0) + { + return std::nullopt; + } - BlitPixelsToCpu(srcPixels, srcWidth, srcHeight, 0, 0, srcWidth, srcHeight, - static_cast(dx), static_cast(dy), srcWidth, srcHeight); + const double sourceRight = sx + sWidth; + const double sourceBottom = sy + sHeight; + if (!std::isfinite(sourceRight) || !std::isfinite(sourceBottom)) + { + return std::nullopt; } - else if (info.Length() == 5) + + 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) { - 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 std::nullopt; + } - // 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 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; - const auto dx = static_cast(dxInt); - const auto dy = static_cast(dyInt); - const auto dWidth = static_cast(dWidthInt); - const auto dHeight = static_cast(dHeightInt); + // 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 std::nullopt; + } - 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 std::nullopt; + } - // See FillRect: clipping is a scissor, so the path must always be reset. - ResetPathState(); + return DrawImageRectangles{ + drawX, drawY, drawWidth, drawHeight, + static_cast(patternX), static_cast(patternY), paintWidth, paintHeight}; + } - nvgRect(*m_nvg, dx, dy, dWidth, dHeight); + void Context::DrawImageCommon(int imageIndex, const DrawImageRectangles& rectangles) + { + NVGpaint imagePaint = nvgImagePattern( + *m_nvg, + rectangles.PatternX, + rectangles.PatternY, + rectangles.PatternWidth, + rectangles.PatternHeight, + 0.f, + imageIndex, + 1.f); + + 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, rectangles.X, rectangles.Y, rectangles.Width, rectangles.Height); 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, rectangles.X, rectangles.Y, rectangles.Width, rectangles.Height); nvgFillPaint(*m_nvg, imagePaint); SetFilterStack(); nvgFill(*m_nvg); + } + } - 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) @@ -1405,8 +1662,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 784864804a..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,9 +33,17 @@ 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. Shared by all Canvas pixel-readback APIs. + 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&); @@ -103,6 +115,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(); + void RetainImageUntilFlush(int imageIndex); + void ReleaseImagesAfterFlush(); NativeCanvas* m_canvas; std::shared_ptr m_nvg; @@ -184,6 +200,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; @@ -198,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, int32_t sx, int32_t sy, uint32_t sw, uint32_t sh, int32_t dx, int32_t dy, uint32_t dw, uint32_t 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/MeasureText.cpp b/Polyfills/Canvas/Source/MeasureText.cpp index 1f2ec47683..dc7d782ab2 100644 --- a/Polyfills/Canvas/Source/MeasureText.cpp +++ b/Polyfills/Canvas/Source/MeasureText.cpp @@ -19,21 +19,26 @@ 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. + // 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}; - 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]); + // 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, 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("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])); diff --git a/Polyfills/Canvas/Source/nanovg/fontstash.h b/Polyfills/Canvas/Source/nanovg/fontstash.h index a668496117..cd78be0dd0 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) @@ -214,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; @@ -304,9 +313,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 +967,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 +994,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. + // 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 +1531,13 @@ void fonsDrawDebug(FONScontext* stash, float x, float y) fons__flush(stash); } +// 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 +#endif + float fonsTextBounds(FONScontext* stash, float x, float y, const char* str, const char* end, @@ -1528,6 +1555,7 @@ float fonsTextBounds(FONScontext* stash, FONSfont* font; float startx, advance; float minx, miny, maxx, maxy; + int hasInk = 0; if (stash == NULL) return 0; if (state->font < 0 || state->font >= stash->nfonts) return 0; @@ -1539,6 +1567,7 @@ float fonsTextBounds(FONScontext* stash, // Align vertically. 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; miny = maxy = y; startx = x; @@ -1551,15 +1580,36 @@ 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; + const float padding = FONS_TEXT_BOUNDS_PADDING + (float)glyph->blur + 1.0f; 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; + // getQuad removes one of getGlyph's (blur + 2) border pixels. + qx0 = q.x0 + padding; + qx1 = q.x1 - padding; 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 + padding; + qy1 = q.y1 - padding; + 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 { - 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 - padding; + qy1 = q.y1 + padding; + 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; + } } } prevGlyphIndex = glyph != NULL ? glyph->index : -1; 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. diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index f767282baa..6e4941740d 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,15 @@ namespace ); } - return bgfx::isValid(tex->id) ? tex->id.idx : 0; + if (!bgfx::isValid(tex->id)) + { + return 0; + } + + // NanoVG-created textures retain their existing bgfx-index image IDs. + // nvgCreateImageFromHandle uses positive IDs above the uint16_t bgfx handle range. + tex->image = static_cast(tex->id.idx); + return tex->image; } static int nvgRenderDeleteTexture(void* _userPtr, int image) @@ -1016,7 +1025,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,26 +1353,60 @@ namespace return; } - // gl->prog.idx can be 0 is a context is destroyed without a call to flush - if (gl->prog.idx) + // 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)) { 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); } @@ -1403,6 +1446,24 @@ 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; + gl->textureId = static_cast(bgfx::kInvalidHandle); bx::memSet(¶ms, 0, sizeof(params) ); params.renderCreate = nvgRenderCreate; @@ -1463,5 +1524,31 @@ 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) +{ + 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; + // 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; } 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 diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp index b9859b50b8..9a19aaa873 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.cpp @@ -48,11 +48,15 @@ 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(); s_gaussBlurProg = bgfx::createProgram( bgfx::createEmbeddedShader(s_embeddedShadersFilterStack, type, "vs_fspass") @@ -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..8a330d3993 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h +++ b/Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h @@ -10,16 +10,23 @@ 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 + + // 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::UniformHandle u_direction; bgfx::UniformHandle u_weights; - } static m_uniforms; + }; + + inline static bgfx::ProgramHandle s_gaussBlurProg; + inline static bgfx::ProgramHandle s_boxBlurProg; + inline static int s_bgfxRefCount = 0; + inline static Uniforms m_uniforms{}; void AddSepia(float strength) {} void AddContrast(float strength) {} @@ -37,9 +44,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);