From 531ac59527be7bbf6942e98b81d4a9cad3e6bcec Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 11 Jun 2026 10:45:05 -0700 Subject: [PATCH 1/3] NativeEngine: MultiRenderTarget framebuffers + OIT alpha blend modes Foundational native-engine support for MultiRenderTarget (MRT) and the order- independent-transparency blend modes, removing several "engine._gl is null" crash classes. It does not by itself land the MRT/OIT/FrameGraph validation tests, which need further work (see follow-ups below). - Add NativeEngine::CreateMultiFrameBuffer: build one bgfx framebuffer with N color attachments (+ optional depth) so a MultiRenderTarget renders to all targets at once (bgfx writes every attachment of the bound framebuffer, so no drawBuffers is needed). JS-controlled attachment count is validated against caps->limits.maxFBAttachments. - Add alpha blend modes ALPHA_ONEONE_ONEONE (11) and ALPHA_LAYER_ACCUMULATE (17) used by the depth-peeling OIT renderer. Pairs with the Babylon.js change (createMultipleRenderTarget + MRT helper overrides, applyStates, reverse-Z clear). Known follow-ups: the OIT depth- peeling path still faults inside the D3D11 driver on submit (needs interactive GPU debugging), and the blend equation (MAX) is not yet applied natively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 65 ++++++++++++++++++++ Plugins/NativeEngine/Source/NativeEngine.h | 1 + 2 files changed, 66 insertions(+) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 864031710..460ab1969 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -77,6 +77,8 @@ namespace Babylon constexpr uint64_t MULTIPLY = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_DST_COLOR, BGFX_STATE_BLEND_ZERO, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE); constexpr uint64_t MAXIMIZED = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_SRC_ALPHA, BGFX_STATE_BLEND_INV_SRC_COLOR, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE); constexpr uint64_t ONEONE = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ZERO, BGFX_STATE_BLEND_ONE); + constexpr uint64_t ONEONE_ONEONE = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE); + constexpr uint64_t LAYER_ACCUMULATE = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_SRC_ALPHA, BGFX_STATE_BLEND_INV_SRC_ALPHA, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA); constexpr uint64_t PREMULTIPLIED = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_ONE); constexpr uint64_t PREMULTIPLIED_PORTERDUFF = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA); constexpr uint64_t INTERPOLATE = BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_FACTOR, BGFX_STATE_BLEND_INV_FACTOR, BGFX_STATE_BLEND_FACTOR, BGFX_STATE_BLEND_INV_FACTOR); @@ -598,6 +600,8 @@ namespace Babylon StaticValue("ALPHA_MULTIPLY", Napi::Number::From(env, AlphaMode::MULTIPLY)), StaticValue("ALPHA_MAXIMIZED", Napi::Number::From(env, AlphaMode::MAXIMIZED)), StaticValue("ALPHA_ONEONE", Napi::Number::From(env, AlphaMode::ONEONE)), + StaticValue("ALPHA_ONEONE_ONEONE", Napi::Number::From(env, AlphaMode::ONEONE_ONEONE)), + StaticValue("ALPHA_LAYER_ACCUMULATE", Napi::Number::From(env, AlphaMode::LAYER_ACCUMULATE)), StaticValue("ALPHA_PREMULTIPLIED", Napi::Number::From(env, AlphaMode::PREMULTIPLIED)), StaticValue("ALPHA_PREMULTIPLIED_PORTERDUFF", Napi::Number::From(env, AlphaMode::PREMULTIPLIED_PORTERDUFF)), StaticValue("ALPHA_INTERPOLATE", Napi::Number::From(env, AlphaMode::INTERPOLATE)), @@ -726,6 +730,7 @@ namespace Babylon InstanceMethod("resizeImageBitmap", &NativeEngine::ResizeImageBitmap), InstanceMethod("createFrameBuffer", &NativeEngine::CreateFrameBuffer), + InstanceMethod("createMultiFrameBuffer", &NativeEngine::CreateMultiFrameBuffer), InstanceMethod("getRenderWidth", &NativeEngine::GetRenderWidth), InstanceMethod("getRenderHeight", &NativeEngine::GetRenderHeight), @@ -1912,6 +1917,66 @@ namespace Babylon return Napi::Pointer::Create(info.Env(), frameBuffer, Napi::NapiPointerDeleter(frameBuffer)); } + Napi::Value NativeEngine::CreateMultiFrameBuffer(const Napi::CallbackInfo& info) + { + const auto colorTextures = info[0].As(); + const uint16_t width = static_cast(info[1].As().Uint32Value()); + const uint16_t height = static_cast(info[2].As().Uint32Value()); + const bool generateStencilBuffer = info[3].As(); + const bool generateDepth = info[4].As(); + const uint32_t samples = info[5].IsUndefined() ? 1 : info[5].As().Uint32Value(); + + const bgfx::Caps* caps = bgfx::getCaps(); + const uint32_t colorCount = colorTextures.Length(); + // One slot per color attachment plus a single depth/stencil attachment. bgfx caps the total via + // maxFBAttachments; reject out-of-range counts up front rather than relying on the validation assert. + if (colorCount == 0 || colorCount + 1 > caps->limits.maxFBAttachments) + { + throw Napi::Error::New(info.Env(), "Invalid number of color attachments for multi render target frame buffer"); + } + + // BGFX_CONFIG_MAX_FRAME_BUFFER_ATTACHMENTS is 8, so 8 color + 1 depth fits in a fixed array. + std::array attachments{}; + uint8_t numAttachments = 0; + + for (uint32_t i = 0; i < colorCount; ++i) + { + const auto texture = colorTextures.Get(i).As>().Get(); + attachments[numAttachments++].init(texture->Handle(), bgfx::Access::Write, 0, 1, 0 + , 0 != (caps->formats[texture->Format()] & BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN) ? BGFX_RESOLVE_AUTO_GEN_MIPS : BGFX_RESOLVE_NONE + ); + } + + bgfx::TextureHandle depthStencilTextureHandle = BGFX_INVALID_HANDLE; + int8_t depthStencilAttachmentIndex = -1; + if (generateStencilBuffer || generateDepth) + { + auto flags = BGFX_TEXTURE_RT_WRITE_ONLY | RenderTargetSamplesToBgfxMsaaFlag(samples); +#ifdef ANDROID + const auto depthStencilFormat{bgfx::TextureFormat::D24S8}; +#else + const auto depthStencilFormat{generateStencilBuffer ? bgfx::TextureFormat::D24S8 : bgfx::TextureFormat::D32}; +#endif + depthStencilTextureHandle = bgfx::createTexture2D(width, height, false, 1, depthStencilFormat, flags); + depthStencilAttachmentIndex = numAttachments; + attachments[numAttachments++].init(depthStencilTextureHandle, bgfx::Access::Write, 0, 1, 0, BGFX_RESOLVE_NONE); + } + + bgfx::FrameBufferHandle frameBufferHandle = bgfx::createFrameBuffer(numAttachments, attachments.data()); + if (!bgfx::isValid(frameBufferHandle)) + { + if (bgfx::isValid(depthStencilTextureHandle)) + { + bgfx::destroy(depthStencilTextureHandle); + } + + throw Napi::Error::New(info.Env(), "Failed to create multi render target frame buffer"); + } + + Graphics::FrameBuffer* frameBuffer = new Graphics::FrameBuffer(m_deviceContext, frameBufferHandle, width, height, false, generateDepth, generateStencilBuffer, depthStencilAttachmentIndex); + return Napi::Pointer::Create(info.Env(), frameBuffer, Napi::NapiPointerDeleter(frameBuffer)); + } + // TODO: This doesn't get called when an Engine instance is disposed. void NativeEngine::DeleteFrameBuffer(NativeDataStream::Reader& data) { diff --git a/Plugins/NativeEngine/Source/NativeEngine.h b/Plugins/NativeEngine/Source/NativeEngine.h index 229ef0e3a..4c24ffe76 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.h +++ b/Plugins/NativeEngine/Source/NativeEngine.h @@ -115,6 +115,7 @@ namespace Babylon void DeleteTexture(const Napi::CallbackInfo& info); Napi::Value ReadTexture(const Napi::CallbackInfo& info); Napi::Value CreateFrameBuffer(const Napi::CallbackInfo& info); + Napi::Value CreateMultiFrameBuffer(const Napi::CallbackInfo& info); void DeleteFrameBuffer(NativeDataStream::Reader& data); void BindFrameBuffer(NativeDataStream::Reader& data); void UnbindFrameBuffer(NativeDataStream::Reader& data); From 779fc3415e87221368fe311dc12f7f9bc78ce4ff Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 13:45:28 -0700 Subject: [PATCH 2/3] NativeEngine: fold CreateFrameBuffer/CreateMultiFrameBuffer into shared helper Address review feedback (discussion_r3546780088): the single- and multi-RT framebuffer paths were ~95% identical. Extract the shared color/depth attachment setup, framebuffer creation and cleanup into a private CreateFrameBufferImpl helper. Both N-API methods are kept so the protocol is unchanged; they now only differ in how they parse info[0] (single nullable texture vs. array) before calling the shared helper. This restores the "Stencil without depth..." warning and the isTextureValid assert that the multi path had dropped, and lets the single-RT path be the 0-or-1-color case (the shared helper no longer rejects colorCount == 0). The maxFBAttachments upper-bound guard is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 111 ++++++++----------- Plugins/NativeEngine/Source/NativeEngine.h | 6 + 2 files changed, 53 insertions(+), 64 deletions(-) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 460ab1969..a3eaca373 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -1858,90 +1858,58 @@ namespace Babylon const bool generateDepth = info[4].As(); const uint32_t samples = info[5].IsUndefined() ? 1 : info[5].As().Uint32Value(); - std::array attachments{}; - uint8_t numAttachments = 0; - - if (texture != nullptr) - { - const bgfx::Caps* caps = bgfx::getCaps(); - // bgfx validation now asserts when trying to use BGFX_RESOLVE_AUTO_GEN_MIPS with a texture that doesn't have the BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN flag, - // but before it would just ignore the flag and not generate mips without any warning. This prevents validation assert, but rendering might be broken if autogen - // mips were expected. Basically this change preserves previous behavior. - attachments[numAttachments++].init(texture->Handle(), bgfx::Access::Write, 0, 1, 0 - , 0 != (caps->formats[texture->Format()] & BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN) ? BGFX_RESOLVE_AUTO_GEN_MIPS : BGFX_RESOLVE_NONE - ); - } - - bgfx::TextureHandle depthStencilTextureHandle = BGFX_INVALID_HANDLE; - int8_t depthStencilAttachmentIndex = -1; - if (generateStencilBuffer || generateDepth) - { - if (generateStencilBuffer && !generateDepth) - { - JsConsoleLogger::LogWarn(info.Env(), "Stencil without depth is not supported, assuming depth and stencil"); - } - - auto flags = BGFX_TEXTURE_RT_WRITE_ONLY | RenderTargetSamplesToBgfxMsaaFlag(samples); -#ifdef ANDROID - // On Android with Mali GPU (Oppo Find x5 lite, Google Pixel 8, Samsung Galaxy Tab Active 3, ...) - // D32 depth buffer gives glitches. Everything is fine with D24S8. - // see https://forum.babylonjs.com/t/post-processing-graphics-glitch/49523 - // As 24bits should be enough for 99.99% cases, defaulting to that format on Android. - const auto depthStencilFormat{bgfx::TextureFormat::D24S8}; -#else - const auto depthStencilFormat{generateStencilBuffer ? bgfx::TextureFormat::D24S8 : bgfx::TextureFormat::D32}; -#endif - assert(bgfx::isTextureValid(0, false, 1, depthStencilFormat, flags)); - depthStencilTextureHandle = bgfx::createTexture2D(width, height, false, 1, depthStencilFormat, flags); - - // bgfx doesn't add flag D3D11_RESOURCE_MISC_GENERATE_MIPS for depth textures (missing that flag will crash D3D with resolving) - // And not sure it makes sense to generate mipmaps from a depth buffer with exponential values. - // only allows mipmaps resolve step when mipmapping is asked and for the color texture, not the depth. - // https://github.com/bkaradzic/bgfx/blob/2c21f68998595fa388e25cb6527e82254d0e9bff/src/renderer_d3d11.cpp#L4525 - depthStencilAttachmentIndex = numAttachments; - attachments[numAttachments++].init(depthStencilTextureHandle, bgfx::Access::Write, 0, 1, 0, BGFX_RESOLVE_NONE); - } - - bgfx::FrameBufferHandle frameBufferHandle = bgfx::createFrameBuffer(numAttachments, attachments.data()); - if (!bgfx::isValid(frameBufferHandle)) - { - if (bgfx::isValid(depthStencilTextureHandle)) - { - bgfx::destroy(depthStencilTextureHandle); - } + // A single render target is just the zero-or-one color attachment case of the shared implementation. + Graphics::Texture* const colorTextures[]{texture}; + const gsl::span colorAttachments{colorTextures, texture != nullptr ? 1u : 0u}; - throw Napi::Error::New(info.Env(), "Failed to create frame buffer"); - } - - Graphics::FrameBuffer* frameBuffer = new Graphics::FrameBuffer(m_deviceContext, frameBufferHandle, width, height, false, generateDepth, generateStencilBuffer, depthStencilAttachmentIndex); - return Napi::Pointer::Create(info.Env(), frameBuffer, Napi::NapiPointerDeleter(frameBuffer)); + return CreateFrameBufferImpl(info.Env(), colorAttachments, width, height, generateStencilBuffer, generateDepth, samples); } Napi::Value NativeEngine::CreateMultiFrameBuffer(const Napi::CallbackInfo& info) { - const auto colorTextures = info[0].As(); + const auto colorTexturesArray = info[0].As(); const uint16_t width = static_cast(info[1].As().Uint32Value()); const uint16_t height = static_cast(info[2].As().Uint32Value()); const bool generateStencilBuffer = info[3].As(); const bool generateDepth = info[4].As(); const uint32_t samples = info[5].IsUndefined() ? 1 : info[5].As().Uint32Value(); + const uint32_t colorCount = colorTexturesArray.Length(); + // BGFX_CONFIG_MAX_FRAME_BUFFER_ATTACHMENTS is 8, so at most 8 color attachments fit alongside the depth attachment. + std::array colorTextures{}; + if (colorCount > colorTextures.size()) + { + throw Napi::Error::New(info.Env(), "Invalid number of color attachments for multi render target frame buffer"); + } + + for (uint32_t i = 0; i < colorCount; ++i) + { + colorTextures[i] = colorTexturesArray.Get(i).As>().Get(); + } + + return CreateFrameBufferImpl(info.Env(), gsl::span{colorTextures.data(), colorCount}, width, height, generateStencilBuffer, generateDepth, samples); + } + + Napi::Value NativeEngine::CreateFrameBufferImpl(Napi::Env env, gsl::span colorTextures, uint16_t width, uint16_t height, bool generateStencilBuffer, bool generateDepth, uint32_t samples) + { const bgfx::Caps* caps = bgfx::getCaps(); - const uint32_t colorCount = colorTextures.Length(); + const uint32_t colorCount = static_cast(colorTextures.size()); // One slot per color attachment plus a single depth/stencil attachment. bgfx caps the total via // maxFBAttachments; reject out-of-range counts up front rather than relying on the validation assert. - if (colorCount == 0 || colorCount + 1 > caps->limits.maxFBAttachments) + if (colorCount + 1 > caps->limits.maxFBAttachments) { - throw Napi::Error::New(info.Env(), "Invalid number of color attachments for multi render target frame buffer"); + throw Napi::Error::New(env, "Invalid number of color attachments for frame buffer"); } // BGFX_CONFIG_MAX_FRAME_BUFFER_ATTACHMENTS is 8, so 8 color + 1 depth fits in a fixed array. std::array attachments{}; uint8_t numAttachments = 0; - for (uint32_t i = 0; i < colorCount; ++i) + for (Graphics::Texture* texture : colorTextures) { - const auto texture = colorTextures.Get(i).As>().Get(); + // bgfx validation now asserts when trying to use BGFX_RESOLVE_AUTO_GEN_MIPS with a texture that doesn't have the BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN flag, + // but before it would just ignore the flag and not generate mips without any warning. This prevents validation assert, but rendering might be broken if autogen + // mips were expected. Basically this change preserves previous behavior. attachments[numAttachments++].init(texture->Handle(), bgfx::Access::Write, 0, 1, 0 , 0 != (caps->formats[texture->Format()] & BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN) ? BGFX_RESOLVE_AUTO_GEN_MIPS : BGFX_RESOLVE_NONE ); @@ -1951,13 +1919,28 @@ namespace Babylon int8_t depthStencilAttachmentIndex = -1; if (generateStencilBuffer || generateDepth) { + if (generateStencilBuffer && !generateDepth) + { + JsConsoleLogger::LogWarn(env, "Stencil without depth is not supported, assuming depth and stencil"); + } + auto flags = BGFX_TEXTURE_RT_WRITE_ONLY | RenderTargetSamplesToBgfxMsaaFlag(samples); #ifdef ANDROID + // On Android with Mali GPU (Oppo Find x5 lite, Google Pixel 8, Samsung Galaxy Tab Active 3, ...) + // D32 depth buffer gives glitches. Everything is fine with D24S8. + // see https://forum.babylonjs.com/t/post-processing-graphics-glitch/49523 + // As 24bits should be enough for 99.99% cases, defaulting to that format on Android. const auto depthStencilFormat{bgfx::TextureFormat::D24S8}; #else const auto depthStencilFormat{generateStencilBuffer ? bgfx::TextureFormat::D24S8 : bgfx::TextureFormat::D32}; #endif + assert(bgfx::isTextureValid(0, false, 1, depthStencilFormat, flags)); depthStencilTextureHandle = bgfx::createTexture2D(width, height, false, 1, depthStencilFormat, flags); + + // bgfx doesn't add flag D3D11_RESOURCE_MISC_GENERATE_MIPS for depth textures (missing that flag will crash D3D with resolving) + // And not sure it makes sense to generate mipmaps from a depth buffer with exponential values. + // only allows mipmaps resolve step when mipmapping is asked and for the color texture, not the depth. + // https://github.com/bkaradzic/bgfx/blob/2c21f68998595fa388e25cb6527e82254d0e9bff/src/renderer_d3d11.cpp#L4525 depthStencilAttachmentIndex = numAttachments; attachments[numAttachments++].init(depthStencilTextureHandle, bgfx::Access::Write, 0, 1, 0, BGFX_RESOLVE_NONE); } @@ -1970,11 +1953,11 @@ namespace Babylon bgfx::destroy(depthStencilTextureHandle); } - throw Napi::Error::New(info.Env(), "Failed to create multi render target frame buffer"); + throw Napi::Error::New(env, "Failed to create frame buffer"); } Graphics::FrameBuffer* frameBuffer = new Graphics::FrameBuffer(m_deviceContext, frameBufferHandle, width, height, false, generateDepth, generateStencilBuffer, depthStencilAttachmentIndex); - return Napi::Pointer::Create(info.Env(), frameBuffer, Napi::NapiPointerDeleter(frameBuffer)); + return Napi::Pointer::Create(env, frameBuffer, Napi::NapiPointerDeleter(frameBuffer)); } // TODO: This doesn't get called when an Engine instance is disposed. diff --git a/Plugins/NativeEngine/Source/NativeEngine.h b/Plugins/NativeEngine/Source/NativeEngine.h index 4c24ffe76..8c250eb57 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.h +++ b/Plugins/NativeEngine/Source/NativeEngine.h @@ -33,6 +33,11 @@ namespace Babylon { + namespace Graphics + { + class Texture; + } + class NativeEngine final : public Napi::ObjectWrap { static constexpr auto JS_CLASS_NAME = "_NativeEngine"; @@ -116,6 +121,7 @@ namespace Babylon Napi::Value ReadTexture(const Napi::CallbackInfo& info); Napi::Value CreateFrameBuffer(const Napi::CallbackInfo& info); Napi::Value CreateMultiFrameBuffer(const Napi::CallbackInfo& info); + Napi::Value CreateFrameBufferImpl(Napi::Env env, gsl::span colorTextures, uint16_t width, uint16_t height, bool generateStencilBuffer, bool generateDepth, uint32_t samples); void DeleteFrameBuffer(NativeDataStream::Reader& data); void BindFrameBuffer(NativeDataStream::Reader& data); void UnbindFrameBuffer(NativeDataStream::Reader& data); From 05862e2e914cdeebe2d5c5cc19c8579e2cba76c8 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 8 Jul 2026 16:13:10 -0700 Subject: [PATCH 3/3] NativeEngine: only reserve depth/stencil FB slot when generated The attachment-count guard in CreateFrameBufferImpl unconditionally added 1 for a depth/stencil attachment (colorCount + 1), which incorrectly rejected a valid MRT that uses the maximum number of color attachments with no depth/stencil (generateDepth=false, generateStencilBuffer=false). Reserve the depth/stencil slot only when one is actually generated, matching the attachment-creation logic below. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index a3eaca373..744065f9b 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -1894,9 +1894,11 @@ namespace Babylon { const bgfx::Caps* caps = bgfx::getCaps(); const uint32_t colorCount = static_cast(colorTextures.size()); - // One slot per color attachment plus a single depth/stencil attachment. bgfx caps the total via - // maxFBAttachments; reject out-of-range counts up front rather than relying on the validation assert. - if (colorCount + 1 > caps->limits.maxFBAttachments) + // One slot per color attachment, plus a single depth/stencil attachment only when one is + // generated. bgfx caps the total via maxFBAttachments; reject out-of-range counts up front + // rather than relying on the validation assert. + const uint32_t depthStencilCount = (generateStencilBuffer || generateDepth) ? 1u : 0u; + if (colorCount + depthStencilCount > caps->limits.maxFBAttachments) { throw Napi::Error::New(env, "Invalid number of color attachments for frame buffer"); }