diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 4a9eaff07..40ae352e0 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -24,7 +24,7 @@ env: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 env: CC: ${{ inputs.cc }} CXX: ${{ inputs.cxx }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a466d0c0a..15d9acc0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,101 +2,13 @@ name: CI on: push: - branches: [master] + branches: [master, unittest-watchdog] pull_request: branches: [master] jobs: - # ── Apple: macOS ────────────────────────────────────────────── - MacOS: - uses: ./.github/workflows/build-macos.yml - - MacOS_Ninja: - uses: ./.github/workflows/build-macos.yml - with: - generator: 'Ninja Multi-Config' - - MacOS_Sanitizers: - uses: ./.github/workflows/build-macos.yml - with: - enable-sanitizers: true - - # ── Apple: iOS ──────────────────────────────────────────────── - iOS_iOS180: - uses: ./.github/workflows/build-ios.yml - with: - deployment-target: '18.0' - - iOS_iOS175: - uses: ./.github/workflows/build-ios.yml - with: - deployment-target: '17.5' - - # ── Apple: Xcode 26 ────────────────────────────────────────── - MacOS_Xcode26: - uses: ./.github/workflows/build-macos.yml - with: - xcode-version: '26.4' - runs-on: macos-26 - - iOS_Xcode26: - uses: ./.github/workflows/build-ios.yml - with: - deployment-target: '26.0' - xcode-version: '26.4' - runs-on: macos-26 - - # ── Win32───────────────────────────────────────────────────── - Win32_x64_D3D11: - uses: ./.github/workflows/build-win32.yml - with: - platform: x64 - - Win32_x64_JSI_D3D11: - uses: ./.github/workflows/build-win32.yml - with: - platform: x64 - napi-type: jsi - - Win32_x64_V8_D3D11: - uses: ./.github/workflows/build-win32.yml - with: - platform: x64 - napi-type: V8 - - Win32_x64_D3D11_Sanitizers: - uses: ./.github/workflows/build-win32.yml - with: - platform: x64 - enable-sanitizers: true - - Win32_x64_D3D12: - uses: ./.github/workflows/build-win32.yml - with: - platform: x64 - graphics-api: D3D12 - - Win32_x64_D3D11_PrecompiledShaderTest: - uses: ./.github/workflows/build-win32-shader.yml - - # ── UWP ─────────────────────────────────────────────────────── - UWP_x64: - uses: ./.github/workflows/build-uwp.yml - with: - platform: x64 - - UWP_arm64: - uses: ./.github/workflows/build-uwp.yml - with: - platform: arm64 - - UWP_arm64_JSI: - uses: ./.github/workflows/build-uwp.yml - with: - platform: arm64 - napi-type: jsi - - # ── Ubuntu / Linux ──────────────────────────────────────────── + # TEMP: Linux-JSC-only while validating LIBGL_ALWAYS_SOFTWARE / GALLIUM_DRIVER fix. + # Restore from master before merging anywhere. Ubuntu_Clang_JSC: uses: ./.github/workflows/build-linux.yml with: @@ -109,44 +21,4 @@ jobs: with: cc: gcc cxx: g++ - js-engine: JavaScriptCore - - # ── Android ─────────────────────────────────────────────────── - Android_Ubuntu_JSC: - uses: ./.github/workflows/build-android.yml - with: - runs-on: ubuntu-latest - js-engine: JavaScriptCore - - Android_Ubuntu_V8: - uses: ./.github/workflows/build-android.yml - with: - runs-on: ubuntu-latest - js-engine: V8 - - Android_MacOS_JSC: - uses: ./.github/workflows/build-android.yml - with: - runs-on: macos-latest - js-engine: JavaScriptCore - - Android_MacOS_V8: - uses: ./.github/workflows/build-android.yml - with: - runs-on: macos-latest - js-engine: V8 - - # ── Installation Tests ──────────────────────────────────────── - iOS_Installation: - uses: ./.github/workflows/test-install-ios.yml - with: - deployment-target: '17.2' - - Linux_Installation: - uses: ./.github/workflows/test-install-linux.yml - - MacOS_Installation: - uses: ./.github/workflows/test-install-macos.yml - - Win32_Installation: - uses: ./.github/workflows/test-install-win32.yml + js-engine: JavaScriptCore \ No newline at end of file diff --git a/Apps/HeadlessScreenshotApp/Win32/App.cpp b/Apps/HeadlessScreenshotApp/Win32/App.cpp index c89d59b74..64b90b690 100644 --- a/Apps/HeadlessScreenshotApp/Win32/App.cpp +++ b/Apps/HeadlessScreenshotApp/Win32/App.cpp @@ -127,41 +127,37 @@ int main() // Create a render target texture for the output. winrt::com_ptr outputTexture = CreateD3DRenderTargetTexture(d3dDevice.get()); - std::promise addToContext{}; + // Close the script-load frame. + deviceUpdate.Finish(); + device.FinishRenderingCurrentFrame(); + + // Open a new frame for `startup` so the JS-side resource creation and + // startup() call run in the same frame as the wait that observes them. + device.StartRenderingCurrentFrame(); + deviceUpdate.Start(); + std::promise startup{}; // Create an external texture for the render target texture and pass it to // the `startup` JavaScript function. - loader.Dispatch([externalTexture = Babylon::Plugins::ExternalTexture{outputTexture.get()}, &addToContext, &startup](Napi::Env env) { - auto jsPromise = externalTexture.AddToContextAsync(env); - addToContext.set_value(); - - auto jsOnFulfilled = Napi::Function::New(env, [&startup](const Napi::CallbackInfo& info) { - auto nativeTexture = info[0]; - info.Env().Global().Get("startup").As().Call( - { - nativeTexture, - Napi::Value::From(info.Env(), WIDTH), - Napi::Value::From(info.Env(), HEIGHT), - }); - startup.set_value(); - }); - - jsPromise = jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled}).As(); - - CatchAndLogError(jsPromise); + loader.Dispatch([externalTexture = Babylon::Plugins::ExternalTexture{outputTexture.get()}, &startup](Napi::Env env) { + auto nativeTexture = externalTexture.CreateForJavaScript(env); + env.Global().Get("startup").As().Call( + { + nativeTexture, + Napi::Value::From(env, WIDTH), + Napi::Value::From(env, HEIGHT), + }); + startup.set_value(); }); - // Wait for `AddToContextAsync` to be called. - addToContext.get_future().wait(); + // Wait for `startup` to finish. + startup.get_future().wait(); - // Render a frame so that `AddToContextAsync` will complete. + // Close the startup frame. deviceUpdate.Finish(); device.FinishRenderingCurrentFrame(); - // Wait for `startup` to finish. - startup.get_future().wait(); - struct Asset { const char* Name; diff --git a/Apps/PrecompiledShaderTest/Source/App.cpp b/Apps/PrecompiledShaderTest/Source/App.cpp index 1ae0dee10..fe2c132c0 100644 --- a/Apps/PrecompiledShaderTest/Source/App.cpp +++ b/Apps/PrecompiledShaderTest/Source/App.cpp @@ -135,40 +135,37 @@ int RunApp( Babylon::ScriptLoader loader{runtime}; loader.LoadScript("app:///index.js"); - std::promise addToContext{}; + // Close the script-load frame. + deviceUpdate.Finish(); + device.FinishRenderingCurrentFrame(); + + // Open a new frame for `startup` so the JS-side resource creation and + // startup() call run in the same frame as the wait that observes them. + device.StartRenderingCurrentFrame(); + deviceUpdate.Start(); + std::promise startup{}; // Create an external texture for the render target texture and pass it to // the `startup` JavaScript function. - loader.Dispatch([externalTexture = std::move(externalTexture), &addToContext, &startup](Napi::Env env) { - auto jsPromise = externalTexture.AddToContextAsync(env); - addToContext.set_value(); - - auto jsOnFulfilled = Napi::Function::New(env, [&startup](const Napi::CallbackInfo& info) { - auto nativeTexture = info[0]; - info.Env().Global().Get("startup").As().Call( - { - nativeTexture, - Napi::Value::From(info.Env(), WIDTH), - Napi::Value::From(info.Env(), HEIGHT), - }); - startup.set_value(); - }); - - jsPromise = jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled}).As(); - CatchAndLogError(jsPromise); + loader.Dispatch([externalTexture = std::move(externalTexture), &startup](Napi::Env env) { + auto nativeTexture = externalTexture.CreateForJavaScript(env); + env.Global().Get("startup").As().Call( + { + nativeTexture, + Napi::Value::From(env, WIDTH), + Napi::Value::From(env, HEIGHT), + }); + startup.set_value(); }); - // Wait for `AddToContextAsync` to be called. - addToContext.get_future().wait(); + // Wait for `startup` to finish. + startup.get_future().wait(); - // Render a frame so that `AddToContextAsync` will complete. + // Close the startup frame. deviceUpdate.Finish(); device.FinishRenderingCurrentFrame(); - // Wait for `startup` to finish. - startup.get_future().wait(); - // Start a new frame for rendering the scene. device.StartRenderingCurrentFrame(); deviceUpdate.Start(); diff --git a/Apps/StyleTransferApp/Win32/App.cpp b/Apps/StyleTransferApp/Win32/App.cpp index 3688a9752..a32178863 100644 --- a/Apps/StyleTransferApp/Win32/App.cpp +++ b/Apps/StyleTransferApp/Win32/App.cpp @@ -334,37 +334,37 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, loader.LoadScript("app:///Scripts/babylonjs.loaders.js"); loader.LoadScript("app:///Scripts/index.js"); - std::promise addToContext{}; + // Close the script-load frame. + g_update->Finish(); + g_device->FinishRenderingCurrentFrame(); + + // Open a new frame for `startup` so the JS-side resource creation and + // startup() call run in the same frame as the wait that observes them. + g_device->StartRenderingCurrentFrame(); + g_update->Start(); + std::promise startup{}; // Create an external texture for the render target texture and pass it to // the `startup` JavaScript function. - loader.Dispatch([externalTexture = Babylon::Plugins::ExternalTexture{g_BabylonRenderTexture.get()}, &addToContext, &startup](Napi::Env env) { - auto jsPromise = externalTexture.AddToContextAsync(env); - addToContext.set_value(); - - jsPromise.Get("then").As().Call(jsPromise, {Napi::Function::New(env, [&startup](const Napi::CallbackInfo& info) { - auto nativeTexture = info[0]; - info.Env().Global().Get("startup").As().Call( - { - nativeTexture, - Napi::Value::From(info.Env(), WIDTH), - Napi::Value::From(info.Env(), HEIGHT), - }); - startup.set_value(); - })}); + loader.Dispatch([externalTexture = Babylon::Plugins::ExternalTexture{g_BabylonRenderTexture.get()}, &startup](Napi::Env env) { + auto nativeTexture = externalTexture.CreateForJavaScript(env); + env.Global().Get("startup").As().Call( + { + nativeTexture, + Napi::Value::From(env, WIDTH), + Napi::Value::From(env, HEIGHT), + }); + startup.set_value(); }); - // Wait for `AddToContextAsync` to be called. - addToContext.get_future().wait(); + // Wait for `startup` to finish. + startup.get_future().wait(); - // Render a frame so that `AddToContextAsync` will complete. + // Close the startup frame. g_update->Finish(); g_device->FinishRenderingCurrentFrame(); - // Wait for `startup` to finish. - startup.get_future().wait(); - // --------------------------- Rendering loop ------------------------- HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_PLAYGROUNDWIN32)); diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index ae1fb713e..1aa7073d7 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -11,6 +11,7 @@ set(BABYLONJS_MATERIALS_ASSETS "../node_modules/babylonjs-materials/babylonjs.materials.js") set(TEST_ASSETS + "JavaScript/dist/tests.externalTexture.render.js" "JavaScript/dist/tests.javaScript.all.js" "JavaScript/dist/tests.shaderCache.basicScene.js") @@ -18,6 +19,7 @@ set(SOURCES "Source/App.h" "Source/App.cpp" "Source/Tests.ExternalTexture.cpp" + "Source/Tests.ExternalTexture.Render.cpp" "Source/Tests.JavaScript.cpp" "Source/Tests.ShaderCache.cpp" "Source/Tests.UniformPadding.cpp" @@ -26,8 +28,7 @@ set(SOURCES if(GRAPHICS_API STREQUAL "D3D11") set(SOURCES ${SOURCES} - "Source/Tests.Device.${GRAPHICS_API}.cpp" - "Source/Tests.ExternalTexture.${GRAPHICS_API}.cpp") + "Source/Tests.Device.${GRAPHICS_API}.cpp") endif() if(APPLE) @@ -43,7 +44,11 @@ elseif(UNIX AND NOT ANDROID) set(SOURCES ${SOURCES} "Source/App.X11.cpp") set(ADDITIONAL_COMPILE_DEFINITIONS PRIVATE SKIP_EXTERNAL_TEXTURE_TESTS) elseif(WIN32) - set(SOURCES ${SOURCES} "Source/App.Win32.cpp") + set(SOURCES ${SOURCES} + "Source/App.Win32.cpp" + "Source/RenderDoc.h" + "Source/RenderDoc.cpp") + set(ADDITIONAL_COMPILE_DEFINITIONS ${ADDITIONAL_COMPILE_DEFINITIONS} PRIVATE HAS_RENDERDOC) endif() add_executable(UnitTests ${BABYLONJS_ASSETS} ${BABYLONJS_MATERIALS_ASSETS} ${TEST_ASSETS} ${SOURCES}) @@ -65,6 +70,10 @@ target_link_libraries(UnitTests target_compile_definitions(UnitTests PRIVATE ${ADDITIONAL_COMPILE_DEFINITIONS}) +if(GRAPHICS_API STREQUAL "D3D12") + target_compile_definitions(UnitTests PRIVATE SKIP_RENDER_TESTS) +endif() + add_test(NAME UnitTests COMMAND UnitTests) # See https://gitlab.kitware.com/cmake/cmake/-/issues/23543 diff --git a/Apps/UnitTests/JavaScript/dist/tests.externalTexture.render.js b/Apps/UnitTests/JavaScript/dist/tests.externalTexture.render.js new file mode 100644 index 000000000..f499c3f79 --- /dev/null +++ b/Apps/UnitTests/JavaScript/dist/tests.externalTexture.render.js @@ -0,0 +1,639 @@ +/******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ "@babylonjs/core" +/*!**************************!*\ + !*** external "BABYLON" ***! + \**************************/ +(module) { + +"use strict"; +module.exports = BABYLON; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/OverloadYield.js" +/*!******************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/OverloadYield.js ***! + \******************************************************************/ +(module) { + +function _OverloadYield(e, d) { + this.v = e, this.k = d; +} +module.exports = _OverloadYield, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regenerator.js" +/*!****************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regenerator.js ***! + \****************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var regeneratorDefine = __webpack_require__(/*! ./regeneratorDefine.js */ "../../node_modules/@babel/runtime/helpers/regeneratorDefine.js"); +function _regenerator() { + /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ + var e, + t, + r = "function" == typeof Symbol ? Symbol : {}, + n = r.iterator || "@@iterator", + o = r.toStringTag || "@@toStringTag"; + function i(r, n, o, i) { + var c = n && n.prototype instanceof Generator ? n : Generator, + u = Object.create(c.prototype); + return regeneratorDefine(u, "_invoke", function (r, n, o) { + var i, + c, + u, + f = 0, + p = o || [], + y = !1, + G = { + p: 0, + n: 0, + v: e, + a: d, + f: d.bind(e, 4), + d: function d(t, r) { + return i = t, c = 0, u = e, G.n = r, a; + } + }; + function d(r, n) { + for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { + var o, + i = p[t], + d = G.p, + l = i[2]; + r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); + } + if (o || r > 1) return a; + throw y = !0, n; + } + return function (o, p, l) { + if (f > 1) throw TypeError("Generator is already running"); + for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { + i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); + try { + if (f = 2, i) { + if (c || (o = "next"), t = i[o]) { + if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); + if (!t.done) return t; + u = t.value, c < 2 && (c = 0); + } else 1 === c && (t = i["return"]) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); + i = e; + } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; + } catch (t) { + i = e, c = 1, u = t; + } finally { + f = 1; + } + } + return { + value: t, + done: y + }; + }; + }(r, o, i), !0), u; + } + var a = {}; + function Generator() {} + function GeneratorFunction() {} + function GeneratorFunctionPrototype() {} + t = Object.getPrototypeOf; + var c = [][n] ? t(t([][n]())) : (regeneratorDefine(t = {}, n, function () { + return this; + }), t), + u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); + function f(e) { + return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, regeneratorDefine(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; + } + return GeneratorFunction.prototype = GeneratorFunctionPrototype, regeneratorDefine(u, "constructor", GeneratorFunctionPrototype), regeneratorDefine(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", regeneratorDefine(GeneratorFunctionPrototype, o, "GeneratorFunction"), regeneratorDefine(u), regeneratorDefine(u, o, "Generator"), regeneratorDefine(u, n, function () { + return this; + }), regeneratorDefine(u, "toString", function () { + return "[object Generator]"; + }), (module.exports = _regenerator = function _regenerator() { + return { + w: i, + m: f + }; + }, module.exports.__esModule = true, module.exports["default"] = module.exports)(); +} +module.exports = _regenerator, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorAsync.js" +/*!*********************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorAsync.js ***! + \*********************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var regeneratorAsyncGen = __webpack_require__(/*! ./regeneratorAsyncGen.js */ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncGen.js"); +function _regeneratorAsync(n, e, r, t, o) { + var a = regeneratorAsyncGen(n, e, r, t, o); + return a.next().then(function (n) { + return n.done ? n.value : a.next(); + }); +} +module.exports = _regeneratorAsync, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncGen.js" +/*!************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorAsyncGen.js ***! + \************************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var regenerator = __webpack_require__(/*! ./regenerator.js */ "../../node_modules/@babel/runtime/helpers/regenerator.js"); +var regeneratorAsyncIterator = __webpack_require__(/*! ./regeneratorAsyncIterator.js */ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncIterator.js"); +function _regeneratorAsyncGen(r, e, t, o, n) { + return new regeneratorAsyncIterator(regenerator().w(r, e, t, o), n || Promise); +} +module.exports = _regeneratorAsyncGen, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncIterator.js" +/*!*****************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorAsyncIterator.js ***! + \*****************************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var OverloadYield = __webpack_require__(/*! ./OverloadYield.js */ "../../node_modules/@babel/runtime/helpers/OverloadYield.js"); +var regeneratorDefine = __webpack_require__(/*! ./regeneratorDefine.js */ "../../node_modules/@babel/runtime/helpers/regeneratorDefine.js"); +function AsyncIterator(t, e) { + function n(r, o, i, f) { + try { + var c = t[r](o), + u = c.value; + return u instanceof OverloadYield ? e.resolve(u.v).then(function (t) { + n("next", t, i, f); + }, function (t) { + n("throw", t, i, f); + }) : e.resolve(u).then(function (t) { + c.value = t, i(c); + }, function (t) { + return n("throw", t, i, f); + }); + } catch (t) { + f(t); + } + } + var r; + this.next || (regeneratorDefine(AsyncIterator.prototype), regeneratorDefine(AsyncIterator.prototype, "function" == typeof Symbol && Symbol.asyncIterator || "@asyncIterator", function () { + return this; + })), regeneratorDefine(this, "_invoke", function (t, o, i) { + function f() { + return new e(function (e, r) { + n(t, i, e, r); + }); + } + return r = r ? r.then(f, f) : f(); + }, !0); +} +module.exports = AsyncIterator, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorDefine.js" +/*!**********************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorDefine.js ***! + \**********************************************************************/ +(module) { + +function _regeneratorDefine(e, r, n, t) { + var i = Object.defineProperty; + try { + i({}, "", {}); + } catch (e) { + i = 0; + } + module.exports = _regeneratorDefine = function regeneratorDefine(e, r, n, t) { + function o(r, n) { + _regeneratorDefine(e, r, function (e) { + return this._invoke(r, n, e); + }); + } + r ? i ? i(e, r, { + value: n, + enumerable: !t, + configurable: !t, + writable: !t + }) : e[r] = n : (o("next", 0), o("throw", 1), o("return", 2)); + }, module.exports.__esModule = true, module.exports["default"] = module.exports, _regeneratorDefine(e, r, n, t); +} +module.exports = _regeneratorDefine, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorKeys.js" +/*!********************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorKeys.js ***! + \********************************************************************/ +(module) { + +function _regeneratorKeys(e) { + var n = Object(e), + r = []; + for (var t in n) r.unshift(t); + return function e() { + for (; r.length;) if ((t = r.pop()) in n) return e.value = t, e.done = !1, e; + return e.done = !0, e; + }; +} +module.exports = _regeneratorKeys, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorRuntime.js" +/*!***********************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorRuntime.js ***! + \***********************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var OverloadYield = __webpack_require__(/*! ./OverloadYield.js */ "../../node_modules/@babel/runtime/helpers/OverloadYield.js"); +var regenerator = __webpack_require__(/*! ./regenerator.js */ "../../node_modules/@babel/runtime/helpers/regenerator.js"); +var regeneratorAsync = __webpack_require__(/*! ./regeneratorAsync.js */ "../../node_modules/@babel/runtime/helpers/regeneratorAsync.js"); +var regeneratorAsyncGen = __webpack_require__(/*! ./regeneratorAsyncGen.js */ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncGen.js"); +var regeneratorAsyncIterator = __webpack_require__(/*! ./regeneratorAsyncIterator.js */ "../../node_modules/@babel/runtime/helpers/regeneratorAsyncIterator.js"); +var regeneratorKeys = __webpack_require__(/*! ./regeneratorKeys.js */ "../../node_modules/@babel/runtime/helpers/regeneratorKeys.js"); +var regeneratorValues = __webpack_require__(/*! ./regeneratorValues.js */ "../../node_modules/@babel/runtime/helpers/regeneratorValues.js"); +function _regeneratorRuntime() { + "use strict"; + + var r = regenerator(), + e = r.m(_regeneratorRuntime), + t = (Object.getPrototypeOf ? Object.getPrototypeOf(e) : e.__proto__).constructor; + function n(r) { + var e = "function" == typeof r && r.constructor; + return !!e && (e === t || "GeneratorFunction" === (e.displayName || e.name)); + } + var o = { + "throw": 1, + "return": 2, + "break": 3, + "continue": 3 + }; + function a(r) { + var e, t; + return function (n) { + e || (e = { + stop: function stop() { + return t(n.a, 2); + }, + "catch": function _catch() { + return n.v; + }, + abrupt: function abrupt(r, e) { + return t(n.a, o[r], e); + }, + delegateYield: function delegateYield(r, o, a) { + return e.resultName = o, t(n.d, regeneratorValues(r), a); + }, + finish: function finish(r) { + return t(n.f, r); + } + }, t = function t(r, _t, o) { + n.p = e.prev, n.n = e.next; + try { + return r(_t, o); + } finally { + e.next = n.n; + } + }), e.resultName && (e[e.resultName] = n.v, e.resultName = void 0), e.sent = n.v, e.next = n.n; + try { + return r.call(this, e); + } finally { + n.p = e.prev, n.n = e.next; + } + }; + } + return (module.exports = _regeneratorRuntime = function _regeneratorRuntime() { + return { + wrap: function wrap(e, t, n, o) { + return r.w(a(e), t, n, o && o.reverse()); + }, + isGeneratorFunction: n, + mark: r.m, + awrap: function awrap(r, e) { + return new OverloadYield(r, e); + }, + AsyncIterator: regeneratorAsyncIterator, + async: function async(r, e, t, o, u) { + return (n(e) ? regeneratorAsyncGen : regeneratorAsync)(a(r), e, t, o, u); + }, + keys: regeneratorKeys, + values: regeneratorValues + }; + }, module.exports.__esModule = true, module.exports["default"] = module.exports)(); +} +module.exports = _regeneratorRuntime, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/regeneratorValues.js" +/*!**********************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/regeneratorValues.js ***! + \**********************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +var _typeof = (__webpack_require__(/*! ./typeof.js */ "../../node_modules/@babel/runtime/helpers/typeof.js")["default"]); +function _regeneratorValues(e) { + if (null != e) { + var t = e["function" == typeof Symbol && Symbol.iterator || "@@iterator"], + r = 0; + if (t) return t.call(e); + if ("function" == typeof e.next) return e; + if (!isNaN(e.length)) return { + next: function next() { + return e && r >= e.length && (e = void 0), { + value: e && e[r++], + done: !e + }; + } + }; + } + throw new TypeError(_typeof(e) + " is not iterable"); +} +module.exports = _regeneratorValues, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/typeof.js" +/*!***********************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/typeof.js ***! + \***********************************************************/ +(module) { + +function _typeof(o) { + "@babel/helpers - typeof"; + + return module.exports = _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { + return typeof o; + } : function (o) { + return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; + }, module.exports.__esModule = true, module.exports["default"] = module.exports, _typeof(o); +} +module.exports = _typeof, module.exports.__esModule = true, module.exports["default"] = module.exports; + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/regenerator/index.js" +/*!**************************************************************!*\ + !*** ../../node_modules/@babel/runtime/regenerator/index.js ***! + \**************************************************************/ +(module, __unused_webpack_exports, __webpack_require__) { + +// TODO(Babel 8): Remove this file. + +var runtime = __webpack_require__(/*! ../helpers/regeneratorRuntime */ "../../node_modules/@babel/runtime/helpers/regeneratorRuntime.js")(); +module.exports = runtime; + +// Copied from https://github.com/facebook/regenerator/blob/main/packages/runtime/runtime.js#L736= +try { + regeneratorRuntime = runtime; +} catch (accidentalStrictMode) { + if (typeof globalThis === "object") { + globalThis.regeneratorRuntime = runtime; + } else { + Function("r", "regeneratorRuntime = r")(runtime); + } +} + + +/***/ }, + +/***/ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js" +/*!*************************************************************************!*\ + !*** ../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js ***! + \*************************************************************************/ +(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "default": () => (/* binding */ _asyncToGenerator) +/* harmony export */ }); +function asyncGeneratorStep(n, t, e, r, o, a, c) { + try { + var i = n[a](c), + u = i.value; + } catch (n) { + return void e(n); + } + i.done ? t(u) : Promise.resolve(u).then(r, o); +} +function _asyncToGenerator(n) { + return function () { + var t = this, + e = arguments; + return new Promise(function (r, o) { + var a = n.apply(t, e); + function _next(n) { + asyncGeneratorStep(a, r, o, _next, _throw, "next", n); + } + function _throw(n) { + asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); + } + _next(void 0); + }); + }; +} + + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Check if module exists (development only) +/******/ if (__webpack_modules__[moduleId] === undefined) { +/******/ var e = new Error("Cannot find module '" + moduleId + "'"); +/******/ e.code = 'MODULE_NOT_FOUND'; +/******/ throw e; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry needs to be wrapped in an IIFE because it needs to be in strict mode. +(() => { +"use strict"; +/*!*********************************************!*\ + !*** ./src/tests.externalTexture.render.ts ***! + \*********************************************/ +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var _babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime/helpers/asyncToGenerator */ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @babel/runtime/regenerator */ "../../node_modules/@babel/runtime/regenerator/index.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @babylonjs/core */ "@babylonjs/core"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_2__); + + +var vertexShader = "\n precision highp float;\n attribute vec3 position;\n attribute vec2 uv;\n uniform mat4 worldViewProjection;\n varying vec2 vUV;\n void main(void) {\n gl_Position = worldViewProjection * vec4(position, 1.0);\n vUV = uv;\n }\n"; + + + + + + + + + + + +var fragmentShader = "\n precision highp float;\n precision highp sampler2DArray;\n uniform sampler2DArray textureArraySampler;\n uniform float sliceIndex;\n varying vec2 vUV;\n void main(void) {\n gl_FragColor = texture(textureArraySampler, vec3(vUV, sliceIndex));\n }\n"; + + + + + + + + + + +var engine; +var scene; +var material; + +function startup( +inputNativeTexture, +outputNativeTexture, +width, +height) +{ + engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.NativeEngine(); + delete engine.getCaps().parallelShaderCompile; + scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Scene(engine); + + // Wrap the output texture as a render target. + var outputTexture = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.RenderTargetTexture( + "output", + { width: width, height: height }, + scene, + { + colorAttachment: engine.wrapNativeTexture(outputNativeTexture), + generateDepthBuffer: true, + generateStencilBuffer: false + } + ); + + // Orthographic camera filling the viewport exactly. + var camera = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.FreeCamera("camera", new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Vector3(0, 0, -1), scene); + camera.setTarget(_babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Vector3.Zero()); + camera.mode = _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Camera.ORTHOGRAPHIC_CAMERA; + camera.orthoTop = 1; + camera.orthoBottom = -1; + camera.orthoLeft = -1; + camera.orthoRight = 1; + camera.outputRenderTarget = outputTexture; + + // Fullscreen quad. + var quad = _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.MeshBuilder.CreatePlane("quad", { size: 2 }, scene); + + // Shader material that samples from a texture array. + material = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.ShaderMaterial( + "textureArrayShader", + scene, + { vertexSource: vertexShader, fragmentSource: fragmentShader }, + { + attributes: ["position", "uv"], + uniforms: ["worldViewProjection", "sliceIndex"], + samplers: ["textureArraySampler"] + } + ); + + material.onError = function (_effect, errors) { + console.error("ShaderMaterial compilation error: " + errors); + }; + + material.backFaceCulling = false; + material.depthFunction = _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Constants.ALWAYS; + + // Wrap the input texture array and bind it to the shader. + var internalTex = engine.wrapNativeTexture(inputNativeTexture); + var inputTexWrapper = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_2__.Texture(null, scene); + inputTexWrapper._texture = internalTex; + material.setTexture("textureArraySampler", inputTexWrapper); + material.setFloat("sliceIndex", 0); + + quad.material = material; +}function + +renderSlice(_x) {return _renderSlice.apply(this, arguments);}function _renderSlice() {_renderSlice = (0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee(sliceIndex) {return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: + material.setFloat("sliceIndex", sliceIndex);_context.next = 1;return ( + scene.whenReadyAsync());case 1: + scene.render();case 2:case "end":return _context.stop();}}, _callee);}));return _renderSlice.apply(this, arguments);} + + +globalThis.startup = startup; +globalThis.renderSlice = renderSlice; +})(); + +/******/ })() +; \ No newline at end of file diff --git a/Apps/UnitTests/JavaScript/src/tests.externalTexture.render.ts b/Apps/UnitTests/JavaScript/src/tests.externalTexture.render.ts new file mode 100644 index 000000000..044381d9c --- /dev/null +++ b/Apps/UnitTests/JavaScript/src/tests.externalTexture.render.ts @@ -0,0 +1,112 @@ +import { + Camera, + Constants, + FreeCamera, + MeshBuilder, + NativeEngine, + RenderTargetTexture, + Scene, + ShaderMaterial, + Texture, + Vector3, +} from "@babylonjs/core"; + +const vertexShader = ` + precision highp float; + attribute vec3 position; + attribute vec2 uv; + uniform mat4 worldViewProjection; + varying vec2 vUV; + void main(void) { + gl_Position = worldViewProjection * vec4(position, 1.0); + vUV = uv; + } +`; + +const fragmentShader = ` + precision highp float; + precision highp sampler2DArray; + uniform sampler2DArray textureArraySampler; + uniform float sliceIndex; + varying vec2 vUV; + void main(void) { + gl_FragColor = texture(textureArraySampler, vec3(vUV, sliceIndex)); + } +`; + +let engine: NativeEngine; +let scene: Scene; +let material: ShaderMaterial; + +function startup( + inputNativeTexture: any, + outputNativeTexture: any, + width: number, + height: number +): void { + engine = new NativeEngine(); + delete engine.getCaps().parallelShaderCompile; + scene = new Scene(engine); + + // Wrap the output texture as a render target. + const outputTexture = new RenderTargetTexture( + "output", + { width, height }, + scene, + { + colorAttachment: engine.wrapNativeTexture(outputNativeTexture), + generateDepthBuffer: true, + generateStencilBuffer: false, + } + ); + + // Orthographic camera filling the viewport exactly. + const camera = new FreeCamera("camera", new Vector3(0, 0, -1), scene); + camera.setTarget(Vector3.Zero()); + camera.mode = Camera.ORTHOGRAPHIC_CAMERA; + camera.orthoTop = 1; + camera.orthoBottom = -1; + camera.orthoLeft = -1; + camera.orthoRight = 1; + camera.outputRenderTarget = outputTexture; + + // Fullscreen quad. + const quad = MeshBuilder.CreatePlane("quad", { size: 2 }, scene); + + // Shader material that samples from a texture array. + material = new ShaderMaterial( + "textureArrayShader", + scene, + { vertexSource: vertexShader, fragmentSource: fragmentShader }, + { + attributes: ["position", "uv"], + uniforms: ["worldViewProjection", "sliceIndex"], + samplers: ["textureArraySampler"], + } + ); + + material.onError = (_effect, errors) => { + console.error("ShaderMaterial compilation error: " + errors); + }; + + material.backFaceCulling = false; + material.depthFunction = Constants.ALWAYS; + + // Wrap the input texture array and bind it to the shader. + const internalTex = engine.wrapNativeTexture(inputNativeTexture); + const inputTexWrapper = new Texture(null, scene); + inputTexWrapper._texture = internalTex; + material.setTexture("textureArraySampler", inputTexWrapper); + material.setFloat("sliceIndex", 0); + + quad.material = material; +} + +async function renderSlice(sliceIndex: number): Promise { + material.setFloat("sliceIndex", sliceIndex); + await scene.whenReadyAsync(); + scene.render(); +} + +(globalThis as any).startup = startup; +(globalThis as any).renderSlice = renderSlice; diff --git a/Apps/UnitTests/JavaScript/webpack.config.js b/Apps/UnitTests/JavaScript/webpack.config.js index 41667f4b0..c138e5dff 100644 --- a/Apps/UnitTests/JavaScript/webpack.config.js +++ b/Apps/UnitTests/JavaScript/webpack.config.js @@ -7,6 +7,7 @@ module.exports = { devtool: false, entry: { "tests.javaScript.all": './src/tests.javaScript.all.ts', + "tests.externalTexture.render": './src/tests.externalTexture.render.ts', "tests.shaderCache.basicScene": './src/tests.shaderCache.basicScene.ts', }, externals: { diff --git a/Apps/UnitTests/Source/RenderDoc.cpp b/Apps/UnitTests/Source/RenderDoc.cpp new file mode 100644 index 000000000..ac029acc8 --- /dev/null +++ b/Apps/UnitTests/Source/RenderDoc.cpp @@ -0,0 +1,62 @@ +#include "RenderDoc.h" + +#ifdef RENDERDOC + +#ifdef _WIN32 +#include +#elif defined(__linux__) +#include +#endif +#include "renderdoc_app.h" + +namespace +{ + RENDERDOC_API_1_1_2* rdoc_api = nullptr; +} + +#endif + +void RenderDoc::Init() +{ +#ifdef RENDERDOC +#ifdef _WIN32 + if (HMODULE mod = GetModuleHandleA("renderdoc.dll")) + { + pRENDERDOC_GetAPI RENDERDOC_GetAPI = (pRENDERDOC_GetAPI)GetProcAddress(mod, "RENDERDOC_GetAPI"); + int ret = RENDERDOC_GetAPI(eRENDERDOC_API_Version_1_1_2, (void**)&rdoc_api); + (void)ret; + } +#elif defined(__linux__) + if (void* mod = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD)) + { + pRENDERDOC_GetAPI RENDERDOC_GetAPI = (pRENDERDOC_GetAPI)dlsym(mod, "RENDERDOC_GetAPI"); + int ret = RENDERDOC_GetAPI(eRENDERDOC_API_Version_1_1_2, (void**)&rdoc_api); + (void)ret; + } +#endif +#endif +} + +void RenderDoc::StartFrameCapture(void* device) +{ +#ifdef RENDERDOC + if (rdoc_api) + { + rdoc_api->StartFrameCapture(device, nullptr); + } +#else + (void)device; +#endif +} + +void RenderDoc::StopFrameCapture(void* device) +{ +#ifdef RENDERDOC + if (rdoc_api) + { + rdoc_api->EndFrameCapture(device, nullptr); + } +#else + (void)device; +#endif +} diff --git a/Apps/UnitTests/Source/RenderDoc.h b/Apps/UnitTests/Source/RenderDoc.h new file mode 100644 index 000000000..a3dc84d92 --- /dev/null +++ b/Apps/UnitTests/Source/RenderDoc.h @@ -0,0 +1,13 @@ +#pragma once + +// To enable RenderDoc captures, uncomment the define below and ensure renderdoc_app.h is +// on the include path (e.g. copy it from the RenderDoc SDK into this directory, or add +// the SDK include directory to UnitTests via CMake). +// #define RENDERDOC + +namespace RenderDoc +{ + void Init(); + void StartFrameCapture(void* device = nullptr); + void StopFrameCapture(void* device = nullptr); +} diff --git a/Apps/UnitTests/Source/Tests.ExternalTexture.D3D11.cpp b/Apps/UnitTests/Source/Tests.ExternalTexture.D3D11.cpp deleted file mode 100644 index 24147e05e..000000000 --- a/Apps/UnitTests/Source/Tests.ExternalTexture.D3D11.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include - -#include "Utils.h" - -#include - -extern Babylon::Graphics::Configuration g_deviceConfig; - -TEST(ExternalTexture, AddToContextAsyncAndUpdateWithLayerIndex) -{ -#ifdef SKIP_EXTERNAL_TEXTURE_TESTS - GTEST_SKIP(); -#else - Babylon::Graphics::Device device{g_deviceConfig}; - Babylon::Graphics::DeviceUpdate update{device.GetUpdate("update")}; - - device.StartRenderingCurrentFrame(); - update.Start(); - - auto nativeTexture = CreateTestTexture(device.GetPlatformInfo().Device, 256, 256, 3); - - Babylon::Plugins::ExternalTexture externalTexture{nativeTexture}; - - std::promise addToContext{}; - std::promise promiseResolved{}; - - Babylon::AppRuntime runtime{}; - runtime.Dispatch([&device, &addToContext, &promiseResolved, externalTexture](Napi::Env env) { - device.AddToJavaScript(env); - - Babylon::Polyfills::Console::Initialize(env, [](const char* message, auto) { - std::cout << message << std::endl; - }); - - Babylon::Polyfills::Window::Initialize(env); - - Babylon::Plugins::NativeEngine::Initialize(env); - - // Test with explicit layer index 1 - auto jsPromise = externalTexture.AddToContextAsync(env, 1); - addToContext.set_value(); - - auto jsOnFulfilled = Napi::Function::New(env, [&promiseResolved](const Napi::CallbackInfo& info) { - promiseResolved.set_value(); - }); - - auto jsOnRejected = Napi::Function::New(env, [&promiseResolved](const Napi::CallbackInfo& info) { - promiseResolved.set_exception(std::make_exception_ptr(info[0].As())); - }); - - jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled, jsOnRejected}); - }); - - // Wait for AddToContextAsync to be called. - addToContext.get_future().wait(); - - // Render a frame so that AddToContextAsync will complete. - update.Finish(); - device.FinishRenderingCurrentFrame(); - - // Wait for promise to resolve. - promiseResolved.get_future().wait(); - - // Start a new frame. - device.StartRenderingCurrentFrame(); - update.Start(); - - // Update the external texture to a new texture with explicit layer index 2. - externalTexture.Update(nativeTexture, std::nullopt, 2); - - DestroyTestTexture(nativeTexture); - - update.Finish(); - device.FinishRenderingCurrentFrame(); -#endif -} diff --git a/Apps/UnitTests/Source/Tests.ExternalTexture.Render.cpp b/Apps/UnitTests/Source/Tests.ExternalTexture.Render.cpp new file mode 100644 index 000000000..23f29fd7f --- /dev/null +++ b/Apps/UnitTests/Source/Tests.ExternalTexture.Render.cpp @@ -0,0 +1,186 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Utils.h" +#ifdef HAS_RENDERDOC +#include "RenderDoc.h" +#endif + +#include +#include +#include +#include +#include + +extern Babylon::Graphics::Configuration g_deviceConfig; + +TEST(ExternalTexture, RenderTextureArray) +{ +#if defined(SKIP_EXTERNAL_TEXTURE_TESTS) || defined(SKIP_RENDER_TESTS) + GTEST_SKIP(); +#else + constexpr uint32_t TEX_SIZE = 64; + constexpr uint32_t SLICE_COUNT = 3; + + const Color sliceColors[SLICE_COUNT] = { + {255, 0, 0, 255}, + {0, 255, 0, 255}, + {0, 0, 255, 255}, + }; + +#ifdef HAS_RENDERDOC + RenderDoc::Init(); +#endif + + Babylon::Graphics::Device device{g_deviceConfig}; + Babylon::Graphics::DeviceUpdate update{device.GetUpdate("update")}; + + device.StartRenderingCurrentFrame(); + update.Start(); + + auto inputTexture = CreateTestTextureArrayWithData( + device.GetPlatformInfo().Device, TEX_SIZE, TEX_SIZE, sliceColors, SLICE_COUNT); + Babylon::Plugins::ExternalTexture inputExternalTexture{inputTexture}; + + auto outputTexture = CreateRenderTargetTexture( + device.GetPlatformInfo().Device, TEX_SIZE, TEX_SIZE); + Babylon::Plugins::ExternalTexture outputExternalTexture{outputTexture}; + + std::promise startupDone; + + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [](const Napi::Error& error) { + std::cerr << "[Uncaught Error] " << Napi::GetErrorString(error) << std::endl; + std::cerr.flush(); + }; + + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&device](Napi::Env env) { + env.Global().Set("globalThis", env.Global()); + device.AddToJavaScript(env); + + Babylon::Polyfills::Console::Initialize(env, [](const char* message, auto) { + std::cout << message << std::endl; + }); + Babylon::Polyfills::Window::Initialize(env); + Babylon::Plugins::NativeEngine::Initialize(env); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.LoadScript("app:///Assets/babylon.max.js"); + loader.LoadScript("app:///Assets/tests.externalTexture.render.js"); + + loader.Dispatch([&inputExternalTexture, &outputExternalTexture, &startupDone](Napi::Env env) { + auto jsInput = inputExternalTexture.CreateForJavaScript(env); + auto jsOutput = outputExternalTexture.CreateForJavaScript(env); + env.Global().Get("startup").As().Call({ + jsInput, + jsOutput, + Napi::Number::New(env, TEX_SIZE), + Napi::Number::New(env, TEX_SIZE), + }); + startupDone.set_value(); + }); + + startupDone.get_future().wait(); + + update.Finish(); + device.FinishRenderingCurrentFrame(); + + for (uint32_t sliceIndex = 0; sliceIndex < SLICE_COUNT; ++sliceIndex) + { +#ifdef HAS_RENDERDOC + RenderDoc::StartFrameCapture(device.GetPlatformInfo().Device); +#endif + + device.StartRenderingCurrentFrame(); + update.Start(); + + std::promise renderDone; + + loader.Dispatch([sliceIndex, &renderDone](Napi::Env env) { + auto jsPromise = env.Global().Get("renderSlice").As().Call({ + Napi::Number::New(env, sliceIndex), + }).As(); + + auto jsOnFulfilled = Napi::Function::New(env, [&renderDone](const Napi::CallbackInfo&) { + renderDone.set_value(); + }); + + auto jsOnRejected = Napi::Function::New(env, [&renderDone](const Napi::CallbackInfo& info) { + renderDone.set_exception(std::make_exception_ptr( + std::runtime_error{Napi::GetErrorString(info[0].As())})); + }); + + jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled, jsOnRejected}); + }); + + auto renderFuture = renderDone.get_future(); + ASSERT_EQ(renderFuture.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "Slice " << sliceIndex << ": renderSlice timed out"; + ASSERT_NO_THROW(renderFuture.get()) << "Slice " << sliceIndex << ": renderSlice rejected"; + + update.Finish(); + device.FinishRenderingCurrentFrame(); + +#ifdef HAS_RENDERDOC + RenderDoc::StopFrameCapture(device.GetPlatformInfo().Device); +#endif + + auto pixels = ReadBackRenderTarget( + device.GetPlatformInfo().Device, outputTexture, TEX_SIZE, TEX_SIZE); + + const auto& expected = sliceColors[sliceIndex]; + uint32_t matchCount = 0; + const uint32_t totalPixels = TEX_SIZE * TEX_SIZE; + + for (uint32_t i = 0; i < 5 && i < totalPixels; ++i) + { + std::cout << " pixel[" << i << "] = (" + << static_cast(pixels[i * 4 + 0]) << ", " + << static_cast(pixels[i * 4 + 1]) << ", " + << static_cast(pixels[i * 4 + 2]) << ", " + << static_cast(pixels[i * 4 + 3]) << ")" << std::endl; + } + + for (uint32_t i = 0; i < totalPixels; ++i) + { + const uint8_t r = pixels[i * 4 + 0]; + const uint8_t g = pixels[i * 4 + 1]; + const uint8_t b = pixels[i * 4 + 2]; + + if (std::abs(static_cast(r) - expected.R) < 2 && + std::abs(static_cast(g) - expected.G) < 2 && + std::abs(static_cast(b) - expected.B) < 2) + { + ++matchCount; + } + } + + const double matchPercent = + static_cast(matchCount) / totalPixels * 100.0; + + std::cout << "Slice " << sliceIndex << ": " << matchCount << "/" + << totalPixels << " pixels match (" << matchPercent << "%)" + << std::endl; + + EXPECT_EQ(matchPercent, 100.0) + << "Slice " << sliceIndex << ": expected (" + << static_cast(expected.R) << ", " + << static_cast(expected.G) << ", " + << static_cast(expected.B) << ") but only " + << matchPercent << "% of pixels matched"; + } + + DestroyRenderTargetTexture(outputTexture); + DestroyTestTexture(inputTexture); +#endif +} diff --git a/Apps/UnitTests/Source/Tests.ExternalTexture.cpp b/Apps/UnitTests/Source/Tests.ExternalTexture.cpp index b842e81f4..26d1962fe 100644 --- a/Apps/UnitTests/Source/Tests.ExternalTexture.cpp +++ b/Apps/UnitTests/Source/Tests.ExternalTexture.cpp @@ -36,7 +36,7 @@ TEST(ExternalTexture, Construction) #endif } -TEST(ExternalTexture, AddToContextAsyncAndUpdate) +TEST(ExternalTexture, CreateForJavaScript) { #ifdef SKIP_EXTERNAL_TEXTURE_TESTS GTEST_SKIP(); @@ -51,11 +51,10 @@ TEST(ExternalTexture, AddToContextAsyncAndUpdate) Babylon::Plugins::ExternalTexture externalTexture{nativeTexture}; DestroyTestTexture(nativeTexture); - std::promise addToContext{}; - std::promise promiseResolved{}; + std::promise done{}; Babylon::AppRuntime runtime{}; - runtime.Dispatch([&device, &addToContext, &promiseResolved, externalTexture](Napi::Env env) { + runtime.Dispatch([&device, &done, externalTexture](Napi::Env env) { device.AddToJavaScript(env); Babylon::Polyfills::Console::Initialize(env, [](const char* message, auto) { @@ -66,39 +65,51 @@ TEST(ExternalTexture, AddToContextAsyncAndUpdate) Babylon::Plugins::NativeEngine::Initialize(env); - auto jsPromise = externalTexture.AddToContextAsync(env); - addToContext.set_value(); + auto jsTexture = externalTexture.CreateForJavaScript(env); + EXPECT_TRUE(jsTexture.IsObject()); - auto jsOnFulfilled = Napi::Function::New(env, [&promiseResolved](const Napi::CallbackInfo& info) { - promiseResolved.set_value(); - }); - - auto jsOnRejected = Napi::Function::New(env, [&promiseResolved](const Napi::CallbackInfo& info) { - promiseResolved.set_exception(std::make_exception_ptr(info[0].As())); - }); - - jsPromise.Get("then").As().Call(jsPromise, {jsOnFulfilled, jsOnRejected}); + done.set_value(); }); - // Wait for AddToContextAsync to be called. - addToContext.get_future().wait(); + done.get_future().wait(); - // Render a frame so that AddToContextAsync will complete. update.Finish(); device.FinishRenderingCurrentFrame(); +#endif +} + +TEST(ExternalTexture, Update) +{ +#ifdef SKIP_EXTERNAL_TEXTURE_TESTS + GTEST_SKIP(); +#else + Babylon::Graphics::Device device{g_deviceConfig}; + Babylon::Graphics::DeviceUpdate update{device.GetUpdate("update")}; - // Wait for promise to resolve. - promiseResolved.get_future().wait(); + device.StartRenderingCurrentFrame(); + update.Start(); - // Start a new frame. + auto nativeTexture = CreateTestTexture(device.GetPlatformInfo().Device, 256, 256); + Babylon::Plugins::ExternalTexture externalTexture{nativeTexture}; + DestroyTestTexture(nativeTexture); + + EXPECT_EQ(externalTexture.Width(), 256u); + EXPECT_EQ(externalTexture.Height(), 256u); + + update.Finish(); + device.FinishRenderingCurrentFrame(); + + // Update the external texture to point at a new native texture with different dimensions. device.StartRenderingCurrentFrame(); update.Start(); - // Update the external texture to a new texture. - auto nativeTexture2 = CreateTestTexture(device.GetPlatformInfo().Device, 256, 256); + auto nativeTexture2 = CreateTestTexture(device.GetPlatformInfo().Device, 128, 128); externalTexture.Update(nativeTexture2); DestroyTestTexture(nativeTexture2); + EXPECT_EQ(externalTexture.Width(), 128u); + EXPECT_EQ(externalTexture.Height(), 128u); + update.Finish(); device.FinishRenderingCurrentFrame(); #endif diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index cbd943b72..2ce9ca51a 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -11,7 +11,13 @@ #include #include +#include +#include #include +#include +#include +#include +#include extern Babylon::Graphics::Configuration g_deviceConfig; @@ -31,6 +37,60 @@ namespace return "unknown"; } + + // Watchdog: aborts the test with a core dump if both JS console output AND + // render-thread frame progress stop for too long. Replaces opaque CI job + // timeouts with an immediate, debuggable failure pointing at the last test + // that ran. Threshold tunable via BN_WATCHDOG_TIMEOUT_S (set to 0 to disable). + // + // Two activity sources feed the watchdog: + // 1. JS console output (via the polyfill console handler) — surfaces test progress. + // 2. Render-thread frame count (via the pump loop below) — distinguishes a real + // deadlock (render thread stuck) from slow-but-progressing software-GL shader + // compile (mocha silent for minutes while bgfx::frame() chews through shader + // variants on llvmpipe). With the heartbeat, the watchdog only fires when + // forward progress has actually stopped. + std::atomic g_lastConsoleActivityNs{0}; + std::atomic g_pumpFrameCount{0}; + std::mutex g_lastMessageMutex; + std::string g_lastMessage; + + // Per-frame timing accumulators (nanoseconds), published by pump loop, sampled by stats thread. + std::atomic g_pumpStartNs{0}; // total time inside StartRenderingCurrentFrame() + std::atomic g_pumpFinishNs{0}; // total time inside FinishRenderingCurrentFrame() + std::atomic g_pumpWaitNs{0}; // total time in wait_for(16ms) + + std::int64_t NowNs() + { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + } + + void RecordConsoleActivity(const char* message) + { + g_lastConsoleActivityNs.store(NowNs(), std::memory_order_relaxed); + std::lock_guard lock{g_lastMessageMutex}; + g_lastMessage.assign(message ? message : ""); + } + + int GetWatchdogTimeoutSeconds() + { + if (const char* env = std::getenv("BN_WATCHDOG_TIMEOUT_S")) + { + try + { + return std::stoi(env); + } + catch (...) + { + } + } + // 60s default. The watchdog also resets on render-thread frame progress + // (see g_pumpFrameCount), so slow-but-progressing software-GL shader compile + // no longer trips it; only a real stall (render thread stuck) does. + return 60; + } } TEST(JavaScript, All) @@ -40,6 +100,13 @@ TEST(JavaScript, All) Babylon::Graphics::Device device{g_deviceConfig}; + // Start rendering a frame to unblock the JavaScript from queuing graphics + // commands. The frame is held open through script load and the test pump + // (which only ticks bgfx via Finish; Start) so the JS thread can submit + // at any time without racing the gate. A final Finish closes it after + // runtime teardown. + device.StartRenderingCurrentFrame(); + std::optional nativeCanvas; Babylon::AppRuntime::Options options{}; @@ -64,6 +131,7 @@ TEST(JavaScript, All) Babylon::Polyfills::XMLHttpRequest::Initialize(env); Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { + RecordConsoleActivity(message); std::cout << "[" << EnumToString(logLevel) << "] " << message << std::endl; }); Babylon::Polyfills::Window::Initialize(env); @@ -87,9 +155,119 @@ TEST(JavaScript, All) loader.LoadScript("app:///Assets/babylonjs.materials.js"); loader.LoadScript("app:///Assets/tests.javaScript.all.js"); - device.StartRenderingCurrentFrame(); - device.FinishRenderingCurrentFrame(); + // Start the JS-progress watchdog. Anchor "last activity" to now so we don't fire + // before the JS engine produces its first console output. + g_lastConsoleActivityNs.store(NowNs(), std::memory_order_relaxed); + const int watchdogTimeoutSeconds = GetWatchdogTimeoutSeconds(); + std::atomic watchdogStop{false}; + std::thread watchdogThread; + if (watchdogTimeoutSeconds > 0) + { + watchdogThread = std::thread{[&watchdogStop, watchdogTimeoutSeconds]() { + const std::int64_t thresholdNs = std::int64_t{watchdogTimeoutSeconds} * 1'000'000'000LL; + std::uint64_t lastSeenFrameCount = g_pumpFrameCount.load(std::memory_order_relaxed); + auto lastHeartbeat = std::chrono::steady_clock::now(); + while (!watchdogStop.load(std::memory_order_relaxed)) + { + std::this_thread::sleep_for(std::chrono::seconds{1}); + // Treat render-thread frame progress as activity — distinguishes slow + // shader compile (frames advancing while JS console is silent) from a + // real deadlock (frames frozen). + const std::uint64_t currentFrameCount = g_pumpFrameCount.load(std::memory_order_relaxed); + if (currentFrameCount != lastSeenFrameCount) + { + lastSeenFrameCount = currentFrameCount; + g_lastConsoleActivityNs.store(NowNs(), std::memory_order_relaxed); + auto now = std::chrono::steady_clock::now(); + if (now - lastHeartbeat >= std::chrono::seconds{30}) + { + // Snapshot per-phase accumulators so we can tell where wall time is going + // on slow runners (start / finish / wait split) and emit a delta from + // the last heartbeat to make per-30s rates easy to read in CI logs. + const std::uint64_t startNs = g_pumpStartNs.load(std::memory_order_relaxed); + const std::uint64_t finishNs = g_pumpFinishNs.load(std::memory_order_relaxed); + const std::uint64_t waitNs = g_pumpWaitNs.load(std::memory_order_relaxed); + std::cerr << "[watchdog-heartbeat] pump_frames=" << currentFrameCount + << " start_ms=" << (startNs / 1'000'000ULL) + << " finish_ms=" << (finishNs / 1'000'000ULL) + << " wait_ms=" << (waitNs / 1'000'000ULL) + << "\n" << std::flush; + lastHeartbeat = now; + } + } + const std::int64_t idleNs = NowNs() - g_lastConsoleActivityNs.load(std::memory_order_relaxed); + if (idleNs >= thresholdNs) + { + std::string lastMessage; + { + std::lock_guard lock{g_lastMessageMutex}; + lastMessage = g_lastMessage; + } + std::cerr << "\n=== WATCHDOG: no console output or pump-frame progress for " + << (idleNs / 1'000'000'000LL) << "s ===" + << "\n=== Last console message: " << lastMessage << " ===" + << "\n=== Last pump frame count: " << currentFrameCount << " ===" + << "\n=== Aborting to produce a core dump for post-mortem analysis ===" + << "\n=== (raise BN_WATCHDOG_TIMEOUT_S or set to 0 to disable) ===\n" + << std::flush; + std::abort(); + } + } + }}; + } - auto exitCode{exitCodePromise.get_future().get()}; + // Pump frames while JS tests run — tests use RAF internally and + // SubmitCommands requires an active frame. The frame was opened + // immediately after device creation; the loop just ticks bgfx + // (Finish; Start) once per iteration so commands can advance. + // Each iteration publishes forward progress to the watchdog so + // slow-but-progressing shader compile (silent on the JS console) + // doesn't trip a false positive. + auto exitCodeFuture = exitCodePromise.get_future(); + while (true) + { + const auto waitStart = std::chrono::steady_clock::now(); + const auto waitStatus = exitCodeFuture.wait_for(std::chrono::milliseconds(16)); + g_pumpWaitNs.fetch_add( + static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - waitStart).count()), + std::memory_order_relaxed); + if (waitStatus == std::future_status::ready) + { + break; + } + + const auto finishBegin = std::chrono::steady_clock::now(); + device.FinishRenderingCurrentFrame(); + const auto finishEnd = std::chrono::steady_clock::now(); + g_pumpFinishNs.fetch_add( + static_cast( + std::chrono::duration_cast(finishEnd - finishBegin).count()), + std::memory_order_relaxed); + + device.StartRenderingCurrentFrame(); + const auto startEnd = std::chrono::steady_clock::now(); + g_pumpStartNs.fetch_add( + static_cast( + std::chrono::duration_cast(startEnd - finishEnd).count()), + std::memory_order_relaxed); + + g_pumpFrameCount.fetch_add(1, std::memory_order_relaxed); + } + + watchdogStop.store(true, std::memory_order_relaxed); + if (watchdogThread.joinable()) + { + watchdogThread.join(); + } + + + auto exitCode = exitCodeFuture.get(); EXPECT_EQ(exitCode, 0); + + // Runtime destructor joins the JS thread; must happen before Finish. + nativeCanvas.reset(); + + device.FinishRenderingCurrentFrame(); } diff --git a/Apps/UnitTests/Source/Utils.D3D11.cpp b/Apps/UnitTests/Source/Utils.D3D11.cpp index e5bfd2c26..050fe8d89 100644 --- a/Apps/UnitTests/Source/Utils.D3D11.cpp +++ b/Apps/UnitTests/Source/Utils.D3D11.cpp @@ -26,3 +26,106 @@ void DestroyTestTexture(Babylon::Graphics::TextureT texture) { texture->Release(); } + +Babylon::Graphics::TextureT CreateTestTextureArrayWithData(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, const Color* sliceColors, uint32_t sliceCount) +{ + const uint32_t rowPitch = width * 4; + const uint32_t sliceSize = rowPitch * height; + + std::vector pixels(sliceSize * sliceCount); + for (uint32_t s = 0; s < sliceCount; ++s) + { + for (uint32_t i = 0; i < width * height; ++i) + { + uint8_t* p = pixels.data() + s * sliceSize + i * 4; + p[0] = sliceColors[s].R; + p[1] = sliceColors[s].G; + p[2] = sliceColors[s].B; + p[3] = sliceColors[s].A; + } + } + + std::vector initData(sliceCount); + for (uint32_t s = 0; s < sliceCount; ++s) + { + initData[s].pSysMem = pixels.data() + s * sliceSize; + initData[s].SysMemPitch = rowPitch; + initData[s].SysMemSlicePitch = 0; + } + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = sliceCount; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + + ID3D11Texture2D* texture = nullptr; + EXPECT_HRESULT_SUCCEEDED(device->CreateTexture2D(&desc, initData.data(), &texture)); + + return texture; +} + +Babylon::Graphics::TextureT CreateRenderTargetTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height) +{ + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + ID3D11Texture2D* texture = nullptr; + EXPECT_HRESULT_SUCCEEDED(device->CreateTexture2D(&desc, nullptr, &texture)); + + return texture; +} + +std::vector ReadBackRenderTarget(Babylon::Graphics::DeviceT device, Babylon::Graphics::TextureT texture, uint32_t width, uint32_t height) +{ + ID3D11DeviceContext* context = nullptr; + device->GetImmediateContext(&context); + + D3D11_TEXTURE2D_DESC stagingDesc{}; + stagingDesc.Width = width; + stagingDesc.Height = height; + stagingDesc.MipLevels = 1; + stagingDesc.ArraySize = 1; + stagingDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + stagingDesc.SampleDesc.Count = 1; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + + ID3D11Texture2D* staging = nullptr; + EXPECT_HRESULT_SUCCEEDED(device->CreateTexture2D(&stagingDesc, nullptr, &staging)); + + context->CopyResource(staging, static_cast(texture)); + + D3D11_MAPPED_SUBRESOURCE mapped{}; + EXPECT_HRESULT_SUCCEEDED(context->Map(staging, 0, D3D11_MAP_READ, 0, &mapped)); + + const uint32_t rowPitch = width * 4; + std::vector pixels(rowPitch * height); + const auto* src = static_cast(mapped.pData); + for (uint32_t row = 0; row < height; ++row) + { + memcpy(pixels.data() + row * rowPitch, src + row * mapped.RowPitch, rowPitch); + } + + context->Unmap(staging, 0); + staging->Release(); + context->Release(); + + return pixels; +} + +void DestroyRenderTargetTexture(Babylon::Graphics::TextureT texture) +{ + texture->Release(); +} diff --git a/Apps/UnitTests/Source/Utils.D3D12.cpp b/Apps/UnitTests/Source/Utils.D3D12.cpp index 65b4f6d23..bfb940af9 100644 --- a/Apps/UnitTests/Source/Utils.D3D12.cpp +++ b/Apps/UnitTests/Source/Utils.D3D12.cpp @@ -39,3 +39,23 @@ void DestroyTestTexture(Babylon::Graphics::TextureT texture) { texture->Release(); } + +Babylon::Graphics::TextureT CreateTestTextureArrayWithData(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, const Color* sliceColors, uint32_t sliceCount) +{ + throw std::runtime_error{"not implemented for D3D12"}; +} + +Babylon::Graphics::TextureT CreateRenderTargetTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height) +{ + throw std::runtime_error{"not implemented for D3D12"}; +} + +std::vector ReadBackRenderTarget(Babylon::Graphics::DeviceT device, Babylon::Graphics::TextureT texture, uint32_t width, uint32_t height) +{ + throw std::runtime_error{"not implemented for D3D12"}; +} + +void DestroyRenderTargetTexture(Babylon::Graphics::TextureT texture) +{ + texture->Release(); +} diff --git a/Apps/UnitTests/Source/Utils.Metal.mm b/Apps/UnitTests/Source/Utils.Metal.mm index b2ec3603e..a8fa18ce7 100644 --- a/Apps/UnitTests/Source/Utils.Metal.mm +++ b/Apps/UnitTests/Source/Utils.Metal.mm @@ -22,3 +22,66 @@ void DestroyTestTexture(Babylon::Graphics::TextureT texture) { texture->release(); } + +Babylon::Graphics::TextureT CreateTestTextureArrayWithData(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, const Color* sliceColors, uint32_t sliceCount) +{ + MTL::TextureDescriptor* descriptor = MTL::TextureDescriptor::texture2DDescriptor( + MTL::PixelFormatRGBA8Unorm, width, height, false); + descriptor->setTextureType(MTL::TextureType2DArray); + descriptor->setArrayLength(sliceCount); + descriptor->setUsage(MTL::TextureUsageShaderRead); + descriptor->setStorageMode(MTL::StorageModeManaged); + + MTL::Texture* texture = device->newTexture(descriptor); + EXPECT_NE(texture, nullptr); + + const uint32_t bytesPerRow = width * 4; + const uint32_t sliceSize = bytesPerRow * height; + std::vector pixels(sliceSize); + + for (uint32_t s = 0; s < sliceCount; ++s) + { + for (uint32_t i = 0; i < width * height; ++i) + { + uint8_t* p = pixels.data() + i * 4; + p[0] = sliceColors[s].R; + p[1] = sliceColors[s].G; + p[2] = sliceColors[s].B; + p[3] = sliceColors[s].A; + } + + MTL::Region region = MTL::Region::Make2D(0, 0, width, height); + texture->replaceRegion(region, 0, s, pixels.data(), bytesPerRow, 0); + } + + return texture; +} + +Babylon::Graphics::TextureT CreateRenderTargetTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height) +{ + MTL::TextureDescriptor* descriptor = MTL::TextureDescriptor::texture2DDescriptor( + MTL::PixelFormatRGBA8Unorm, width, height, false); + descriptor->setUsage(MTL::TextureUsageRenderTarget | MTL::TextureUsageShaderRead); + descriptor->setStorageMode(MTL::StorageModeManaged); + + MTL::Texture* texture = device->newTexture(descriptor); + EXPECT_NE(texture, nullptr); + + return texture; +} + +std::vector ReadBackRenderTarget(Babylon::Graphics::DeviceT device, Babylon::Graphics::TextureT texture, uint32_t width, uint32_t height) +{ + const uint32_t bytesPerRow = width * 4; + std::vector pixels(bytesPerRow * height); + + MTL::Region region = MTL::Region::Make2D(0, 0, width, height); + texture->getBytes(pixels.data(), bytesPerRow, region, 0); + + return pixels; +} + +void DestroyRenderTargetTexture(Babylon::Graphics::TextureT texture) +{ + texture->release(); +} diff --git a/Apps/UnitTests/Source/Utils.OpenGL.cpp b/Apps/UnitTests/Source/Utils.OpenGL.cpp index 7f1ab668a..b9c81eec2 100644 --- a/Apps/UnitTests/Source/Utils.OpenGL.cpp +++ b/Apps/UnitTests/Source/Utils.OpenGL.cpp @@ -10,3 +10,23 @@ void DestroyTestTexture(Babylon::Graphics::TextureT texture) { throw std::runtime_error{"not implemented"}; } + +Babylon::Graphics::TextureT CreateTestTextureArrayWithData(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, const Color* sliceColors, uint32_t sliceCount) +{ + throw std::runtime_error{"not implemented"}; +} + +Babylon::Graphics::TextureT CreateRenderTargetTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height) +{ + throw std::runtime_error{"not implemented"}; +} + +std::vector ReadBackRenderTarget(Babylon::Graphics::DeviceT device, Babylon::Graphics::TextureT texture, uint32_t width, uint32_t height) +{ + throw std::runtime_error{"not implemented"}; +} + +void DestroyRenderTargetTexture(Babylon::Graphics::TextureT texture) +{ + throw std::runtime_error{"not implemented"}; +} diff --git a/Apps/UnitTests/Source/Utils.h b/Apps/UnitTests/Source/Utils.h index a3ee6a982..435e3f37a 100644 --- a/Apps/UnitTests/Source/Utils.h +++ b/Apps/UnitTests/Source/Utils.h @@ -1,6 +1,20 @@ #pragma once #include +#include + +#include +#include + +struct Color +{ + uint8_t R, G, B, A; +}; Babylon::Graphics::TextureT CreateTestTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, uint32_t arraySize = 1); void DestroyTestTexture(Babylon::Graphics::TextureT texture); + +Babylon::Graphics::TextureT CreateTestTextureArrayWithData(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height, const Color* sliceColors, uint32_t sliceCount); +Babylon::Graphics::TextureT CreateRenderTargetTexture(Babylon::Graphics::DeviceT device, uint32_t width, uint32_t height); +std::vector ReadBackRenderTarget(Babylon::Graphics::DeviceT device, Babylon::Graphics::TextureT texture, uint32_t width, uint32_t height); +void DestroyRenderTargetTexture(Babylon::Graphics::TextureT texture); \ No newline at end of file diff --git a/Core/Graphics/CMakeLists.txt b/Core/Graphics/CMakeLists.txt index 7f2059a35..1079a398d 100644 --- a/Core/Graphics/CMakeLists.txt +++ b/Core/Graphics/CMakeLists.txt @@ -6,7 +6,6 @@ set(SOURCES "InternalInclude/Babylon/Graphics/continuation_scheduler.h" "InternalInclude/Babylon/Graphics/FrameBuffer.h" "InternalInclude/Babylon/Graphics/DeviceContext.h" - "InternalInclude/Babylon/Graphics/SafeTimespanGuarantor.h" "InternalInclude/Babylon/Graphics/Texture.h" "Source/BgfxCallback.cpp" "Source/FrameBuffer.cpp" @@ -16,7 +15,6 @@ set(SOURCES "Source/DeviceImpl.h" "Source/DeviceImpl_${BABYLON_NATIVE_PLATFORM}.${BABYLON_NATIVE_PLATFORM_IMPL_EXT}" "Source/DeviceImpl_${GRAPHICS_API}.${BABYLON_NATIVE_PLATFORM_IMPL_EXT}" - "Source/SafeTimespanGuarantor.cpp" "Source/Texture.cpp") add_library(Graphics ${SOURCES}) diff --git a/Core/Graphics/Include/Shared/Babylon/Graphics/Device.h b/Core/Graphics/Include/Shared/Babylon/Graphics/Device.h index 8a529cdee..f57a95b2d 100644 --- a/Core/Graphics/Include/Shared/Babylon/Graphics/Device.h +++ b/Core/Graphics/Include/Shared/Babylon/Graphics/Device.h @@ -58,45 +58,23 @@ namespace Babylon::Graphics DepthStencilFormat BackBufferDepthStencilFormat{DepthStencilFormat::Depth24Stencil8}; }; - class Device; + class DeviceImpl; + // Deprecated: DeviceUpdate is a no-op compatibility shim. Frame synchronization + // is now handled by FrameCompletionScope inside StartRenderingCurrentFrame/ + // FinishRenderingCurrentFrame. This class will be removed in a future PR. class DeviceUpdate { public: - void Start() - { - m_start(); - } + void Start() {} + void Finish() {} void RequestFinish(std::function onFinishCallback) { - m_requestFinish(std::move(onFinishCallback)); - } - - void Finish() - { - std::promise promise{}; - auto future = promise.get_future(); - RequestFinish([&promise] { promise.set_value(); }); - future.wait(); - } - - private: - friend class Device; - - template - DeviceUpdate(StartCallableT&& start, RequestEndCallableT&& requestEnd) - : m_start{std::forward(start)} - , m_requestFinish{std::forward(requestEnd)} - { + onFinishCallback(); } - - std::function m_start{}; - std::function)> m_requestFinish{}; }; - class DeviceImpl; - class Device { public: @@ -128,7 +106,7 @@ namespace Babylon::Graphics void EnableRendering(); void DisableRendering(); - DeviceUpdate GetUpdate(const char* updateName); + DeviceUpdate GetUpdate(const char* /*updateName*/) { return {}; } void StartRenderingCurrentFrame(); void FinishRenderingCurrentFrame(); diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h index 3824237c7..2bd5b7882 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/DeviceContext.h @@ -3,7 +3,8 @@ #include "BgfxCallback.h" #include #include "continuation_scheduler.h" -#include "SafeTimespanGuarantor.h" + +#include #include @@ -15,7 +16,6 @@ namespace Babylon::Graphics { - class Update; class DeviceContext; class DeviceImpl; @@ -29,53 +29,41 @@ namespace Babylon::Graphics bgfx::TextureFormat::Enum Format{}; }; - class UpdateToken final + // FrameCompletionScope is an RAII guard that prevents the render thread from + // closing a bgfx frame while JS-thread work is still in flight. While any + // scope is alive, FinishRenderingCurrentFrame() blocks before bgfx::frame() + // — so all encoder commands recorded while a scope was held land in the + // same bgfx frame, never split across two. + // + // Acquisition blocks if the gate is closed (m_frameBlocked == true), so a + // JS thread that picks up work between frames waits for the next Start + // before proceeding. + // + // Two scoping patterns are used: + // 1. JS-frame scoped: capture the scope into an m_runtime.Dispatch lambda + // so it survives until the JS-thread queue services the next + // continuation. Use this when subsequent work in the same JS task may + // also touch bgfx (chained submitCommands, RAF callbacks, scene + // cleanup, etc.). NativeEngine::SubmitCommands and + // ScheduleRequestAnimationFrameCallbacks both do this. + // 2. Block scoped: hold the scope on the stack across a single self- + // contained bgfx phase. Used for one-shot operations like + // Canvas::Flush() called outside an active frame, and + // ReadTextureAsync. + class FrameCompletionScope final { public: - UpdateToken(const UpdateToken& other) = delete; - UpdateToken& operator=(const UpdateToken& other) = delete; - - UpdateToken(UpdateToken&&) noexcept = default; - - // The move assignment of `SafeTimespanGuarantor::SafetyGuarantee` is marked as delete. - // See https://github.com/Microsoft/GSL/issues/705. - //UpdateToken& operator=(UpdateToken&& other) = delete; + FrameCompletionScope(const FrameCompletionScope&) = delete; + FrameCompletionScope& operator=(const FrameCompletionScope&) = delete; + FrameCompletionScope& operator=(FrameCompletionScope&&) = delete; - bgfx::Encoder* GetEncoder(); - - private: - friend class Update; - - UpdateToken(DeviceContext&, SafeTimespanGuarantor&); - - DeviceContext& m_context; - SafeTimespanGuarantor::SafetyGuarantee m_guarantee; - }; - - class Update - { - public: - continuation_scheduler<>& Scheduler() - { - return m_safeTimespanGuarantor.OpenScheduler(); - } - - UpdateToken GetUpdateToken() - { - return {m_context, m_safeTimespanGuarantor}; - } + FrameCompletionScope(FrameCompletionScope&&) noexcept; + ~FrameCompletionScope(); private: friend class DeviceContext; - - Update(SafeTimespanGuarantor& safeTimespanGuarantor, DeviceContext& context) - : m_safeTimespanGuarantor{safeTimespanGuarantor} - , m_context{context} - { - } - - SafeTimespanGuarantor& m_safeTimespanGuarantor; - DeviceContext& m_context; + FrameCompletionScope(DeviceImpl&); + DeviceImpl* m_impl; }; class DeviceContext @@ -93,7 +81,23 @@ namespace Babylon::Graphics continuation_scheduler<>& BeforeRenderScheduler(); continuation_scheduler<>& AfterRenderScheduler(); - Update GetUpdate(const char* updateName); + // Scheduler that fires when StartRenderingCurrentFrame ticks the frame start dispatcher. + // Use this to schedule work (e.g., requestAnimationFrame callbacks) that should run each frame. + continuation_scheduler<>& FrameStartScheduler(); + + // Acquire a scope that prevents FinishRenderingCurrentFrame from + // completing until the scope is destroyed. JS-thread callers that + // need coverage across the whole current JS task should capture the + // scope into an m_runtime.Dispatch lambda; callers needing only a + // single phase can hold it stack-scoped. See the class comment on + // FrameCompletionScope above for the two patterns. + FrameCompletionScope AcquireFrameCompletionScope(); + + // Active encoder for the current frame. Managed by DeviceImpl in + // StartRenderingCurrentFrame/FinishRenderingCurrentFrame. + // Used by NativeEngine, Canvas, and NativeXr. + void SetActiveEncoder(bgfx::Encoder* encoder); + bgfx::Encoder* GetActiveEncoder(); void RequestScreenShot(std::function)> callback); void RequestCaptureNextFrame(); @@ -114,7 +118,7 @@ namespace Babylon::Graphics using CaptureCallbackTicketT = arcana::ticketed_collection>::ticket; CaptureCallbackTicketT AddCaptureCallback(std::function callback); - bgfx::ViewId AcquireNewViewId(bgfx::Encoder&); + bgfx::ViewId AcquireNewViewId(); bgfx::ViewId PeekNextViewId() const; // TODO: find a different way to get the texture info for frame capture @@ -124,8 +128,6 @@ namespace Babylon::Graphics static bx::AllocatorI& GetDefaultAllocator() { return m_allocator; } private: - friend UpdateToken; - DeviceImpl& m_graphicsImpl; std::unordered_map m_textureHandleToInfo{}; diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/FrameBuffer.h b/Core/Graphics/InternalInclude/Babylon/Graphics/FrameBuffer.h index d3e64d406..d970ecc97 100644 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/FrameBuffer.h +++ b/Core/Graphics/InternalInclude/Babylon/Graphics/FrameBuffer.h @@ -33,12 +33,12 @@ namespace Babylon::Graphics uint16_t Height() const; bool DefaultBackBuffer() const; - void Bind(bgfx::Encoder& encoder); - void Unbind(bgfx::Encoder& encoder); + void Bind(); + void Unbind(); void Clear(bgfx::Encoder& encoder, uint16_t flags, uint32_t rgba, float depth, uint8_t stencil); - void SetViewPort(bgfx::Encoder& encoder, float x, float y, float width, float height); - void SetScissor(bgfx::Encoder& encoder, float x, float y, float width, float height); + void SetViewPort(float x, float y, float width, float height); + void SetScissor(float x, float y, float width, float height); void Submit(bgfx::Encoder& encoder, bgfx::ProgramHandle programHandle, uint8_t flags); void SetStencil(bgfx::Encoder& encoder, uint32_t stencilState); void Blit(bgfx::Encoder& encoder, bgfx::TextureHandle dst, uint16_t dstX, uint16_t dstY, bgfx::TextureHandle src, uint16_t srcX = 0, uint16_t srcY = 0, uint16_t width = UINT16_MAX, uint16_t height = UINT16_MAX); @@ -48,7 +48,7 @@ namespace Babylon::Graphics private: Rect GetBgfxScissor(float x, float y, float width, float height) const; - void SetBgfxViewPortAndScissor(bgfx::Encoder& encoder, const Rect& viewPort, const Rect& scissor); + void SetBgfxViewPortAndScissor(const Rect& viewPort, const Rect& scissor); DeviceContext& m_deviceContext; const uintptr_t m_deviceID{}; diff --git a/Core/Graphics/InternalInclude/Babylon/Graphics/SafeTimespanGuarantor.h b/Core/Graphics/InternalInclude/Babylon/Graphics/SafeTimespanGuarantor.h deleted file mode 100644 index 98c556b8c..000000000 --- a/Core/Graphics/InternalInclude/Babylon/Graphics/SafeTimespanGuarantor.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "continuation_scheduler.h" - -#include -#include - -#include - -#include -#include -#include - -namespace Babylon::Graphics -{ - class SafeTimespanGuarantor - { - public: - SafeTimespanGuarantor(std::optional&); - - continuation_scheduler<>& OpenScheduler() - { - return m_openDispatcher.scheduler(); - } - - continuation_scheduler<>& CloseScheduler() - { - return m_closeDispatcher.scheduler(); - } - - using SafetyGuarantee = gsl::final_action>; - SafetyGuarantee GetSafetyGuarantee(); - - void Open(); - void RequestClose(); - void Lock(); - void Unlock(); - - private: - enum class State - { - Open, - Closing, - Closed, - Locked - }; - - std::optional& m_cancellation; - State m_state{State::Locked}; - uint32_t m_count{}; - std::mutex m_mutex{}; - std::condition_variable m_condition_variable{}; - continuation_dispatcher<> m_openDispatcher{}; - continuation_dispatcher<> m_closeDispatcher{}; - }; -} diff --git a/Core/Graphics/Source/BgfxCallback.cpp b/Core/Graphics/Source/BgfxCallback.cpp index f0d394ab2..5c2a86b8b 100644 --- a/Core/Graphics/Source/BgfxCallback.cpp +++ b/Core/Graphics/Source/BgfxCallback.cpp @@ -25,14 +25,17 @@ namespace Babylon::Graphics void BgfxCallback::fatal(const char* filePath, uint16_t line, bgfx::Fatal::Enum code, const char* str) { + // Always log fatal errors to stderr so they appear in CI logs. + trace(filePath, line, "BGFX FATAL 0x%08x: %s\n", code, str); + fprintf(stderr, "BGFX FATAL %s (%d): 0x%08x: %s\n", filePath, line, code, str); + fflush(stderr); + if (bgfx::Fatal::DebugCheck == code) { bx::debugBreak(); } else { - trace(filePath, line, "BGFX 0x%08x: %s\n", code, str); - BX_UNUSED(code, str); abort(); } } diff --git a/Core/Graphics/Source/Device.cpp b/Core/Graphics/Source/Device.cpp index ec9365ea6..1dcdb7608 100644 --- a/Core/Graphics/Source/Device.cpp +++ b/Core/Graphics/Source/Device.cpp @@ -66,19 +66,6 @@ namespace Babylon::Graphics m_impl->DisableRendering(); } - DeviceUpdate Device::GetUpdate(const char* updateName) - { - auto& guarantor = m_impl->GetSafeTimespanGuarantor(updateName); - return { - [&guarantor] { - guarantor.Open(); - }, - [&guarantor](std::function callback) { - guarantor.CloseScheduler()(std::move(callback)); - guarantor.RequestClose(); - }}; - } - void Device::StartRenderingCurrentFrame() { m_impl->StartRenderingCurrentFrame(); diff --git a/Core/Graphics/Source/DeviceContext.cpp b/Core/Graphics/Source/DeviceContext.cpp index a8105a45d..df5599a0a 100644 --- a/Core/Graphics/Source/DeviceContext.cpp +++ b/Core/Graphics/Source/DeviceContext.cpp @@ -4,20 +4,6 @@ #include -namespace Babylon::Graphics -{ - UpdateToken::UpdateToken(DeviceContext& context, SafeTimespanGuarantor& guarantor) - : m_context{context} - , m_guarantee{guarantor.GetSafetyGuarantee()} - { - } - - bgfx::Encoder* UpdateToken::GetEncoder() - { - return m_context.m_graphicsImpl.GetEncoderForThread(); - } -} - namespace Babylon::Graphics { DeviceContext& DeviceContext::GetFromJavaScript(Napi::Env env) @@ -51,9 +37,44 @@ namespace Babylon::Graphics return m_graphicsImpl.AfterRenderScheduler(); } - Update DeviceContext::GetUpdate(const char* updateName) + continuation_scheduler<>& DeviceContext::FrameStartScheduler() + { + return m_graphicsImpl.FrameStartScheduler(); + } + + FrameCompletionScope::FrameCompletionScope(DeviceImpl& impl) + : m_impl{&impl} + { + m_impl->IncrementPendingFrameScopes(); + } + + FrameCompletionScope::FrameCompletionScope(FrameCompletionScope&& other) noexcept + : m_impl{other.m_impl} + { + other.m_impl = nullptr; + } + + FrameCompletionScope::~FrameCompletionScope() + { + if (m_impl) + { + m_impl->DecrementPendingFrameScopes(); + } + } + + FrameCompletionScope DeviceContext::AcquireFrameCompletionScope() + { + return FrameCompletionScope{m_graphicsImpl}; + } + + void DeviceContext::SetActiveEncoder(bgfx::Encoder* encoder) + { + m_graphicsImpl.SetActiveEncoder(encoder); + } + + bgfx::Encoder* DeviceContext::GetActiveEncoder() { - return {m_graphicsImpl.GetSafeTimespanGuarantor(updateName), *this}; + return m_graphicsImpl.GetActiveEncoder(); } void DeviceContext::RequestScreenShot(std::function)> callback) @@ -106,9 +127,9 @@ namespace Babylon::Graphics return m_graphicsImpl.AddCaptureCallback(std::move(callback)); } - bgfx::ViewId DeviceContext::AcquireNewViewId(bgfx::Encoder& encoder) + bgfx::ViewId DeviceContext::AcquireNewViewId() { - return m_graphicsImpl.AcquireNewViewId(encoder); + return m_graphicsImpl.AcquireNewViewId(); } bgfx::ViewId DeviceContext::PeekNextViewId() const diff --git a/Core/Graphics/Source/DeviceImpl.cpp b/Core/Graphics/Source/DeviceImpl.cpp index 93a3fa75e..5293990cb 100644 --- a/Core/Graphics/Source/DeviceImpl.cpp +++ b/Core/Graphics/Source/DeviceImpl.cpp @@ -257,19 +257,6 @@ namespace Babylon::Graphics } } - SafeTimespanGuarantor& DeviceImpl::GetSafeTimespanGuarantor(const char* updateName) - { - std::scoped_lock lock{m_updateSafeTimespansMutex}; - std::string updateNameStr{updateName}; - auto found = m_updateSafeTimespans.find(updateNameStr); - if (found == m_updateSafeTimespans.end()) - { - m_updateSafeTimespans.emplace(std::piecewise_construct, std::forward_as_tuple(updateNameStr), std::forward_as_tuple(m_cancellationSource)); - found = m_updateSafeTimespans.find(updateNameStr); - } - return found->second; - } - void DeviceImpl::SetDiagnosticOutput(std::function diagnosticOutput) { ASSERT_THREAD_AFFINITY(m_renderThreadAffinity); @@ -288,42 +275,66 @@ namespace Babylon::Graphics } m_rendering = true; + m_firstFrameStarted = true; // Ensure rendering is enabled. EnableRendering(); - // Unlock the update safe timespans. + // Acquire the frame encoder BEFORE opening the gate. This guarantees + // that when JS code unblocks from AcquireFrameCompletionScope, the + // encoder is already available in DeviceContext. + m_frameEncoder = bgfx::begin(true); + + // Open the gate: allow JS thread to acquire FrameCompletionScopes and use the encoder. { - std::scoped_lock lock{m_updateSafeTimespansMutex}; - for (auto& [key, value] : m_updateSafeTimespans) - { - value.Unlock(); - } + std::lock_guard lock{m_frameSyncMutex}; + m_frameBlocked = false; } + m_frameSyncCV.notify_all(); + + // Tick the frame start dispatcher. This fires requestAnimationFrame tasks that + // were scheduled by NativeEngine/NativeXr. Those tasks acquire FrameCompletionScopes + // (keeping the gate reference count > 0) and dispatch JS callbacks to the JS thread. + m_frameStartDispatcher.tick(*m_cancellationSource); } void DeviceImpl::FinishRenderingCurrentFrame() { - // Lock the update safe timespans. - { - std::scoped_lock lock{m_updateSafeTimespansMutex}; - for (auto& [key, value] : m_updateSafeTimespans) - { - value.Lock(); - } - } - arcana::trace_region finishRenderingRegion{"DeviceImpl::FinishRenderingCurrentFrame"}; ASSERT_THREAD_AFFINITY(m_renderThreadAffinity); if (!m_rendering) { + if (!m_firstFrameStarted) + { + // First call at startup - no frame in progress yet, nothing to finish. + return; + } + throw std::runtime_error{"Current frame cannot be finished prior to having been started."}; } + // Close the gate: wait until JS thread has released all FrameCompletionScopes + // (meaning all encoder work for this frame is done), then block new acquisitions. + // After this point, no bgfx encoder calls can be in flight on the JS thread. + { + std::unique_lock lock{m_frameSyncMutex}; + m_frameSyncCV.wait(lock, [this] { return m_pendingFrameScopes == 0; }); + m_frameBlocked = true; + } + m_beforeRenderDispatcher.tick(*m_cancellationSource); + // End the frame encoder before calling bgfx::frame(). frame() waits for + // all encoders to be returned via encoderApiWait(), so the encoder must + // be ended first to avoid a deadlock on the same thread. + if (m_frameEncoder) + { + bgfx::end(m_frameEncoder); + m_frameEncoder = nullptr; + } + Frame(); m_afterRenderDispatcher.tick(*m_cancellationSource); @@ -368,6 +379,43 @@ namespace Babylon::Graphics return m_afterRenderDispatcher.scheduler(); } + continuation_scheduler<>& DeviceImpl::FrameStartScheduler() + { + return m_frameStartDispatcher.scheduler(); + } + + // Called by FrameCompletionScope constructor (on any thread). + // Blocks if the gate is closed (m_frameBlocked), meaning bgfx::frame() is running + // or no frame has started yet. Once unblocked, increments the scope counter. + void DeviceImpl::IncrementPendingFrameScopes() + { + std::unique_lock lock{m_frameSyncMutex}; + m_frameSyncCV.wait(lock, [this] { return !m_frameBlocked; }); + m_pendingFrameScopes++; + } + + // Called by FrameCompletionScope destructor (on any thread). + // Decrements the scope counter and wakes the main thread, which may be waiting + // in FinishRenderingCurrentFrame for all scopes to be released. + void DeviceImpl::DecrementPendingFrameScopes() + { + { + std::lock_guard lock{m_frameSyncMutex}; + m_pendingFrameScopes--; + } + m_frameSyncCV.notify_all(); + } + + void DeviceImpl::SetActiveEncoder(bgfx::Encoder* encoder) + { + m_frameEncoder = encoder; + } + + bgfx::Encoder* DeviceImpl::GetActiveEncoder() const + { + return m_frameEncoder; + } + void DeviceImpl::RequestScreenShot(std::function)> callback) { m_screenShotCallbacks.push(std::move(callback)); @@ -400,7 +448,7 @@ namespace Babylon::Graphics return m_captureCallbacks.insert(std::move(callback), m_captureCallbacksMutex); } - bgfx::ViewId DeviceImpl::AcquireNewViewId(bgfx::Encoder&) + bgfx::ViewId DeviceImpl::AcquireNewViewId() { bgfx::ViewId viewId = m_nextViewId.fetch_add(1); if (viewId >= bgfx::getCaps()->limits.maxViews) @@ -462,9 +510,6 @@ namespace Babylon::Graphics { arcana::trace_region frameRegion{"DeviceImpl::Frame"}; - // Automatically end bgfx encoders. - EndEncoders(); - // Update bgfx state if necessary. UpdateBgfxState(); @@ -485,34 +530,6 @@ namespace Babylon::Graphics m_nextViewId.store(0); } - bgfx::Encoder* DeviceImpl::GetEncoderForThread() - { - assert(!m_renderThreadAffinity.check()); - std::scoped_lock lock{m_threadIdToEncoderMutex}; - - const auto threadId{std::this_thread::get_id()}; - auto it{m_threadIdToEncoder.find(threadId)}; - if (it == m_threadIdToEncoder.end()) - { - bgfx::Encoder* encoder{bgfx::begin(true)}; - it = m_threadIdToEncoder.emplace(threadId, encoder).first; - } - - return it->second; - } - - void DeviceImpl::EndEncoders() - { - std::scoped_lock lock{m_threadIdToEncoderMutex}; - - for (auto [threadId, encoder] : m_threadIdToEncoder) - { - bgfx::end(encoder); - } - - m_threadIdToEncoder.clear(); - } - void DeviceImpl::CaptureCallback(const BgfxCallback::CaptureData& data) { std::scoped_lock callbackLock{m_captureCallbacksMutex}; diff --git a/Core/Graphics/Source/DeviceImpl.h b/Core/Graphics/Source/DeviceImpl.h index a96f57c93..9e2caf933 100644 --- a/Core/Graphics/Source/DeviceImpl.h +++ b/Core/Graphics/Source/DeviceImpl.h @@ -3,7 +3,6 @@ #include #include #include -#include #include #include @@ -16,8 +15,9 @@ #include #include +#include +#include #include -#include #include #include @@ -59,8 +59,6 @@ namespace Babylon::Graphics void EnableRendering(); void DisableRendering(); - SafeTimespanGuarantor& GetSafeTimespanGuarantor(const char* updateName); - void SetDiagnosticOutput(std::function diagnosticOutput); void StartRenderingCurrentFrame(); @@ -84,6 +82,7 @@ namespace Babylon::Graphics continuation_scheduler<>& BeforeRenderScheduler(); continuation_scheduler<>& AfterRenderScheduler(); + continuation_scheduler<>& FrameStartScheduler(); void RequestScreenShot(std::function)> callback); @@ -94,9 +93,17 @@ namespace Babylon::Graphics using CaptureCallbackTicketT = arcana::ticketed_collection>::ticket; CaptureCallbackTicketT AddCaptureCallback(std::function callback); - bgfx::ViewId AcquireNewViewId(bgfx::Encoder&); + bgfx::ViewId AcquireNewViewId(); bgfx::ViewId PeekNextViewId() const; + // Frame completion scope support + void IncrementPendingFrameScopes(); + void DecrementPendingFrameScopes(); + + // Active encoder for the current frame. + void SetActiveEncoder(bgfx::Encoder* encoder); + bgfx::Encoder* GetActiveEncoder() const; + /* ********** END DEVICE CONTEXT CONTRACT ********** */ // TODO: HACK @@ -106,7 +113,7 @@ namespace Babylon::Graphics } private: - friend class UpdateToken; + friend class FrameCompletionScope; static const bgfx::RendererType::Enum s_bgfxRenderType; static void ConfigureBgfxPlatformData(bgfx::PlatformData& pd, WindowT window); @@ -117,12 +124,16 @@ namespace Babylon::Graphics void UpdateBgfxResolution(); void RequestScreenShots(); void Frame(); - bgfx::Encoder* GetEncoderForThread(); - void EndEncoders(); void CaptureCallback(const BgfxCallback::CaptureData&); arcana::affinity m_renderThreadAffinity{}; bool m_rendering{}; + bool m_firstFrameStarted{}; + + // The single bgfx encoder for the current frame. Acquired in + // StartRenderingCurrentFrame, ended in FinishRenderingCurrentFrame. + // Read by all consumers via DeviceContext::GetActiveEncoder() → DeviceImpl::GetActiveEncoder(). + bgfx::Encoder* m_frameEncoder{nullptr}; std::atomic m_nextViewId{0}; @@ -156,19 +167,49 @@ namespace Babylon::Graphics continuation_dispatcher<> m_beforeRenderDispatcher{}; continuation_dispatcher<> m_afterRenderDispatcher{}; + // Ticked by StartRenderingCurrentFrame(). NativeEngine and NativeXr schedule + // requestAnimationFrame tasks here so they fire once per frame at the right time. + continuation_dispatcher<> m_frameStartDispatcher{}; + + // --- Frame synchronization between main thread and JS thread --- + // + // bgfx itself can handle concurrent bgfx::begin() and bgfx::frame() safely + // (begin blocks on a mutex until frame releases it). However, BabylonNative needs + // to ensure LOGICAL frame correctness: all encoder commands intended for frame N + // must be submitted (bgfx::end) before bgfx::frame() for frame N runs, otherwise + // draw calls are lost or appear in the wrong frame (causing flickering/artifacts). + // + // Solution: a blocking gate with a reference counter. + // + // m_frameBlocked (bool): + // - true = JS cannot acquire new FrameCompletionScopes (blocks in IncrementPendingFrameScopes) + // - false = JS is free to acquire scopes and use encoders + // - Set to false in StartRenderingCurrentFrame (opens the gate) + // - Set to true in FinishRenderingCurrentFrame after all scopes are released (closes the gate) + // - Starts as true (no frame in progress at init) + // + // m_pendingFrameScopes (int): + // - Count of active FrameCompletionScope instances + // - FinishRenderingCurrentFrame waits for this to reach 0 + // - Incremented by IncrementPendingFrameScopes (called by FrameCompletionScope constructor) + // - Decremented by DecrementPendingFrameScopes (called by FrameCompletionScope destructor) + // + // m_frameSyncMutex + m_frameSyncCV: + // - Protects m_frameBlocked and m_pendingFrameScopes + // - CV is waited on by: main thread (for scopes==0) and JS thread (for !blocked) + // - CV is notified by: JS thread (scope released) and main thread (unblocked) + std::mutex m_frameSyncMutex{}; + std::condition_variable m_frameSyncCV{}; + int m_pendingFrameScopes{0}; + bool m_frameBlocked{true}; + std::mutex m_captureCallbacksMutex{}; arcana::ticketed_collection> m_captureCallbacks{}; arcana::blocking_concurrent_queue)>> m_screenShotCallbacks{}; - std::map m_threadIdToEncoder{}; - std::mutex m_threadIdToEncoderMutex{}; - std::queue>> m_readTextureRequests{}; - std::map m_updateSafeTimespans{}; - std::mutex m_updateSafeTimespansMutex{}; - DeviceContext m_context; uintptr_t m_bgfxId = 0; std::function m_renderResetCallback; diff --git a/Core/Graphics/Source/FrameBuffer.cpp b/Core/Graphics/Source/FrameBuffer.cpp index 35959f606..94f26345a 100644 --- a/Core/Graphics/Source/FrameBuffer.cpp +++ b/Core/Graphics/Source/FrameBuffer.cpp @@ -69,19 +69,19 @@ namespace Babylon::Graphics return m_defaultBackBuffer; } - void FrameBuffer::Bind(bgfx::Encoder&) + void FrameBuffer::Bind() { m_viewId.reset(); } - void FrameBuffer::Unbind(bgfx::Encoder&) + void FrameBuffer::Unbind() { } void FrameBuffer::Clear(bgfx::Encoder& encoder, uint16_t flags, uint32_t rgba, float depth, uint8_t stencil) { // BGFX requires us to create a new viewID, this will ensure that the view gets cleared. - m_viewId = m_deviceContext.AcquireNewViewId(encoder); + m_viewId = m_deviceContext.AcquireNewViewId(); bgfx::setViewMode(m_viewId.value(), bgfx::ViewMode::Sequential); bgfx::setViewClear(m_viewId.value(), flags, rgba, depth, stencil); @@ -124,28 +124,28 @@ namespace Babylon::Graphics encoder.touch(m_viewId.value()); } - void FrameBuffer::SetViewPort(bgfx::Encoder& encoder, float x, float y, float width, float height) + void FrameBuffer::SetViewPort(float x, float y, float width, float height) { m_desiredViewPort = {x, y, width, height}; - SetBgfxViewPortAndScissor(encoder, m_desiredViewPort, m_desiredScissor); + SetBgfxViewPortAndScissor(m_desiredViewPort, m_desiredScissor); } - void FrameBuffer::SetScissor(bgfx::Encoder& encoder, float x, float y, float width, float height) + void FrameBuffer::SetScissor(float x, float y, float width, float height) { m_desiredScissor = GetBgfxScissor(x, y, width, height); - SetBgfxViewPortAndScissor(encoder, m_desiredViewPort, m_desiredScissor); + SetBgfxViewPortAndScissor(m_desiredViewPort, m_desiredScissor); } void FrameBuffer::Submit(bgfx::Encoder& encoder, bgfx::ProgramHandle programHandle, uint8_t flags) { - SetBgfxViewPortAndScissor(encoder, m_desiredViewPort, m_desiredScissor); + SetBgfxViewPortAndScissor(m_desiredViewPort, m_desiredScissor); encoder.submit(m_viewId.value(), programHandle, 0, flags); } void FrameBuffer::Blit(bgfx::Encoder& encoder, bgfx::TextureHandle dst, uint16_t dstX, uint16_t dstY, bgfx::TextureHandle src, uint16_t srcX, uint16_t srcY, uint16_t width, uint16_t height) { // In order for Blit to work properly we need to force the creation of a new ViewID. - SetBgfxViewPortAndScissor(encoder, m_desiredViewPort, m_desiredScissor); + SetBgfxViewPortAndScissor(m_desiredViewPort, m_desiredScissor); encoder.blit(m_viewId.value(), dst, dstX, dstY, src, srcX, srcY, width, height); } @@ -195,14 +195,14 @@ namespace Babylon::Graphics return Rect{x, y, width, height}; } - void FrameBuffer::SetBgfxViewPortAndScissor(bgfx::Encoder& encoder, const Rect& viewPort, const Rect& scissor) + void FrameBuffer::SetBgfxViewPortAndScissor(const Rect& viewPort, const Rect& scissor) { if (m_viewId.has_value() && viewPort.Equals(m_bgfxViewPort) && scissor.Equals(m_bgfxScissor)) { return; } - m_viewId = m_deviceContext.AcquireNewViewId(encoder); + m_viewId = m_deviceContext.AcquireNewViewId(); bgfx::setViewMode(m_viewId.value(), bgfx::ViewMode::Sequential); bgfx::setViewClear(m_viewId.value(), BGFX_CLEAR_NONE, 0, 1.0f, 0); diff --git a/Core/Graphics/Source/SafeTimespanGuarantor.cpp b/Core/Graphics/Source/SafeTimespanGuarantor.cpp deleted file mode 100644 index 29c7e14fc..000000000 --- a/Core/Graphics/Source/SafeTimespanGuarantor.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include - -namespace Babylon::Graphics -{ - SafeTimespanGuarantor::SafeTimespanGuarantor(std::optional& cancellation) - : m_cancellation{cancellation} - { - } - - void SafeTimespanGuarantor::Open() - { - { - std::scoped_lock lock{m_mutex}; - if (m_state != State::Closed) - { - throw std::runtime_error{"Safe timespan cannot begin if guarantor state is not closed"}; - } - m_state = State::Open; - } - - m_condition_variable.notify_all(); - std::this_thread::yield(); - - m_openDispatcher.tick(*m_cancellation); - } - - void SafeTimespanGuarantor::RequestClose() - { - std::scoped_lock lock{m_mutex}; - if (m_state != State::Open) - { - throw std::runtime_error{"Safe timespan cannot end if guarantor state is not open"}; - } - if (m_count == 0) - { - m_state = State::Closed; - m_closeDispatcher.tick(*m_cancellation); - } - else - { - m_state = State::Closing; - } - } - - void SafeTimespanGuarantor::Lock() - { - std::scoped_lock lock{m_mutex}; - if (m_state != State::Closed) - { - throw std::runtime_error{"SafeTimespanGuarantor can only be locked from a closed state"}; - } - m_state = State::Locked; - } - - void SafeTimespanGuarantor::Unlock() - { - std::scoped_lock lock{m_mutex}; - if (m_state != State::Locked) - { - throw std::runtime_error{"SafeTimespanGuarantor can only be unlocked if it was locked"}; - } - m_state = State::Closed; - } - - SafeTimespanGuarantor::SafetyGuarantee SafeTimespanGuarantor::GetSafetyGuarantee() - { - std::unique_lock lock{m_mutex}; - if (m_state == State::Closed || m_state == State::Locked) - { - m_condition_variable.wait(lock, [this]() { return m_state != State::Closed && m_state != State::Locked; }); - } - m_count++; - - return gsl::finally(std::function{[this] { - std::scoped_lock lock{m_mutex}; - if (--m_count == 0 && m_state == State::Closing) - { - m_state = State::Closed; - m_closeDispatcher.tick(*m_cancellation); - } - }}); - } -} diff --git a/Install/Test/CMakeLists.txt b/Install/Test/CMakeLists.txt index dbf87e910..31a2c900e 100644 --- a/Install/Test/CMakeLists.txt +++ b/Install/Test/CMakeLists.txt @@ -45,7 +45,6 @@ if(WIN32 AND NOT WINDOWS_STORE) "${SOURCE_DIR}/App.Win32.cpp" "${SOURCE_DIR}/Tests.Device.D3D11.cpp" "${SOURCE_DIR}/Tests.ExternalTexture.cpp" - "${SOURCE_DIR}/Tests.ExternalTexture.D3D11.cpp" "${SOURCE_DIR}/Tests.JavaScript.cpp" "${SOURCE_DIR}/Tests.ShaderCache.cpp" "${SOURCE_DIR}/Utils.D3D11.cpp" diff --git a/Plugins/ExternalTexture/CMakeLists.txt b/Plugins/ExternalTexture/CMakeLists.txt index e3fd2786f..56fda4405 100644 --- a/Plugins/ExternalTexture/CMakeLists.txt +++ b/Plugins/ExternalTexture/CMakeLists.txt @@ -13,7 +13,6 @@ target_include_directories(ExternalTexture target_link_libraries(ExternalTexture PUBLIC napi PUBLIC GraphicsDevice - PRIVATE JsRuntimeInternal PRIVATE GraphicsDeviceContext) target_compile_definitions(ExternalTexture diff --git a/Plugins/ExternalTexture/Include/Babylon/Plugins/ExternalTexture.h b/Plugins/ExternalTexture/Include/Babylon/Plugins/ExternalTexture.h index 1834ff9b8..746606ea0 100644 --- a/Plugins/ExternalTexture/Include/Babylon/Plugins/ExternalTexture.h +++ b/Plugins/ExternalTexture/Include/Babylon/Plugins/ExternalTexture.h @@ -1,16 +1,16 @@ #pragma once -#include +#include #include #include #include namespace Babylon::Plugins { + // All operations of this class must be called from the graphics thread unless otherwise noted. class ExternalTexture final { public: - // NOTE: Must call from the Graphics thread. ExternalTexture(Graphics::TextureT, std::optional = {}); ~ExternalTexture(); @@ -31,13 +31,19 @@ namespace Babylon::Plugins // Returns the underlying texture. Graphics::TextureT Get() const; - // Adds this texture to the graphics context of the given N-API environment. - // NOTE: Must call from the JavaScript thread. - Napi::Promise AddToContextAsync(Napi::Env, std::optional layerIndex = {}) const; + // Creates a JavaScript value wrapping this external texture. + // Wrap the returned value with `engine.wrapNativeTexture` on the JS side to get a Babylon.js `InternalTexture`. + // This method must be called from the JavaScript thread. The caller must ensure no other thread + // is concurrently calling any other operations on this object, including move operations. + Napi::Value CreateForJavaScript(Napi::Env) const; + + // Deprecated: use CreateForJavaScript instead. Retained as a shim for existing consumers. + // Returns a Promise that is already resolved with the value from CreateForJavaScript. + [[deprecated("Use CreateForJavaScript instead.")]] + Napi::Promise AddToContextAsync(Napi::Env) const; // Updates to a new texture. - // NOTE: Must call from the Graphics thread. - void Update(Graphics::TextureT, std::optional = {}, std::optional layerIndex = {}); + void Update(Graphics::TextureT, std::optional = {}); private: class Impl; diff --git a/Plugins/ExternalTexture/Readme.md b/Plugins/ExternalTexture/Readme.md index 6ddf457c1..30575af5f 100644 --- a/Plugins/ExternalTexture/Readme.md +++ b/Plugins/ExternalTexture/Readme.md @@ -25,80 +25,52 @@ To set the ExternalTexture to be used as a render target by Babylon.js one must int width = 1024; // Your render target width. int height = 768; // Your render target height. -std::promise textureCreationSubmitted {}; -std::promise textureCreationDone {}; - // Create an ExternalTexture from an ID3D12Resource. -auto externalTexture = std::make_shared(d3d12Resource); +Babylon::Plugins::ExternalTexture externalTexture{d3d12Resource}; -jsRuntime.Dispatch([&externalTexture, &textureCreationSubmitted, width, height, &textureCreationDone](Napi::Env env) +jsRuntime.Dispatch([externalTexture = std::move(externalTexture), width, height](Napi::Env env) { // Creates a JS object that can be used by the Babylon Engine to create a render texture. - auto jsPromisse = externalTexture->AddToContextAsync(env); + auto jsTexture = externalTexture.CreateForJavaScript(env); auto result = env.Global().Get("YOUR_JS_FUNCTION").As().Call( { - jsPromisse, + jsTexture, Napi::Value::From(env, width), Napi::Value::From(env, height), - Napi::Function::New(env, [&textureCreationDone](const Napi::CallbackInfo& info) - { - textureCreationDone.set_value(); - }) }); - textureCreationSubmitted.set_value(); }); - -// Wait for texture creation to be submitted. -textureCreationSubmitted.get_future().get(); - -// Run 1 render loop so the texture can get created. -m_update->Finish(); -m_device->FinishRenderingCurrentFrame(); -m_device->StartRenderingCurrentFrame(); -m_update->Start(); - -// Wait for callback to confirm the texture is created on the JS side. -textureCreated.get_future().get(); ``` The usual JS function to assign the external texture object as a render target for the Babylon scene camera looks like the following: ```js -function YOUR_JS_FUNCTION(externalTexturePromisse, width, height, textureCreatedCallback) { - externalTexturePromisse.then((externalTexture) => { - const outputTexture = engine.wrapNativeTexture(externalTexture); - scene.activeCamera.outputRenderTarget = new BABYLON.RenderTargetTexture( - "ExternalTexture", - { - width: width, - height: height, - }, - scene, - { - colorAttachment: outputTexture, - generateDepthBuffer: true, - generateStencilBuffer: true, - } - ); - textureCreatedCallback(); - }); +function YOUR_JS_FUNCTION(externalTexture, width, height) { + const outputTexture = engine.wrapNativeTexture(externalTexture); + scene.activeCamera.outputRenderTarget = new BABYLON.RenderTargetTexture( + "ExternalTexture", + { + width: width, + height: height, + }, + scene, + { + colorAttachment: outputTexture, + generateDepthBuffer: true, + generateStencilBuffer: true, + } + ); } ``` ## ExternalTexture class -`Babylon::Plugins::ExternalTexture` is a class that can be constructed with a native texture and then used to send to the JavaScript environment using `ExternalTexture::AddToContextAsync`. It is important to note that the constructor will only store the necessary information to convert the native texture to the bgfx texture, but it will _not_ create bgfx texture. This class will hold a strong reference to the native texture when possible. The native texture ownership will be shared with JS when `ExternalTexture::AddToContextAsync` is called. It is safe to destroy this class before `ExternalTexture::AddToContextAsync` async operation completes. +`Babylon::Plugins::ExternalTexture` is a class that can be constructed with a native texture and then used to create a JavaScript texture object via `ExternalTexture::CreateForJavaScript`. The constructor stores the necessary information about the native texture (dimensions, format, flags) and holds a strong reference to the native texture when possible. This class assumes that the native texture was created using the same graphics device used to create the Babylon::Device. See [Properly Initialize `Babylon::Graphics::Device`](#properly-initialize-babylongraphicsdevice). -The following will happen inside a call to `ExternalTexture::AddToContextAsync`: - -- A `Napi::Promise` will be created to encapsulate the async operation over a frame. -- During `Babylon::Graphics::DeviceContext::BeforeRenderScheduler()`, a new dummy bgfx texture will be created. -- During `Babylon::Graphics::DeviceContext::AfterRenderScheduler()`, this bgfx texture will be overridden with the native texture. -- On the JS thread, a `Napi::Pointer` will be created to hold the texture and the JS promise will resolved with this object. +`ExternalTexture::CreateForJavaScript` synchronously returns a `Napi::Value` wrapping a bgfx texture handle that is backed directly by the native texture. The returned value can be passed to `engine.wrapNativeTexture` on the JS side. -It is safe to create multiple JS objects from the same `Babylon::Plugins::ExternalTexture` via `ExternalTexture::AddToContextAsync`. +It is safe to create multiple JS objects from the same `Babylon::Plugins::ExternalTexture` via `ExternalTexture::CreateForJavaScript`. Once the JS texture is available on the JS side, use `engine.wrapNativeTexture` to create an Babylon.js `InternalTexture`. diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_Base.h b/Plugins/ExternalTexture/Source/ExternalTexture_Base.h index f3da6a7ff..ae0dd0684 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_Base.h +++ b/Plugins/ExternalTexture/Source/ExternalTexture_Base.h @@ -2,16 +2,39 @@ #include #include +#include #include #include #include #include +#include namespace Babylon::Plugins { + namespace + { + template + uintptr_t NativeHandleToUintPtr(T value) + { + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } + } + } + class ExternalTexture::ImplBase { public: + // Per the ExternalTexture public contract, all read-only accessors are called + // only from the graphics thread, where they are serialized against Update() (also + // graphics-thread). They do not need to lock against themselves; the mutex below + // exists solely to bridge graphics-thread state to the JS thread for + // CreateTexture / DestroyTexture. uint16_t Width() const { return m_info.Width; } uint16_t Height() const { return m_info.Height; } bgfx::TextureFormat::Enum Format() const { return m_info.Format; } @@ -19,26 +42,12 @@ namespace Babylon::Plugins uint16_t NumLayers() const { return m_info.NumLayers; } uint64_t Flags() const { return m_info.Flags; } - void AddHandle(bgfx::TextureHandle handle) - { - std::scoped_lock lock{m_mutex}; - - if (!m_handles.insert(handle).second) - { - assert(!"Failed to insert handle"); - } - } - - void RemoveHandle(bgfx::TextureHandle handle) - { - std::scoped_lock lock{m_mutex}; - - auto it = m_handles.find(handle); - if (it != m_handles.end()) - { - m_handles.erase(it); - } - } + // JS-thread entry points. These lock internally to read graphics-thread-written + // state (m_info, derived m_ptr, m_textures) consistently and to mutate m_textures. + // They contain no JS callouts, so the lock is never held across user-visible JS + // execution -- preventing the recursive-mutex / finalizer-reentrancy bug class. + Graphics::Texture* CreateTexture(Graphics::DeviceContext& context); + void DestroyTexture(Graphics::Texture* texture); protected: static bool IsFullMipChain(uint16_t mipLevel, uint16_t width, uint16_t height) @@ -63,16 +72,29 @@ namespace Babylon::Plugins return BGFX_TEXTURE_NONE; } - void UpdateHandles(Graphics::TextureT ptr, std::optional layerIndex) + // Re-attaches every registered Graphics::Texture to a fresh bgfx handle backed by + // `ptr`. Caller must hold m_mutex (called from the locked region of Impl::Update). + void UpdateTextures(Graphics::TextureT ptr) { - std::scoped_lock lock{m_mutex}; - - for (auto handle : m_handles) + for (auto* texture : m_textures) { - if (bgfx::overrideInternal(handle, uintptr_t(ptr), layerIndex.value_or(0)) == 0) + bgfx::TextureHandle handle = bgfx::createTexture2D( + Width(), + Height(), + HasMips(), + NumLayers(), + Format(), + Flags(), + 0, + NativeHandleToUintPtr(ptr) + ); + + if (!bgfx::isValid(handle)) { - assert(!"Failed to override texture"); + throw std::runtime_error{"Failed to create external texture"}; } + + texture->Attach(handle, true, m_info.Width, m_info.Height, HasMips(), m_info.NumLayers, m_info.Format, m_info.Flags); } } @@ -87,17 +109,9 @@ namespace Babylon::Plugins }; Info m_info{}; + mutable std::mutex m_mutex{}; private: - struct TextureHandleLess - { - bool operator()(const bgfx::TextureHandle& a, const bgfx::TextureHandle& b) const - { - return a.idx < b.idx; - } - }; - - mutable std::mutex m_mutex{}; - std::set m_handles{}; + std::set m_textures{}; }; } diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_D3D11.cpp b/Plugins/ExternalTexture/Source/ExternalTexture_D3D11.cpp index 333c012e4..bb49a0c3e 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_D3D11.cpp +++ b/Plugins/ExternalTexture/Source/ExternalTexture_D3D11.cpp @@ -163,7 +163,7 @@ namespace Babylon::Plugins public: // Implemented in ExternalTexture_Shared.h Impl(Graphics::TextureT, std::optional); - void Update(Graphics::TextureT, std::optional, std::optional); + void Update(Graphics::TextureT, std::optional); Graphics::TextureT Get() const { diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_D3D12.cpp b/Plugins/ExternalTexture/Source/ExternalTexture_D3D12.cpp index 0936118a6..8d9b29915 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_D3D12.cpp +++ b/Plugins/ExternalTexture/Source/ExternalTexture_D3D12.cpp @@ -163,7 +163,7 @@ namespace Babylon::Plugins public: // Implemented in ExternalTexture_Shared.h Impl(Graphics::TextureT, std::optional); - void Update(Graphics::TextureT, std::optional, std::optional); + void Update(Graphics::TextureT, std::optional); Graphics::TextureT Get() const { diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_Metal.cpp b/Plugins/ExternalTexture/Source/ExternalTexture_Metal.cpp index e8a01e359..f96526b0b 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_Metal.cpp +++ b/Plugins/ExternalTexture/Source/ExternalTexture_Metal.cpp @@ -251,7 +251,7 @@ namespace Babylon::Plugins public: // Implemented in ExternalTexture_Shared.h Impl(Graphics::TextureT, std::optional); - void Update(Graphics::TextureT, std::optional, std::optional); + void Update(Graphics::TextureT, std::optional); Graphics::TextureT Get() const { diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_OpenGL.cpp b/Plugins/ExternalTexture/Source/ExternalTexture_OpenGL.cpp index 14b4e6343..95dcbcbd0 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_OpenGL.cpp +++ b/Plugins/ExternalTexture/Source/ExternalTexture_OpenGL.cpp @@ -16,7 +16,7 @@ namespace Babylon::Plugins public: // Implemented in ExternalTexture_Shared.h Impl(Graphics::TextureT, std::optional); - void Update(Graphics::TextureT, std::optional, std::optional); + void Update(Graphics::TextureT, std::optional); Graphics::TextureT Get() const { diff --git a/Plugins/ExternalTexture/Source/ExternalTexture_Shared.h b/Plugins/ExternalTexture/Source/ExternalTexture_Shared.h index f0362487a..f0dfa95e6 100644 --- a/Plugins/ExternalTexture/Source/ExternalTexture_Shared.h +++ b/Plugins/ExternalTexture/Source/ExternalTexture_Shared.h @@ -14,7 +14,7 @@ namespace Babylon::Plugins Set(ptr); } - void ExternalTexture::Impl::Update(Graphics::TextureT ptr, std::optional overrideFormat, std::optional layerIndex) + void ExternalTexture::Impl::Update(Graphics::TextureT ptr, std::optional overrideFormat) { Info info; GetInfo(ptr, overrideFormat, info); @@ -22,10 +22,58 @@ namespace Babylon::Plugins DEBUG_TRACE("ExternalTexture [0x%p] Update %d x %d %d mips %d layers", this, int(info.Width), int(info.Height), int(info.MipLevels), int(info.NumLayers)); - m_info = info; + // Lock to publish (m_info, m_ptr, recreated bgfx handles) atomically to any JS-thread + // reader currently in CreateTexture. + { + std::scoped_lock lock{m_mutex}; + m_info = info; + Set(ptr); + UpdateTextures(ptr); + } + } - Set(ptr); - UpdateHandles(ptr, layerIndex); + Graphics::Texture* ExternalTexture::ImplBase::CreateTexture(Graphics::DeviceContext& context) + { + std::scoped_lock lock{m_mutex}; + + bgfx::TextureHandle handle = bgfx::createTexture2D( + m_info.Width, + m_info.Height, + HasMips(), + m_info.NumLayers, + m_info.Format, + m_info.Flags, + 0, + NativeHandleToUintPtr(static_cast(this)->Get()) + ); + + DEBUG_TRACE("ExternalTexture [0x%p] CreateForJavaScript %d x %d %d mips %d layers. Format : %d Flags : %d. (bgfx handle id %d)", + this, int(m_info.Width), int(m_info.Height), int(HasMips()), int(m_info.NumLayers), int(m_info.Format), int(m_info.Flags), int(handle.idx)); + + if (!bgfx::isValid(handle)) + { + throw std::runtime_error{"Failed to create external texture"}; + } + + auto* texture = new Graphics::Texture{context}; + texture->Attach(handle, true, m_info.Width, m_info.Height, HasMips(), m_info.NumLayers, m_info.Format, m_info.Flags); + + if (!m_textures.insert(texture).second) + { + assert(!"Failed to insert texture"); + } + + return texture; + } + + void ExternalTexture::ImplBase::DestroyTexture(Graphics::Texture* texture) + { + { + std::scoped_lock lock{m_mutex}; + m_textures.erase(texture); + } + + delete texture; } ExternalTexture::ExternalTexture(Graphics::TextureT ptr, std::optional overrideFormat) @@ -58,70 +106,38 @@ namespace Babylon::Plugins return m_impl->Get(); } - Napi::Promise ExternalTexture::AddToContextAsync(Napi::Env env, std::optional layerIndex) const + Napi::Value ExternalTexture::CreateForJavaScript(Napi::Env env) const { + // Resolve the DeviceContext outside any lock: the lookup is a JS property read + // that may run engine GC/finalizers, and finalizers may re-enter the impl mutex. + // DeviceContext is process-scoped and does not need synchronization. Graphics::DeviceContext& context = Graphics::DeviceContext::GetFromJavaScript(env); - JsRuntime& runtime = JsRuntime::GetFromJavaScript(env); - - auto deferred{Napi::Promise::Deferred::New(env)}; - auto promise{deferred.Promise()}; - DEBUG_TRACE("ExternalTexture [0x%p] AddToContextAsync", m_impl.get()); + // CreateTexture locks internally and contains no JS callouts, so the + // mutex is never held across the JS object allocation below. + Graphics::Texture* texture = m_impl->CreateTexture(context); - arcana::make_task(context.BeforeRenderScheduler(), arcana::cancellation_source::none(), [&context, &runtime, deferred = std::move(deferred), impl = m_impl, layerIndex = std::move(layerIndex)]() mutable { - // REVIEW: The bgfx texture handle probably needs to be an RAII object to make sure it gets clean up during the asynchrony. - // For example, if any of the schedulers/dispatches below don't fire, then the texture handle will leak. - bgfx::TextureHandle handle = bgfx::createTexture2D(impl->Width(), impl->Height(), impl->HasMips(), impl->NumLayers(), impl->Format(), impl->Flags()); - DEBUG_TRACE("ExternalTexture [0x%p] create %d x %d %d mips %d layers. Format : %d Flags : %d. (bgfx handle id %d)", - impl.get(), int(impl->Width()), int(impl->Height()), int(impl->HasMips()), int(impl->NumLayers()), int(impl->Format()), int(impl->Flags()), int(handle.idx)); - if (!bgfx::isValid(handle)) + return Napi::Pointer::Create(env, texture, [texture, weakImpl = std::weak_ptr{m_impl}] { + if (auto impl = weakImpl.lock()) { - DEBUG_TRACE("ExternalTexture [0x%p] is not valid", impl.get()); - runtime.Dispatch([deferred{std::move(deferred)}](Napi::Env env) { - deferred.Reject(Napi::Error::New(env, "Failed to create native texture").Value()); - }); - - return; + impl->DestroyTexture(texture); + } + else + { + delete texture; } - - arcana::make_task(context.AfterRenderScheduler(), arcana::cancellation_source::none(), [&runtime, &context, deferred = std::move(deferred), handle, impl = std::move(impl), layerIndex = std::move(layerIndex)]() mutable { - if (bgfx::overrideInternal(handle, uintptr_t(impl->Get()), layerIndex.value_or(0)) == 0) - { - runtime.Dispatch([deferred = std::move(deferred), handle](Napi::Env env) { - bgfx::destroy(handle); - deferred.Reject(Napi::Error::New(env, "Failed to override native texture").Value()); - }); - - return; - } - - runtime.Dispatch([deferred = std::move(deferred), handle, &context, impl = std::move(impl)](Napi::Env env) mutable { - auto* texture = new Graphics::Texture{context}; - DEBUG_TRACE("ExternalTexture [0x%p] attach %d x %d %d mips. Format : %d Flags : %d. (bgfx handle id %d)", - impl.get(), int(impl->Width()), int(impl->Height()), int(impl->HasMips()), int(impl->Format()), int(impl->Flags()), int(handle.idx)); - texture->Attach(handle, true, impl->Width(), impl->Height(), impl->HasMips(), 1, impl->Format(), impl->Flags()); - - impl->AddHandle(texture->Handle()); - - auto jsObject = Napi::Pointer::Create(env, texture, [texture, weakImpl = std::weak_ptr{impl}] { - if (auto impl = weakImpl.lock()) - { - impl->RemoveHandle(texture->Handle()); - } - - delete texture; - }); - - deferred.Resolve(jsObject); - }); - }); }); + } - return promise; + void ExternalTexture::Update(Graphics::TextureT ptr, std::optional overrideFormat) + { + m_impl->Update(ptr, overrideFormat); } - void ExternalTexture::Update(Graphics::TextureT ptr, std::optional overrideFormat, std::optional layerIndex) + Napi::Promise ExternalTexture::AddToContextAsync(Napi::Env env) const { - m_impl->Update(ptr, overrideFormat, layerIndex); + auto deferred = Napi::Promise::Deferred::New(env); + deferred.Resolve(CreateForJavaScript(env)); + return deferred.Promise(); } } diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 2cab6e696..f479939ad 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -23,7 +23,9 @@ #include #endif +#include #include +#include #if defined(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) && defined(WEBP) #include @@ -321,7 +323,7 @@ namespace Babylon texture->Create2D(static_cast(image->m_width), static_cast(image->m_height), (image->m_numMips > 1), 1, Cast(image->m_format), flags); } - for (uint8_t mip = 0, numMips = image->m_numMips; mip < numMips; ++mip) + for (uint8_t mip = 0; mip < image->m_numMips; ++mip) { bimg::ImageMip imageMip{}; if (bimg::imageGetRawData(*image, 0, mip, image->m_data, image->m_size, imageMip)) @@ -390,7 +392,7 @@ namespace Babylon for (uint8_t side = 0; side < 6; ++side) { bimg::ImageContainer* image{images[side]}; - for (uint8_t mip = 0, numMips = image->m_numMips; mip < numMips; ++mip) + for (uint8_t mip = 0; mip < image->m_numMips; ++mip) { bimg::ImageMip imageMip{}; if (bimg::imageGetRawData(*image, 0, mip, image->m_data, image->m_size, imageMip)) @@ -731,8 +733,12 @@ namespace Babylon InstanceMethod("submitCommands", &NativeEngine::SubmitCommands), InstanceMethod("populateFrameStats", &NativeEngine::PopulateFrameStats), + InstanceMethod("beginFrame", &NativeEngine::BeginFrame), + InstanceMethod("endFrame", &NativeEngine::EndFrame), InstanceMethod("setDeviceLostCallback", &NativeEngine::SetRenderResetCallback), + + }); JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_CONSTRUCTOR_NAME, func); @@ -748,7 +754,6 @@ namespace Babylon , m_cancellationSource{std::make_shared()} , m_runtime{runtime} , m_deviceContext{Graphics::DeviceContext::GetFromJavaScript(info.Env())} - , m_update{m_deviceContext.GetUpdate("update")} , m_runtimeScheduler{runtime} , m_defaultFrameBuffer{m_deviceContext, BGFX_INVALID_HANDLE, 0, 0, true, true, true} , m_boundFrameBuffer{&m_defaultFrameBuffer} @@ -1350,11 +1355,14 @@ namespace Babylon void NativeEngine::CopyTexture(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder = GetUpdateToken().GetEncoder(); + bgfx::Encoder* encoder = GetEncoder(); const auto textureSource = data.ReadPointer(); const auto textureDestination = data.ReadPointer(); + // Use a view id greater than every view used so far so the blit runs after + // all canvas Flushes that may have produced the source content (bgfx + // processes blits in numeric view-id order). See #1683. const bgfx::ViewId blitView = m_deviceContext.PeekNextViewId(); encoder->blit(blitView, textureDestination->Handle(), 0, 0, textureSource->Handle()); } @@ -1594,26 +1602,24 @@ namespace Babylon void NativeEngine::SetTexture(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder = GetUpdateToken().GetEncoder(); - const UniformInfo* uniformInfo = data.ReadPointer(); const Graphics::Texture* texture = data.ReadPointer(); + bgfx::Encoder* encoder = GetEncoder(); encoder->setTexture(uniformInfo->Stage, uniformInfo->Handle, texture->Handle(), texture->SamplerFlags()); } void NativeEngine::UnsetTexture(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder = GetUpdateToken().GetEncoder(); - const UniformInfo* uniformInfo = data.ReadPointer(); + bgfx::Encoder* encoder = GetEncoder(); encoder->setTexture(uniformInfo->Stage, uniformInfo->Handle, BGFX_INVALID_HANDLE); } void NativeEngine::DiscardAllTextures(NativeDataStream::Reader&) { - bgfx::Encoder* encoder = GetUpdateToken().GetEncoder(); + bgfx::Encoder* encoder = GetEncoder(); encoder->discard(BGFX_DISCARD_BINDINGS); } @@ -1671,31 +1677,33 @@ namespace Babylon } else { + // Acquire a FrameCompletionScope for the duration of the read operation. + // This ensures the encoder is available for the blit (if needed) and that + // bgfx::readTexture lands in the same frame as the blit. + Graphics::FrameCompletionScope scope{m_deviceContext.AcquireFrameCompletionScope()}; + bgfx::TextureHandle sourceTextureHandle{texture->Handle()}; auto tempTexture = std::make_shared(false); - // If the image needs to be cropped (not starting at 0, or less than full width/height (accounting for requested mip level)), - // or if the texture was not created with the BGFX_TEXTURE_READ_BACK flag, then blit it to a temp texture. + // If the image needs to be cropped or the texture lacks the READ_BACK flag, blit to a temp texture. if (x != 0 || y != 0 || width != (texture->Width() >> mipLevel) || height != (texture->Height() >> mipLevel) || (texture->Flags() & BGFX_TEXTURE_READ_BACK) == 0) { const bgfx::TextureHandle blitTextureHandle{bgfx::createTexture2D(width, height, /*hasMips*/ false, /*numLayers*/ 1, sourceTextureFormat, BGFX_TEXTURE_BLIT_DST | BGFX_TEXTURE_READ_BACK)}; - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; + + bgfx::Encoder* encoder = GetEncoder(); encoder->blit(static_cast(bgfx::getCaps()->limits.maxViews - 1), blitTextureHandle, /*dstMip*/ 0, /*dstX*/ 0, /*dstY*/ 0, /*dstZ*/ 0, sourceTextureHandle, mipLevel, x, y, /*srcZ*/ 0, width, height, /*depth*/ 0); sourceTextureHandle = blitTextureHandle; *tempTexture = true; - - // The requested mip level was blitted, so the source texture now has just one mip, so reset the mip level to 0. mipLevel = 0; } // Allocate a buffer to store the source pixel data. std::vector textureBuffer(sourceTextureInfo.storageSize); - // Read the source texture. + // Read the source texture (async — completes after bgfx::frame). m_deviceContext.ReadTextureAsync(sourceTextureHandle, textureBuffer, mipLevel) .then(arcana::inline_scheduler, *m_cancellationSource, [textureBuffer{std::move(textureBuffer)}, sourceTextureInfo, targetTextureInfo]() mutable { - // If the source texture format does not match the target texture format, convert it. if (targetTextureInfo.format != sourceTextureInfo.format) { #ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES @@ -1709,47 +1717,32 @@ namespace Babylon textureBuffer = convertedTextureBuffer; #endif } - - // Ensure the final texture buffer has the expected size. assert(textureBuffer.size() == targetTextureInfo.storageSize); - - // Flip the image vertically if needed. if (bgfx::getCaps()->originBottomLeft) { FlipImage(textureBuffer, targetTextureInfo.height); } - return textureBuffer; }) .then(m_runtimeScheduler, *m_cancellationSource, [this, bufferRef{Napi::Persistent(buffer)}, bufferOffset, deferred, tempTexture, sourceTextureHandle](std::vector textureBuffer) mutable { - // Double check the destination buffer length. This is redundant with prior checks, but we'll be extra sure before the memcpy. assert(bufferRef.Value().ByteLength() - bufferOffset >= textureBuffer.size()); - - // Copy the pixel data into the JS ArrayBuffer. - uint8_t* buffer{static_cast(bufferRef.Value().Data())}; - std::memcpy(buffer + bufferOffset, textureBuffer.data(), textureBuffer.size()); - - // Dispose of the texture handle before resolving the promise. - // TODO: Handle properly handle stale handles after BGFX shutdown + uint8_t* buf{static_cast(bufferRef.Value().Data())}; + std::memcpy(buf + bufferOffset, textureBuffer.data(), textureBuffer.size()); if (*tempTexture && !m_cancellationSource->cancelled()) { bgfx::destroy(sourceTextureHandle); *tempTexture = false; } - deferred.Resolve(bufferRef.Value()); }) - .then(m_runtimeScheduler, arcana::cancellation::none(), [this, env, deferred, tempTexture, sourceTextureHandle](const arcana::expected& result) { - // Dispose of the texture handle if not yet disposed. - // TODO: Handle properly handle stale handles after BGFX shutdown + .then(m_runtimeScheduler, arcana::cancellation::none(), [this, deferred, tempTexture, sourceTextureHandle](const arcana::expected& result) { if (*tempTexture && !m_cancellationSource->cancelled()) { bgfx::destroy(sourceTextureHandle); } - if (result.has_error()) { - deferred.Reject(Napi::Error::New(env, result.error()).Value()); + deferred.Reject(Napi::Error::New(Env(), result.error()).Value()); } }); } @@ -1833,63 +1826,55 @@ namespace Babylon void NativeEngine::BindFrameBuffer(NativeDataStream::Reader& data) { - auto encoder = GetUpdateToken().GetEncoder(); - Graphics::FrameBuffer* frameBuffer = data.ReadPointer(); - m_boundFrameBuffer->Unbind(*encoder); + m_boundFrameBuffer->Unbind(); m_boundFrameBuffer = frameBuffer; - m_boundFrameBuffer->Bind(*encoder); - m_boundFrameBufferNeedsRebinding.Set(*encoder, false); + m_boundFrameBuffer->Bind(); + m_boundFrameBufferNeedsRebinding.Set(false); } void NativeEngine::UnbindFrameBuffer(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder = GetUpdateToken().GetEncoder(); - const Graphics::FrameBuffer* frameBuffer = data.ReadPointer(); assert(m_boundFrameBuffer == frameBuffer); UNUSED(frameBuffer); - m_boundFrameBuffer->Unbind(*encoder); + m_boundFrameBuffer->Unbind(); m_boundFrameBuffer = nullptr; - m_boundFrameBufferNeedsRebinding.Set(*encoder, false); + m_boundFrameBufferNeedsRebinding.Set(false); } // Note: For legacy reasons JS might call this function for instance drawing. // In that case the instanceCount will be calculated inside the SetVertexBuffers method. void NativeEngine::DrawIndexed(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const uint32_t fillMode = data.ReadUint32(); const uint32_t indexStart = data.ReadUint32(); const uint32_t indexCount = data.ReadUint32(); + bgfx::Encoder* encoder = GetEncoder(); if (m_boundVertexArray != nullptr) { m_boundVertexArray->SetIndexBuffer(encoder, indexStart, indexCount); m_boundVertexArray->SetVertexBuffers(encoder, 0, std::numeric_limits::max()); } - DrawInternal(encoder, fillMode); } void NativeEngine::DrawIndexedInstanced(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const uint32_t fillMode = data.ReadUint32(); const uint32_t indexStart = data.ReadUint32(); const uint32_t indexCount = data.ReadUint32(); const uint32_t instanceCount = data.ReadUint32(); + bgfx::Encoder* encoder = GetEncoder(); if (m_boundVertexArray != nullptr) { m_boundVertexArray->SetIndexBuffer(encoder, indexStart, indexCount); m_boundVertexArray->SetVertexBuffers(encoder, 0, std::numeric_limits::max(), instanceCount); } - DrawInternal(encoder, fillMode); } @@ -1897,41 +1882,35 @@ namespace Babylon // In that case the instanceCount will be calculated inside the SetVertexBuffers method. void NativeEngine::Draw(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const uint32_t fillMode = data.ReadUint32(); const uint32_t verticesStart = data.ReadUint32(); const uint32_t verticesCount = data.ReadUint32(); + bgfx::Encoder* encoder = GetEncoder(); if (m_boundVertexArray != nullptr) { m_boundVertexArray->SetVertexBuffers(encoder, verticesStart, verticesCount); } - DrawInternal(encoder, fillMode); } void NativeEngine::DrawInstanced(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const uint32_t fillMode = data.ReadUint32(); const uint32_t verticesStart = data.ReadUint32(); const uint32_t verticesCount = data.ReadUint32(); const uint32_t instanceCount = data.ReadUint32(); + bgfx::Encoder* encoder = GetEncoder(); if (m_boundVertexArray != nullptr) { m_boundVertexArray->SetVertexBuffers(encoder, verticesStart, verticesCount, instanceCount); } - DrawInternal(encoder, fillMode); } void NativeEngine::Clear(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - uint16_t flags{0}; uint32_t rgba{0x000000ff}; @@ -1945,6 +1924,8 @@ namespace Babylon const bool shouldClearStencil{static_cast(data.ReadUint32())}; const uint8_t stencil{static_cast(data.ReadUint32())}; + bgfx::Encoder* encoder = GetEncoder(); + if (shouldClearColor) { rgba = @@ -1966,7 +1947,7 @@ namespace Babylon flags |= BGFX_CLEAR_STENCIL; } - GetBoundFrameBuffer(*encoder).Clear(*encoder, flags, rgba, depth, stencil); + GetBoundFrameBuffer().Clear(*encoder, flags, rgba, depth, stencil); } Napi::Value NativeEngine::GetRenderWidth(const Napi::CallbackInfo& info) @@ -2146,27 +2127,23 @@ namespace Babylon void NativeEngine::SetViewPort(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const float x{data.ReadFloat32()}; const float y{data.ReadFloat32()}; const float width{data.ReadFloat32()}; const float height{data.ReadFloat32()}; const float yOrigin = bgfx::getCaps()->originBottomLeft ? y : (1.f - y - height); - GetBoundFrameBuffer(*encoder).SetViewPort(*encoder, x, yOrigin, width, height); + GetBoundFrameBuffer().SetViewPort(x, yOrigin, width, height); } void NativeEngine::SetScissor(NativeDataStream::Reader& data) { - bgfx::Encoder* encoder{GetUpdateToken().GetEncoder()}; - const float x{data.ReadFloat32()}; const float y{data.ReadFloat32()}; const float width{data.ReadFloat32()}; const float height{data.ReadFloat32()}; - GetBoundFrameBuffer(*encoder).SetScissor(*encoder, x, y, width, height); + GetBoundFrameBuffer().SetScissor(x, y, width, height); } void NativeEngine::SetCommandDataStream(const Napi::CallbackInfo& info) @@ -2178,6 +2155,25 @@ namespace Babylon void NativeEngine::SubmitCommands(const Napi::CallbackInfo& info) { + // Acquire a FrameCompletionScope and capture it into a Dispatch + // lambda so the frame stays open across the rest of the current JS + // task, not just this command-stream pass. Any continuation work in + // the same JS task (further submitCommands calls, immediate promise + // resolutions touching bgfx, scene.dispose() cleanup, etc.) sees + // count > 0 and remains protected from a concurrent + // FinishRenderingCurrentFrame. + // + // Dispatching at the top (before the try block) also makes the + // coverage exception-safe — if the command stream throws, the lambda + // is already queued so the scope still survives to the next dispatch + // cycle. + // + // shared_ptr because Dispatchable is move-only at the AppRuntime + // layer but JsRuntime downstream type-erases via std::function which + // requires copy-constructibility. + m_runtime.Dispatch([scope = std::make_shared( + m_deviceContext.AcquireFrameCompletionScope())](auto) {}); + try { NativeDataStream::Reader reader = m_commandStream->GetReader(); @@ -2194,7 +2190,6 @@ namespace Babylon void NativeEngine::PopulateFrameStats(const Napi::CallbackInfo& info) { - const auto updateToken{m_update.GetUpdateToken()}; const auto stats{bgfx::getStats()}; const double toGpuNs = 1000000000.0 / double(stats->gpuTimerFreq); const double gpuTimeNs = (stats->gpuTimeEnd - stats->gpuTimeBegin) * toGpuNs; @@ -2202,6 +2197,18 @@ namespace Babylon jsStatsObject.Set("gpuTimeNs", gpuTimeNs); } + void NativeEngine::BeginFrame(const Napi::CallbackInfo&) + { + // Encoder is managed by StartRenderingCurrentFrame/FinishRenderingCurrentFrame. + // Nothing to do here. + } + + void NativeEngine::EndFrame(const Napi::CallbackInfo&) + { + // Encoder is managed by StartRenderingCurrentFrame/FinishRenderingCurrentFrame. + // Nothing to do here. + } + void NativeEngine::DrawInternal(bgfx::Encoder* encoder, uint32_t fillMode) { uint64_t fillModeState{0}; // indexed triangle list @@ -2252,7 +2259,7 @@ namespace Babylon encoder->setUniform({it.first}, value.Data.data(), value.ElementLength); } - auto& boundFrameBuffer = GetBoundFrameBuffer(*encoder); + auto& boundFrameBuffer = GetBoundFrameBuffer(); if (boundFrameBuffer.HasDepth()) { encoder->setState(m_engineState | fillModeState); @@ -2268,33 +2275,20 @@ namespace Babylon boundFrameBuffer.Submit(*encoder, m_currentProgram->Handle(), BGFX_DISCARD_ALL & ~BGFX_DISCARD_BINDINGS); } - Graphics::UpdateToken& NativeEngine::GetUpdateToken() - { - if (!m_updateToken) - { - m_updateToken.emplace(m_update.GetUpdateToken()); - m_runtime.Dispatch([this](auto) { - m_updateToken.reset(); - }); - } - - return m_updateToken.value(); - } - - Graphics::FrameBuffer& NativeEngine::GetBoundFrameBuffer(bgfx::Encoder& encoder) + Graphics::FrameBuffer& NativeEngine::GetBoundFrameBuffer() { if (m_boundFrameBuffer == nullptr) { m_boundFrameBuffer = &m_defaultFrameBuffer; - m_defaultFrameBuffer.Bind(encoder); + m_defaultFrameBuffer.Bind(); } - else if (m_boundFrameBufferNeedsRebinding.Get(encoder)) + else if (m_boundFrameBufferNeedsRebinding.Get()) { - m_boundFrameBuffer->Unbind(encoder); - m_boundFrameBuffer->Bind(encoder); + m_boundFrameBuffer->Unbind(); + m_boundFrameBuffer->Bind(); } - m_boundFrameBufferNeedsRebinding.Set(encoder, false); + m_boundFrameBufferNeedsRebinding.Set(false); return *m_boundFrameBuffer; } @@ -2307,8 +2301,23 @@ namespace Babylon m_requestAnimationFrameCallbacksScheduled = true; - arcana::make_task(m_update.Scheduler(), *m_cancellationSource, [this, cancellationSource{m_cancellationSource}]() { - return arcana::make_task(m_runtimeScheduler, *m_cancellationSource, [this, updateToken{m_update.GetUpdateToken()}, cancellationSource{m_cancellationSource}]() { + // Schedule a two-phase task: + // Phase 1 (FrameStartScheduler, runs on main thread during StartRenderingCurrentFrame): + // Acquires a FrameCompletionScope to keep the frame open, then dispatches to JS. + // Phase 2 (runtimeScheduler, runs on JS thread): + // Executes RAF callbacks (which call scene.render → beginFrame/endFrame). + // Defers scope release to the NEXT dispatch cycle so that any bgfx API calls + // triggered after callbacks return (e.g., GC-driven resource destruction) are + // still protected from concurrent bgfx::frame(). + arcana::make_task(m_deviceContext.FrameStartScheduler(), *m_cancellationSource, [this, cancellationSource{m_cancellationSource}]() { + return arcana::make_task( + m_runtimeScheduler + , *m_cancellationSource + , [this + , frameScope{std::make_shared(m_deviceContext.AcquireFrameCompletionScope())} + , cancellationSource{m_cancellationSource} + ]() + { m_requestAnimationFrameCallbacksScheduled = false; arcana::trace_region scheduleRegion{"NativeEngine::ScheduleRequestAnimationFrameCallbacks invoke JS callbacks"}; @@ -2317,6 +2326,12 @@ namespace Babylon { callback.Value().Call({}); } + + // Defer scope release to next dispatch cycle. The shared_ptr captured by + // the Dispatch lambda is the last reference — when that lambda runs and + // returns, the scope is destroyed, decrementing the counter and allowing + // FinishRenderingCurrentFrame to proceed. + m_runtime.Dispatch([prevent_frame = frameScope](auto) {}); }).then(arcana::inline_scheduler, *m_cancellationSource, [this, cancellationSource{m_cancellationSource}](const arcana::expected& result) { if (!cancellationSource->cancelled() && result.has_error()) { @@ -2325,4 +2340,11 @@ namespace Babylon }); }); } + + bgfx::Encoder* NativeEngine::GetEncoder() + { + bgfx::Encoder* encoder = m_deviceContext.GetActiveEncoder(); + assert(encoder != nullptr); + return encoder; + } } diff --git a/Plugins/NativeEngine/Source/NativeEngine.h b/Plugins/NativeEngine/Source/NativeEngine.h index 4c2e8c0cf..ae9f1a7f0 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.h +++ b/Plugins/NativeEngine/Source/NativeEngine.h @@ -26,6 +26,7 @@ #include #include +#include namespace Babylon { @@ -128,10 +129,12 @@ namespace Babylon void SetCommandDataStream(const Napi::CallbackInfo& info); void SubmitCommands(const Napi::CallbackInfo& info); void PopulateFrameStats(const Napi::CallbackInfo& info); + void BeginFrame(const Napi::CallbackInfo&); + void EndFrame(const Napi::CallbackInfo&); void DrawInternal(bgfx::Encoder* encoder, uint32_t fillMode); - Graphics::UpdateToken& GetUpdateToken(); - Graphics::FrameBuffer& GetBoundFrameBuffer(bgfx::Encoder& encoder); + bgfx::Encoder* GetEncoder(); + Graphics::FrameBuffer& GetBoundFrameBuffer(); std::shared_ptr m_cancellationSource{}; @@ -141,12 +144,9 @@ namespace Babylon JsRuntime& m_runtime; Graphics::DeviceContext& m_deviceContext; - Graphics::Update m_update; JsRuntimeScheduler m_runtimeScheduler; - std::optional m_updateToken{}; - void ScheduleRequestAnimationFrameCallbacks(); bool m_requestAnimationFrameCallbacksScheduled{}; diff --git a/Plugins/NativeEngine/Source/PerFrameValue.h b/Plugins/NativeEngine/Source/PerFrameValue.h index f594fb955..0932381a9 100644 --- a/Plugins/NativeEngine/Source/PerFrameValue.h +++ b/Plugins/NativeEngine/Source/PerFrameValue.h @@ -21,12 +21,12 @@ namespace Babylon { } - T Get(bgfx::Encoder&) const + T Get() const { return m_value; } - void Set(bgfx::Encoder&, bool value) + void Set(bool value) { m_value = value; if (!m_isResetScheduled) diff --git a/Plugins/NativeXr/Source/NativeXrImpl.cpp b/Plugins/NativeXr/Source/NativeXrImpl.cpp index 72a1185e0..0855b87ca 100644 --- a/Plugins/NativeXr/Source/NativeXrImpl.cpp +++ b/Plugins/NativeXr/Source/NativeXrImpl.cpp @@ -163,10 +163,10 @@ namespace Babylon // reason requestAnimationFrame is being called twice when starting XR. m_sessionState->ScheduleFrameCallbacks.emplace_back(callback); - m_sessionState->FrameTask = arcana::make_task(m_sessionState->Update.Scheduler(), m_sessionState->CancellationSource, [this, thisRef{shared_from_this()}] { + m_sessionState->FrameTask = arcana::make_task(m_sessionState->GraphicsContext.FrameStartScheduler(), m_sessionState->CancellationSource, [this, thisRef{shared_from_this()}] { BeginFrame(); - return arcana::make_task(m_runtimeScheduler, m_sessionState->CancellationSource, [this, updateToken{m_sessionState->Update.GetUpdateToken()}, thisRef{shared_from_this()}]() { + return arcana::make_task(m_runtimeScheduler, m_sessionState->CancellationSource, [this, frameScope{std::make_shared(m_sessionState->GraphicsContext.AcquireFrameCompletionScope())}, thisRef{shared_from_this()}]() { m_sessionState->FrameScheduled = false; BeginUpdate(); @@ -307,7 +307,7 @@ namespace Babylon // WebXR, at least in its current implementation, specifies an implicit default clear to black. // https://immersive-web.github.io/webxr/#xrwebgllayer-interface - frameBuffer.Clear(*m_sessionState->Update.GetUpdateToken().GetEncoder(), BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.0f, 0); + frameBuffer.Clear(*m_sessionState->GraphicsContext.GetActiveEncoder(), BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.0f, 0); viewConfig.FrameBuffers[eyeIdx] = frameBufferPtr; diff --git a/Plugins/NativeXr/Source/NativeXrImpl.h b/Plugins/NativeXr/Source/NativeXrImpl.h index 4c431d16e..2ce058598 100644 --- a/Plugins/NativeXr/Source/NativeXrImpl.h +++ b/Plugins/NativeXr/Source/NativeXrImpl.h @@ -111,12 +111,10 @@ namespace Babylon { explicit SessionState(Graphics::DeviceContext& graphicsContext) : GraphicsContext{graphicsContext} - , Update{GraphicsContext.GetUpdate("update")} { } Graphics::DeviceContext& GraphicsContext; - Graphics::Update Update; Napi::FunctionReference CreateRenderTexture{}; Napi::FunctionReference DestroyRenderTexture{}; std::vector ActiveViewConfigurations{}; diff --git a/Polyfills/Canvas/Source/Context.cpp b/Polyfills/Canvas/Source/Context.cpp index bb94333c7..ca59c44c1 100644 --- a/Polyfills/Canvas/Source/Context.cpp +++ b/Polyfills/Canvas/Source/Context.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #ifdef __GNUC__ @@ -105,7 +106,6 @@ namespace Babylon::Polyfills::Internal , m_canvas{NativeCanvas::Unwrap(info[0].As())} , m_nvg{std::make_shared(nvgCreate(1))} , m_graphicsContext{m_canvas->GetGraphicsContext()} - , m_update{m_graphicsContext.GetUpdate("update")} , m_cancellationSource{std::make_shared()} , m_runtimeScheduler{Babylon::JsRuntime::GetFromJavaScript(info.Env())} , Polyfills::Canvas::Impl::MonitoredResource{Polyfills::Canvas::Impl::GetFromJavaScript(info.Env())} @@ -663,20 +663,39 @@ namespace Babylon::Polyfills::Internal void Context::Flush(const Napi::CallbackInfo&) { + // Pick up any fonts loaded after this Context was created (#1683). EnsureFontsLoaded(); + // If called outside the frame cycle (e.g., during initialization/font loading + // or async texture load callbacks), acquire a FrameCompletionScope which blocks + // until StartRenderingCurrentFrame provides the encoder. + std::optional scope; + if (m_graphicsContext.GetActiveEncoder() == nullptr) + { + scope.emplace(m_graphicsContext.AcquireFrameCompletionScope()); + } + + bgfx::Encoder* encoder = m_graphicsContext.GetActiveEncoder(); + if (encoder == nullptr) + { + return; + } + + // Discard any residual encoder state from NativeEngine rendering. + // In the old model Canvas had its own per-thread encoder with clean state; + // now it shares the frame encoder with NativeEngine. + encoder->discard(BGFX_DISCARD_ALL); + bool needClear = m_canvas->UpdateRenderTarget(); Graphics::FrameBuffer& frameBuffer = m_canvas->GetFrameBuffer(); - auto updateToken{m_update.GetUpdateToken()}; - bgfx::Encoder* encoder = updateToken.GetEncoder(); - frameBuffer.Bind(*encoder); + frameBuffer.Bind(); if (needClear) { frameBuffer.Clear(*encoder, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH | BGFX_CLEAR_STENCIL, 0, 1.f, 0); } - frameBuffer.SetViewPort(*encoder, 0.f, 0.f, 1.f, 1.f); + frameBuffer.SetViewPort(0.f, 0.f, 1.f, 1.f); const auto width = m_canvas->GetWidth(); const auto height = m_canvas->GetHeight(); @@ -687,21 +706,21 @@ namespace Babylon::Polyfills::Internal } std::function acquire = [this, encoder]() -> Babylon::Graphics::FrameBuffer* { Babylon::Graphics::FrameBuffer *frameBuffer = this->m_canvas->m_frameBufferPool.Acquire(); - frameBuffer->Bind(*encoder); + 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(*encoder); + 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(*encoder); + frameBuffer.Unbind(); for (auto& buffer : m_canvas->m_frameBufferPool.GetPoolBuffers()) { diff --git a/Polyfills/Canvas/Source/Context.h b/Polyfills/Canvas/Source/Context.h index a0bb8bc04..261ce4c83 100644 --- a/Polyfills/Canvas/Source/Context.h +++ b/Polyfills/Canvas/Source/Context.h @@ -120,7 +120,6 @@ namespace Babylon::Polyfills::Internal std::vector m_savedStyles; Graphics::DeviceContext& m_graphicsContext; - Graphics::Update m_update; bool m_isClipped{false}; diff --git a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp index 15ffbfa56..9884b6e9a 100644 --- a/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp +++ b/Polyfills/Canvas/Source/nanovg/nanovg_babylon.cpp @@ -797,7 +797,7 @@ namespace outBuffer->Submit(*gl->encoder, prog, BGFX_DISCARD_ALL); }; Babylon::Graphics::FrameBuffer *finalFrameBuffer = gl->frameBuffer; - finalFrameBuffer->Bind(*gl->encoder); // Should this be bound elsewhere? + finalFrameBuffer->Bind(); // Should this be bound elsewhere? call->filterStack.Render(firstProg, setUniform, firstPass, filterPass, finalPass, finalFrameBuffer, gl->frameBufferPool.acquire, gl->frameBufferPool.release); } @@ -857,7 +857,7 @@ namespace outBuffer->Submit(*gl->encoder, prog, BGFX_DISCARD_ALL); }; Babylon::Graphics::FrameBuffer *finalFrameBuffer = gl->frameBuffer; - finalFrameBuffer->Bind(*gl->encoder); // Should this be bound elsewhere? + finalFrameBuffer->Bind(); // Should this be bound elsewhere? call->filterStack.Render(firstProg, setUniform, firstPass, filterPass, finalPass, finalFrameBuffer, gl->frameBufferPool.acquire, gl->frameBufferPool.release); } @@ -900,7 +900,7 @@ namespace outBuffer->Submit(*gl->encoder, prog, BGFX_DISCARD_ALL); }; Babylon::Graphics::FrameBuffer *finalFrameBuffer = gl->frameBuffer; - finalFrameBuffer->Bind(*gl->encoder); // Should this be bound elsewhere? + finalFrameBuffer->Bind(); // Should this be bound elsewhere? call->filterStack.Render(firstProg, setUniform, firstPass, filterPass, finalPass, finalFrameBuffer, gl->frameBufferPool.acquire, gl->frameBufferPool.release); } @@ -938,7 +938,7 @@ namespace outBuffer->Submit(*gl->encoder, prog, BGFX_DISCARD_ALL); }; Babylon::Graphics::FrameBuffer *finalFrameBuffer = gl->frameBuffer; - finalFrameBuffer->Bind(*gl->encoder); // Should this be bound elsewhere? + finalFrameBuffer->Bind(); // Should this be bound elsewhere? call->filterStack.Render(firstProg, setUniform, firstPass, filterPass, finalPass, finalFrameBuffer, gl->frameBufferPool.acquire, gl->frameBufferPool.release); }