From 77fc9bde0f1345809ab28cf8ea40d8638331251a Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 9 Jul 2026 12:51:40 -0700 Subject: [PATCH 1/4] NativeEngine: add updateTextureDirectly texture-loader sink Implement the native sink for Babylon's _uploadDataToTextureDirectly / _uploadCompressedDataToTextureDirectly so single-file container textures (.dds/.ktx/.ktx2, plus Basis/IES/HDR/EXR/TGA) load through the same JS texture loaders WebGL/WebGPU use. The loaders upload one (face, mip) at a time WebGL texImage2D-style; bgfx needs the whole texture allocated first, so the underlying texture is created lazily on the first upload. Validates JS-provided dimensions against maxTextureSize before uint16 narrowing and the payload size against bimg::imageGetSize, uses bgfx::copy for async-owned upload memory, and matches the existing loader flip conventions (skipping row-flips for compressed formats). Addresses #218 (paired with the Babylon.js single-file cubemap loader change). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 72 ++++++++++++++++++++ Plugins/NativeEngine/Source/NativeEngine.h | 1 + 2 files changed, 73 insertions(+) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 864031710..e6c9f860d 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -715,6 +715,7 @@ namespace Babylon InstanceMethod("loadTexture", &NativeEngine::LoadTexture), InstanceMethod("loadRawTexture", &NativeEngine::LoadRawTexture), InstanceMethod("loadRawTexture2DArray", &NativeEngine::LoadRawTexture2DArray), + InstanceMethod("updateTextureDirectly", &NativeEngine::UpdateTextureDirectly), InstanceMethod("loadCubeTexture", &NativeEngine::LoadCubeTexture), InstanceMethod("loadCubeTextureWithMips", &NativeEngine::LoadCubeTextureWithMips), InstanceMethod("getTextureWidth", &NativeEngine::GetTextureWidth), @@ -1520,6 +1521,77 @@ namespace Babylon #endif } + // Implements the shared JS texture-loader sink (Babylon's _uploadDataToTextureDirectly / + // _uploadCompressedDataToTextureDirectly), letting DDS/KTX/KTX2/Basis/IES/HDR/EXR/TGA load + // through the same loaders WebGL/WebGPU use. The loaders upload one (face, mip) at a time, + // WebGL texImage2D-style; bgfx instead needs the whole texture allocated before any update, + // so the texture is created lazily on the first upload. + void NativeEngine::UpdateTextureDirectly(const Napi::CallbackInfo& info) + { + const auto texture{info[0].As>().Get()}; + const auto data{info[1].As()}; + const auto faceIndex{static_cast(info[2].As().Uint32Value())}; + const auto lod{static_cast(info[3].As().Uint32Value())}; + const auto baseWidth{info[4].As().Uint32Value()}; + const auto baseHeight{info[5].As().Uint32Value()}; + const auto mipWidth{info[6].As().Uint32Value()}; + const auto mipHeight{info[7].As().Uint32Value()}; + const auto format{static_cast(info[8].As().Uint32Value())}; + const auto isCube{info[9].As().Value()}; + const auto hasMips{info[10].As().Value()}; + const auto invertY{info[11].As().Value()}; + + // Validate the JS-provided dimensions against GPU limits before narrowing to uint16_t, + // so an out-of-range value can't wrap into an in-range one and drive an OOB read/upload. + const auto maxTextureSize = bgfx::getCaps()->limits.maxTextureSize; + if (baseWidth == 0 || baseHeight == 0 || mipWidth == 0 || mipHeight == 0 || + baseWidth > maxTextureSize || baseHeight > maxTextureSize || + mipWidth > maxTextureSize || mipHeight > maxTextureSize) + { + throw Napi::Error::New(Env(), "Invalid base or mip dimensions for the texture."); + } + + if (!texture->IsValid()) + { + if (isCube) + { + texture->CreateCube(static_cast(baseWidth), hasMips, 1, Cast(format), BGFX_TEXTURE_NONE); + } + else + { + texture->Create2D(static_cast(baseWidth), static_cast(baseHeight), hasMips, 1, Cast(format), BGFX_TEXTURE_NONE); + } + } + + const uint64_t expectedSize{bimg::imageGetSize(nullptr, static_cast(mipWidth), static_cast(mipHeight), 1, false, false, 1, format)}; + if (expectedSize == 0 || static_cast(data.ByteLength()) != expectedSize) + { + throw Napi::Error::New(Env(), "The data size does not match mip dimensions and format."); + } + + const auto bytes{static_cast(data.ArrayBuffer().Data()) + data.ByteOffset()}; + // bgfx must own the upload buffer (released asynchronously after the GPU consumes it). + const bgfx::Memory* mem{bgfx::copy(bytes, static_cast(data.ByteLength()))}; + + // Match the existing loader flip conventions: cube faces flip only on origin-bottom-left + // (OpenGL), like LoadCubeTextureFromImages; 2D follows the raw/loadTexture convention. + // Compressed block data cannot be row-flipped. + const bool flip{isCube ? bgfx::getCaps()->originBottomLeft : (bgfx::getCaps()->originBottomLeft ? invertY : !invertY)}; + if (flip && !bimg::isCompressed(format)) + { + FlipImage({mem->data, mem->size}, static_cast(mipHeight)); + } + + if (isCube) + { + texture->UpdateCube(0, faceIndex, lod, 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); + } + else + { + texture->Update2D(0, lod, 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); + } + } + void NativeEngine::LoadCubeTexture(const Napi::CallbackInfo& info) { #ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES diff --git a/Plugins/NativeEngine/Source/NativeEngine.h b/Plugins/NativeEngine/Source/NativeEngine.h index 229ef0e3a..58e5e6ead 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.h +++ b/Plugins/NativeEngine/Source/NativeEngine.h @@ -102,6 +102,7 @@ namespace Babylon void CopyTexture(NativeDataStream::Reader& data); void LoadRawTexture(const Napi::CallbackInfo& info); void LoadRawTexture2DArray(const Napi::CallbackInfo& info); + void UpdateTextureDirectly(const Napi::CallbackInfo& info); void LoadCubeTexture(const Napi::CallbackInfo& info); void LoadCubeTextureWithMips(const Napi::CallbackInfo& info); Napi::Value GetTextureWidth(const Napi::CallbackInfo& info); From c82382124f6769c46eb22f8efa395fe18a75afd8 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 9 Jul 2026 15:42:27 -0700 Subject: [PATCH 2/4] Address Copilot review: guard, validate, and bound UpdateTextureDirectly - Guard the body with BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES (matches LoadRawTexture/LoadCubeTexture); throw when image loading is off so the plugin still compiles with LOAD_IMAGES=OFF (bimg headers are gated). - Read face index and lod as uint32 and validate BEFORE narrowing to uint8_t. - Reject non-square cube dimensions and out-of-range face indices (cube face < 6, 2D face == 0). - Reject lod values that exceed the mip chain implied by the base dimensions. - Reject uploads whose size would truncate bgfx::copy's 32-bit length. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 53 ++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index e6c9f860d..bc4d84299 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -1528,10 +1528,13 @@ namespace Babylon // so the texture is created lazily on the first upload. void NativeEngine::UpdateTextureDirectly(const Napi::CallbackInfo& info) { +#ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES + throw Napi::Error::New(info.Env(), "Image loading is disabled in this build (BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES=OFF)."); +#else const auto texture{info[0].As>().Get()}; const auto data{info[1].As()}; - const auto faceIndex{static_cast(info[2].As().Uint32Value())}; - const auto lod{static_cast(info[3].As().Uint32Value())}; + const auto faceIndexValue{info[2].As().Uint32Value()}; + const auto lodValue{info[3].As().Uint32Value()}; const auto baseWidth{info[4].As().Uint32Value()}; const auto baseHeight{info[5].As().Uint32Value()}; const auto mipWidth{info[6].As().Uint32Value()}; @@ -1551,6 +1554,41 @@ namespace Babylon throw Napi::Error::New(Env(), "Invalid base or mip dimensions for the texture."); } + // Cube textures must be square and address one of the six faces; 2D textures have a single + // face. Validate the JS-provided face index before narrowing it to uint8_t. + if (isCube) + { + if (baseWidth != baseHeight || mipWidth != mipHeight) + { + throw Napi::Error::New(Env(), "Cube texture dimensions must be square."); + } + if (faceIndexValue >= 6) + { + throw Napi::Error::New(Env(), "Cube face index must be in the range [0, 5]."); + } + } + else if (faceIndexValue != 0) + { + throw Napi::Error::New(Env(), "Face index must be 0 for a 2D texture."); + } + + // The lod must address a real mip level: level 0 only when the texture has no mipmaps, + // otherwise within the mip chain implied by the base dimensions. + uint32_t numMips{1}; + if (hasMips) + { + uint32_t levelDim{baseWidth > baseHeight ? baseWidth : baseHeight}; + while (levelDim > 1) + { + levelDim >>= 1; + ++numMips; + } + } + if (lodValue >= numMips) + { + throw Napi::Error::New(Env(), "Lod exceeds the texture's mip level count."); + } + if (!texture->IsValid()) { if (isCube) @@ -1569,6 +1607,12 @@ namespace Babylon throw Napi::Error::New(Env(), "The data size does not match mip dimensions and format."); } + // bgfx::copy takes a 32-bit size; reject anything that would truncate. + if (expectedSize > 0xFFFFFFFFull) + { + throw Napi::Error::New(Env(), "Texture upload size exceeds the maximum supported size."); + } + const auto bytes{static_cast(data.ArrayBuffer().Data()) + data.ByteOffset()}; // bgfx must own the upload buffer (released asynchronously after the GPU consumes it). const bgfx::Memory* mem{bgfx::copy(bytes, static_cast(data.ByteLength()))}; @@ -1584,12 +1628,13 @@ namespace Babylon if (isCube) { - texture->UpdateCube(0, faceIndex, lod, 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); + texture->UpdateCube(0, static_cast(faceIndexValue), static_cast(lodValue), 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); } else { - texture->Update2D(0, lod, 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); + texture->Update2D(0, static_cast(lodValue), 0, 0, static_cast(mipWidth), static_cast(mipHeight), mem); } +#endif } void NativeEngine::LoadCubeTexture(const Napi::CallbackInfo& info) From cfd70e851ed17a80f251589e9792f33cc49d56d0 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 9 Jul 2026 16:06:00 -0700 Subject: [PATCH 3/4] Address Copilot re-review: validate mip dimensions against base and lod Reject UpdateTextureDirectly calls whose mipWidth/mipHeight don't match max(1, base >> lod), so an inconsistent update region can't drive a bgfx assertion or backend-dependent undefined behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index bc4d84299..cbcef0298 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -1589,6 +1589,17 @@ namespace Babylon throw Napi::Error::New(Env(), "Lod exceeds the texture's mip level count."); } + // The mip dimensions must match the level implied by (base >> lod); otherwise bgfx would + // update a region that doesn't match the allocated mip level (asserts / UB on some backends). + const uint32_t shiftedWidth{baseWidth >> lodValue}; + const uint32_t shiftedHeight{baseHeight >> lodValue}; + const uint32_t expectedMipWidth{shiftedWidth > 1 ? shiftedWidth : 1}; + const uint32_t expectedMipHeight{shiftedHeight > 1 ? shiftedHeight : 1}; + if (mipWidth != expectedMipWidth || mipHeight != expectedMipHeight) + { + throw Napi::Error::New(Env(), "Mip dimensions do not match the base dimensions and lod."); + } + if (!texture->IsValid()) { if (isCube) From 1bf6f14f4e429244854375064c0c1b4350a630e7 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 9 Jul 2026 17:07:51 -0700 Subject: [PATCH 4/4] Address Copilot review: validate texture format before use Read the JS-provided format as uint32 and reject values >= bimg::TextureFormat::Count before narrowing to the enum, so an out-of-range value can't index past bimg/bgfx's format tables in Create*/imageGetSize. Mirrors the existing pre-narrow validation of faceIndex/lod/dimensions in this method. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Plugins/NativeEngine/Source/NativeEngine.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index cbcef0298..391d1ea4c 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -1539,7 +1539,7 @@ namespace Babylon const auto baseHeight{info[5].As().Uint32Value()}; const auto mipWidth{info[6].As().Uint32Value()}; const auto mipHeight{info[7].As().Uint32Value()}; - const auto format{static_cast(info[8].As().Uint32Value())}; + const auto formatValue{info[8].As().Uint32Value()}; const auto isCube{info[9].As().Value()}; const auto hasMips{info[10].As().Value()}; const auto invertY{info[11].As().Value()}; @@ -1554,6 +1554,14 @@ namespace Babylon throw Napi::Error::New(Env(), "Invalid base or mip dimensions for the texture."); } + // The format is a raw JS enum value; validate it before narrowing so an out-of-range value + // can't index past bimg/bgfx's format tables in Create*/imageGetSize (OOB read). + if (formatValue >= bimg::TextureFormat::Count) + { + throw Napi::Error::New(Env(), "Invalid texture format."); + } + const auto format{static_cast(formatValue)}; + // Cube textures must be square and address one of the six faces; 2D textures have a single // face. Validate the JS-provided face index before narrowing it to uint8_t. if (isCube)