diff --git a/Apps/Playground/Scripts/config.json b/Apps/Playground/Scripts/config.json index 3b7389558..cb286c870 100644 --- a/Apps/Playground/Scripts/config.json +++ b/Apps/Playground/Scripts/config.json @@ -858,8 +858,6 @@ { "title": "NMEGLTF", "playgroundId": "#WGZLGJ#10320", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "nmegltf.png" }, { @@ -1056,15 +1054,11 @@ { "title": "Anisotropic", "playgroundId": "#MAXCNU#1", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "anisotropic.png" }, { "title": "Clear Coat", "playgroundId": "#YACNQS#2", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "clearCoat.png" }, { @@ -1571,22 +1565,16 @@ { "title": "PBRMetallicRoughnessMaterial", "playgroundId": "#2FDQT5#13", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "PBRMetallicRoughnessMaterial.png" }, { "title": "PBRSpecularGlossinessMaterial", "playgroundId": "#Z1VL3V#4", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "PBRSpecularGlossinessMaterial.png" }, { "title": "PBR", "playgroundId": "#LCA0Q4#27", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "pbr.png" }, { @@ -1823,16 +1811,12 @@ "title": "Shadows with instances in left handed system", "playgroundId": "#MSAHKR#79", "renderCount": 10, - "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", "referenceImage": "shadowsinstancesleft.png" }, { "title": "Shadows with instances in right handed system", "playgroundId": "#MSAHKR#13", "renderCount": 10, - "excludeFromAutomaticTesting": true, - "reason": "Test crashes or hangs on Babylon Native", "referenceImage": "shadowsinstancesright.png" }, { @@ -2212,8 +2196,6 @@ { "title": "Test updateTextureData", "playgroundId": "#EVX1DH#80", - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "testUpdateTextureData.png" }, { @@ -2575,8 +2557,6 @@ "title": "blur-cube-with-the-effect-renderer", "playgroundId": "#4C900K#2", "renderCount": 20, - "excludeFromAutomaticTesting": true, - "reason": "Pixel comparison fails (more than 20% pixels differ)", "referenceImage": "blur-cube-with-the-effect-renderer.png" }, { diff --git a/Plugins/NativeEngine/Source/NativeEngine.cpp b/Plugins/NativeEngine/Source/NativeEngine.cpp index 864031710..b5a1f91d9 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.cpp +++ b/Plugins/NativeEngine/Source/NativeEngine.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #ifdef BABYLON_NATIVE_NATIVEENGINE_TEST_HOOKS #include @@ -417,6 +418,241 @@ namespace Babylon } } } + // Parse a single self-contained cubemap container (e.g. .dds / .ktx / + // .ktx2) that already holds all six faces and their mip chain. bimg + // decodes these natively, so there is no need to split into six images + // on the JS side. Unlike ParseImage (which targets single-face 2D images + // and asserts !m_cubeMap), this keeps the container as-is. + bimg::ImageContainer* ParseCubeImage(bx::AllocatorI& allocator, gsl::span data) + { + bx::ErrorIgnore parseError; + bimg::ImageContainer* image{bimg::imageParse(&allocator, data.data(), static_cast(data.size()), bimg::TextureFormat::Count, &parseError)}; + if (image == nullptr) + { + throw std::runtime_error{"Failed to parse cube image."}; + } + + if (!image->m_cubeMap) + { + bimg::imageFree(image); + throw std::runtime_error{"Image is not a cubemap."}; + } + + return image; + } + + // Port of Babylon.js CubeMapToSphericalPolynomialTools.ConvertCubeMapToSphericalPolynomial. + // Prefiltered .dds environments need diffuse-IBL spherical harmonics, which Babylon's WebGL + // path computes on the CPU from the top-mip faces. The native engine cannot read cube faces + // back from the GPU (_readTexturePixels throws for cube faces), so we compute the harmonics + // here from the bimg-decoded top mip. Returns the 9x3 polynomial coefficients in + // SphericalPolynomial.FromArray order: x, y, z, xx, yy, zz, yz, zx, xy. + std::array ComputeCubeSphericalPolynomial(bx::AllocatorI& allocator, bimg::ImageContainer* image) + { + std::array result{}; + + bimg::ImageContainer* f32{bimg::imageConvert(&allocator, bimg::TextureFormat::RGBA32F, *image, false)}; + if (f32 == nullptr) + { + return result; + } + + const uint32_t size{f32->m_width}; + constexpr double pi{3.14159265358979323846}; + + // Face orientations matching Babylon's _FileFaces, indexed by bimg cube side order + // (+X, -X, +Y, -Y, +Z, -Z): worldAxisForNormal, worldAxisForFileX, worldAxisForFileY. + struct FaceAxes + { + double n[3]; + double fx[3]; + double fy[3]; + }; + static const FaceAxes faces[6] = { + {{1, 0, 0}, {0, 0, -1}, {0, -1, 0}}, // +X right + {{-1, 0, 0}, {0, 0, 1}, {0, -1, 0}}, // -X left + {{0, 1, 0}, {1, 0, 0}, {0, 0, 1}}, // +Y up + {{0, -1, 0}, {1, 0, 0}, {0, 0, -1}}, // -Y down + {{0, 0, 1}, {1, 0, 0}, {0, -1, 0}}, // +Z front + {{0, 0, -1}, {-1, 0, 0}, {0, -1, 0}}, // -Z back + }; + + const double shConst[9] = { + std::sqrt(1.0 / (4.0 * pi)), + -std::sqrt(3.0 / (4.0 * pi)), + std::sqrt(3.0 / (4.0 * pi)), + -std::sqrt(3.0 / (4.0 * pi)), + std::sqrt(15.0 / (4.0 * pi)), + -std::sqrt(15.0 / (4.0 * pi)), + std::sqrt(5.0 / (16.0 * pi)), + -std::sqrt(15.0 / (4.0 * pi)), + std::sqrt(15.0 / (16.0 * pi)), + }; + const double cosKernel[9] = {pi, 2.0 * pi / 3.0, 2.0 * pi / 3.0, 2.0 * pi / 3.0, pi / 4.0, pi / 4.0, pi / 4.0, pi / 4.0, pi / 4.0}; + + const auto areaElement = [](double x, double y) { return std::atan2(x * y, std::sqrt(x * x + y * y + 1.0)); }; + + double sh[9][3] = {}; + double totalSolidAngle{0.0}; + + const double du{2.0 / static_cast(size)}; + const double halfTexel{0.5 * du}; + const double minUV{halfTexel - 1.0}; + const double maxHdri{4096.0}; + + for (uint16_t side = 0; side < 6; ++side) + { + bimg::ImageMip mip{}; + if (!bimg::imageGetRawData(*f32, side, 0, f32->m_data, f32->m_size, mip)) + { + continue; + } + + const float* data{reinterpret_cast(mip.m_data)}; + const FaceAxes& f{faces[side]}; + + double v{minUV}; + for (uint32_t y = 0; y < size; ++y) + { + double u{minUV}; + for (uint32_t x = 0; x < size; ++x) + { + double dir[3] = { + f.fx[0] * u + f.fy[0] * v + f.n[0], + f.fx[1] * u + f.fy[1] * v + f.n[1], + f.fx[2] * u + f.fy[2] * v + f.n[2], + }; + const double len{std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2])}; + dir[0] /= len; + dir[1] /= len; + dir[2] /= len; + + const double deltaSolidAngle{ + areaElement(u - halfTexel, v - halfTexel) - + areaElement(u - halfTexel, v + halfTexel) - + areaElement(u + halfTexel, v - halfTexel) + + areaElement(u + halfTexel, v + halfTexel)}; + + const size_t idx{(static_cast(y) * size + x) * 4}; + double rgb[3] = {data[idx + 0], data[idx + 1], data[idx + 2]}; + for (int c = 0; c < 3; ++c) + { + if (std::isnan(rgb[c])) + { + rgb[c] = 0.0; + } + rgb[c] = rgb[c] < 0.0 ? 0.0 : (rgb[c] > maxHdri ? maxHdri : rgb[c]); + } + + const double trig[9] = { + 1.0, + dir[1], + dir[2], + dir[0], + dir[0] * dir[1], + dir[1] * dir[2], + 3.0 * dir[2] * dir[2] - 1.0, + dir[0] * dir[2], + dir[0] * dir[0] - dir[1] * dir[1], + }; + for (int lm = 0; lm < 9; ++lm) + { + const double basis{shConst[lm] * trig[lm] * deltaSolidAngle}; + sh[lm][0] += rgb[0] * basis; + sh[lm][1] += rgb[1] * basis; + sh[lm][2] += rgb[2] * basis; + } + totalSolidAngle += deltaSolidAngle; + u += du; + } + v += du; + } + } + + bimg::imageFree(f32); + + if (totalSolidAngle <= 0.0) + { + return result; + } + + // scaleInPlace(correction) + convertIncidentRadianceToIrradiance + convertIrradianceToLambertianRadiance. + const double correction{(4.0 * pi) / totalSolidAngle}; + for (int lm = 0; lm < 9; ++lm) + { + const double scale{correction * cosKernel[lm] / pi}; + sh[lm][0] *= scale; + sh[lm][1] *= scale; + sh[lm][2] *= scale; + } + + // SphericalPolynomial.FromHarmonics (updateFromHarmonics then *1/pi). + for (int c = 0; c < 3; ++c) + { + const double l00{sh[0][c]}, l1_1{sh[1][c]}, l10{sh[2][c]}, l11{sh[3][c]}; + const double l2_2{sh[4][c]}, l2_1{sh[5][c]}, l20{sh[6][c]}, l21{sh[7][c]}, l22{sh[8][c]}; + const double invPi{1.0 / pi}; + result[0 * 3 + c] = static_cast(-1.02333 * l11 * invPi); // x + result[1 * 3 + c] = static_cast(-1.02333 * l1_1 * invPi); // y + result[2 * 3 + c] = static_cast(1.02333 * l10 * invPi); // z + result[3 * 3 + c] = static_cast((0.886277 * l00 - 0.247708 * l20 + 0.429043 * l22) * invPi); // xx + result[4 * 3 + c] = static_cast((0.886277 * l00 - 0.247708 * l20 - 0.429043 * l22) * invPi); // yy + result[5 * 3 + c] = static_cast((0.886277 * l00 + 0.495417 * l20) * invPi); // zz + result[6 * 3 + c] = static_cast(-0.858086 * l2_1 * invPi); // yz + result[7 * 3 + c] = static_cast(-0.858086 * l21 * invPi); // zx + result[8 * 3 + c] = static_cast(0.858086 * l2_2 * invPi); // xy + } + + return result; + } + + void LoadCubeTextureFromContainer(Graphics::Texture* texture, bimg::ImageContainer* image, bool srgb) + { + assert(image->m_cubeMap); + assert(image->m_width == image->m_height); + const uint32_t size{image->m_width}; + + if (texture->IsValid()) + { + if (texture->Width() != size || texture->Height() != size) + { + bimg::imageFree(image); + throw std::runtime_error{"Cannot update texture from image of different size"}; + } + } + else + { + const bool hasMips{image->m_numMips > 1}; + const bgfx::TextureFormat::Enum format{Cast(image->m_format)}; + const uint64_t flags{srgb ? BGFX_TEXTURE_SRGB : BGFX_TEXTURE_NONE}; + texture->CreateCube(static_cast(size), hasMips, 1, format, flags); + } + + // Every (side, mip) view points into the single container's backing + // store, so the allocation is released exactly once, after bgfx has + // consumed the final upload. + const uint8_t numMips{static_cast(image->m_numMips)}; + for (uint8_t side = 0; side < 6; ++side) + { + for (uint8_t mip = 0; mip < numMips; ++mip) + { + bimg::ImageMip imageMip{}; + if (bimg::imageGetRawData(*image, side, mip, image->m_data, image->m_size, imageMip)) + { + bgfx::ReleaseFn releaseFn{}; + if (side == 5 && mip == numMips - 1) + { + releaseFn = [](void*, void* userData) { + bimg::imageFree(static_cast(userData)); + }; + } + + const bgfx::Memory* mem{bgfx::makeRef(imageMip.m_data, imageMip.m_size, releaseFn, image)}; + texture->UpdateCube(0, side, mip, 0, 0, static_cast(imageMip.m_width), static_cast(imageMip.m_height), mem); + } + } + } + } #endif // BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES auto RenderTargetSamplesToBgfxMsaaFlag(uint32_t renderTargetSamples) @@ -714,6 +950,7 @@ namespace Babylon InstanceMethod("initializeTexture", &NativeEngine::InitializeTexture), InstanceMethod("loadTexture", &NativeEngine::LoadTexture), InstanceMethod("loadRawTexture", &NativeEngine::LoadRawTexture), + InstanceMethod("updateTextureData", &NativeEngine::UpdateTextureData), InstanceMethod("loadRawTexture2DArray", &NativeEngine::LoadRawTexture2DArray), InstanceMethod("loadCubeTexture", &NativeEngine::LoadCubeTexture), InstanceMethod("loadCubeTextureWithMips", &NativeEngine::LoadCubeTextureWithMips), @@ -1348,6 +1585,7 @@ namespace Babylon const bool renderTarget = info[5].As(); const bool srgb = info[6].As(); const uint32_t samples = info[7].IsUndefined() ? 1 : info[7].As().Uint32Value(); + const bool isCube = info.Length() > 8 && !info[8].IsUndefined() && info[8].As(); auto flags = BGFX_TEXTURE_NONE; if (renderTarget) @@ -1359,7 +1597,15 @@ namespace Babylon flags |= BGFX_TEXTURE_SRGB; } - texture->Create2D(width, height, hasMips, 1, format, flags); + if (isCube) + { + // Cube render target: width is the per-face size. + texture->CreateCube(width, hasMips, 1, format, flags); + } + else + { + texture->Create2D(width, height, hasMips, 1, format, flags); + } } void NativeEngine::LoadTexture(const Napi::CallbackInfo& info) @@ -1441,6 +1687,77 @@ namespace Babylon #endif } + void NativeEngine::UpdateTextureData(const Napi::CallbackInfo& info) + { + const auto texture{info[0].As>().Get()}; + const auto data{info[1].As()}; + const auto x{static_cast(info[2].As().Uint32Value())}; + const auto y{static_cast(info[3].As().Uint32Value())}; + const auto width{static_cast(info[4].As().Uint32Value())}; + const auto height{static_cast(info[5].As().Uint32Value())}; + const uint16_t layer{info.Length() > 6 && !info[6].IsUndefined() ? static_cast(info[6].As().Uint32Value()) : static_cast(0)}; + const uint8_t mip{info.Length() > 7 && !info[7].IsUndefined() ? static_cast(info[7].As().Uint32Value()) : static_cast(0)}; + const bool invertY{info.Length() > 8 && !info[8].IsUndefined() ? info[8].As().Value() : false}; + + if (texture == nullptr || !texture->IsValid()) + { + throw Napi::Error::New(info.Env(), "updateTextureData called on an invalid texture"); + } + + // Validate the (JS-controlled) update rectangle against the mip-level extents before handing it to + // bgfx, so an out-of-range origin/size can't drive an out-of-bounds read of the source buffer below. + uint32_t mipWidth{static_cast(texture->Width()) >> mip}; + uint32_t mipHeight{static_cast(texture->Height()) >> mip}; + if (mipWidth == 0) + { + mipWidth = 1; + } + if (mipHeight == 0) + { + mipHeight = 1; + } + const uint16_t numLayers{texture->NumLayers() > 0 ? texture->NumLayers() : static_cast(1)}; + if (width == 0 || height == 0 || + static_cast(x) + width > mipWidth || + static_cast(y) + height > mipHeight || + layer >= numLayers) + { + throw Napi::Error::New(info.Env(), "updateTextureData region is out of bounds"); + } + + // Size of the source rectangle in the texture's own format. bgfx is always linked (bimg is not, in + // builds without image loading), so size the upload with bgfx::calcTextureSize rather than bimg. + bgfx::TextureInfo textureInfo; + bgfx::calcTextureSize(textureInfo, width, height, 1, false, false, 1, texture->Format()); + const uint32_t requiredSize{textureInfo.storageSize}; + if (requiredSize == 0 || data.ByteLength() < requiredSize) + { + throw Napi::Error::New(info.Env(), "updateTextureData data size does not match width, height, and texture format"); + } + + const auto bytes{static_cast(data.ArrayBuffer().Data()) + data.ByteOffset()}; + + // Match the vertical orientation the base upload applies (PrepareImage flips the whole image when + // originBottomLeft ? invertY : !invertY). To land a sub-rectangle at the same place, flip it to the + // mirrored Y origin and reverse its rows so row 0 of the source lines up with the flipped base data. + const bool flip{bgfx::getCaps()->originBottomLeft ? invertY : !invertY}; + const uint16_t targetY{flip ? static_cast(mipHeight - y - height) : y}; + const bgfx::Memory* mem{bgfx::alloc(requiredSize)}; + if (flip) + { + const uint32_t rowBytes{requiredSize / height}; + for (uint16_t row = 0; row < height; ++row) + { + std::memcpy(mem->data + static_cast(row) * rowBytes, bytes + static_cast(height - 1 - row) * rowBytes, rowBytes); + } + } + else + { + std::memcpy(mem->data, bytes, requiredSize); + } + texture->Update2D(layer, mip, x, targetY, width, height, mem); + } + void NativeEngine::LoadRawTexture2DArray(const Napi::CallbackInfo& info) { #ifndef BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES @@ -1533,6 +1850,44 @@ namespace Babylon const auto onSuccess{info[5].As()}; const auto onError{info[6].As()}; + // A single buffer means a self-contained cubemap container (.dds / .ktx / + // .ktx2) that already holds all six faces and their mip chain; hand it to + // bimg directly instead of expecting six pre-split face images. + if (data.Length() == 1) + { + const auto typedArray{data[0u].As()}; + const auto dataSpan{gsl::make_span(static_cast(typedArray.ArrayBuffer().Data()) + typedArray.ByteOffset(), typedArray.ByteLength())}; + auto dataRef{Napi::Persistent(typedArray)}; + arcana::make_task(arcana::threadpool_scheduler, *m_cancellationSource, [dataSpan]() { + return ParseCubeImage(Graphics::DeviceContext::GetDefaultAllocator(), dataSpan); + }) + .then(arcana::inline_scheduler, *m_cancellationSource, [texture, srgb, cancellationSource{m_cancellationSource}](bimg::ImageContainer* image) { + // Compute the spherical harmonics from the decoded top mip before the upload + // hands the container's memory to bgfx. + auto sphericalPolynomial = ComputeCubeSphericalPolynomial(Graphics::DeviceContext::GetDefaultAllocator(), image); + LoadCubeTextureFromContainer(texture, image, srgb); + return sphericalPolynomial; + }) + .then(m_runtimeScheduler, *m_cancellationSource, [dataRef{std::move(dataRef)}, onSuccessRef{Napi::Persistent(onSuccess)}, onErrorRef{Napi::Persistent(onError)}, cancellationSource{m_cancellationSource}](arcana::expected, std::exception_ptr> result) { + if (result.has_error()) + { + onErrorRef.Call({}); + } + else + { + const auto& sphericalPolynomial{result.value()}; + auto array{Napi::Float32Array::New(onSuccessRef.Env(), sphericalPolynomial.size())}; + float* dst{array.Data()}; + for (size_t i = 0; i < sphericalPolynomial.size(); ++i) + { + dst[i] = sphericalPolynomial[i]; + } + onSuccessRef.Call({array}); + } + }); + return; + } + std::array, 6> dataRefs; std::array, 6> tasks; for (uint32_t face = 0; face < data.Length(); face++) @@ -1852,6 +2207,8 @@ namespace Babylon const bool generateStencilBuffer = info[3].As(); const bool generateDepth = info[4].As(); const uint32_t samples = info[5].IsUndefined() ? 1 : info[5].As().Uint32Value(); + // Optional cube-face / array layer for the color attachment (single-face cube render targets). + const uint16_t layer = (info.Length() > 6 && !info[6].IsUndefined()) ? static_cast(info[6].As().Uint32Value()) : 0; std::array attachments{}; uint8_t numAttachments = 0; @@ -1862,7 +2219,7 @@ namespace Babylon // 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 + attachments[numAttachments++].init(texture->Handle(), bgfx::Access::Write, layer, 1, 0 , 0 != (caps->formats[texture->Format()] & BGFX_CAPS_FORMAT_TEXTURE_MIP_AUTOGEN) ? BGFX_RESOLVE_AUTO_GEN_MIPS : BGFX_RESOLVE_NONE ); } diff --git a/Plugins/NativeEngine/Source/NativeEngine.h b/Plugins/NativeEngine/Source/NativeEngine.h index 229ef0e3a..3fad975fa 100644 --- a/Plugins/NativeEngine/Source/NativeEngine.h +++ b/Plugins/NativeEngine/Source/NativeEngine.h @@ -101,6 +101,7 @@ namespace Babylon void LoadTexture(const Napi::CallbackInfo& info); void CopyTexture(NativeDataStream::Reader& data); void LoadRawTexture(const Napi::CallbackInfo& info); + void UpdateTextureData(const Napi::CallbackInfo& info); void LoadRawTexture2DArray(const Napi::CallbackInfo& info); void LoadCubeTexture(const Napi::CallbackInfo& info); void LoadCubeTextureWithMips(const Napi::CallbackInfo& info);