Skip to content

Commit e1cc94a

Browse files
bkaradzicCopilot
authored andcommitted
NativeEngine: load single-file .dds/.ktx/.ktx2 cubemaps + spherical harmonics
loadCubeTexture now accepts a single self-contained cubemap container (all six faces + mips), decoded via bimg::imageParse, and uploads sides 0-5 x mips. ComputeCubeSphericalPolynomial derives the diffuse-IBL spherical harmonics from the top-mip faces (port of CubeMapToSphericalPolynomialTools) and returns the polynomial coefficients to JS. This is done natively because the WebGL upload and cube-readback paths are unimplemented on native and .dds stores no SH. Re-enables 6 prefiltered-environment PBR validation tests. Pairs with BabylonJS/Babylon.js#18560 (native createCubeTexture dispatch for single-URL containers). Depends on a babylonjs dependency bump including it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 158e2ef commit e1cc94a

2 files changed

Lines changed: 274 additions & 12 deletions

File tree

Apps/Playground/Scripts/config.json

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -858,8 +858,6 @@
858858
{
859859
"title": "NMEGLTF",
860860
"playgroundId": "#WGZLGJ#10320",
861-
"excludeFromAutomaticTesting": true,
862-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
863861
"referenceImage": "nmegltf.png"
864862
},
865863
{
@@ -1056,15 +1054,11 @@
10561054
{
10571055
"title": "Anisotropic",
10581056
"playgroundId": "#MAXCNU#1",
1059-
"excludeFromAutomaticTesting": true,
1060-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
10611057
"referenceImage": "anisotropic.png"
10621058
},
10631059
{
10641060
"title": "Clear Coat",
10651061
"playgroundId": "#YACNQS#2",
1066-
"excludeFromAutomaticTesting": true,
1067-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
10681062
"referenceImage": "clearCoat.png"
10691063
},
10701064
{
@@ -1571,22 +1565,16 @@
15711565
{
15721566
"title": "PBRMetallicRoughnessMaterial",
15731567
"playgroundId": "#2FDQT5#13",
1574-
"excludeFromAutomaticTesting": true,
1575-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
15761568
"referenceImage": "PBRMetallicRoughnessMaterial.png"
15771569
},
15781570
{
15791571
"title": "PBRSpecularGlossinessMaterial",
15801572
"playgroundId": "#Z1VL3V#4",
1581-
"excludeFromAutomaticTesting": true,
1582-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
15831573
"referenceImage": "PBRSpecularGlossinessMaterial.png"
15841574
},
15851575
{
15861576
"title": "PBR",
15871577
"playgroundId": "#LCA0Q4#27",
1588-
"excludeFromAutomaticTesting": true,
1589-
"reason": "Pixel comparison fails (more than 20% pixels differ)",
15901578
"referenceImage": "pbr.png"
15911579
},
15921580
{

Plugins/NativeEngine/Source/NativeEngine.cpp

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
#include <cmath>
3232
#include <limits>
3333
#include <optional>
34+
#include <array>
3435

3536
#ifdef BABYLON_NATIVE_NATIVEENGINE_TEST_HOOKS
3637
#include <atomic>
@@ -417,6 +418,241 @@ namespace Babylon
417418
}
418419
}
419420
}
421+
// Parse a single self-contained cubemap container (e.g. .dds / .ktx /
422+
// .ktx2) that already holds all six faces and their mip chain. bimg
423+
// decodes these natively, so there is no need to split into six images
424+
// on the JS side. Unlike ParseImage (which targets single-face 2D images
425+
// and asserts !m_cubeMap), this keeps the container as-is.
426+
bimg::ImageContainer* ParseCubeImage(bx::AllocatorI& allocator, gsl::span<uint8_t> data)
427+
{
428+
bx::ErrorIgnore parseError;
429+
bimg::ImageContainer* image{bimg::imageParse(&allocator, data.data(), static_cast<uint32_t>(data.size()), bimg::TextureFormat::Count, &parseError)};
430+
if (image == nullptr)
431+
{
432+
throw std::runtime_error{"Failed to parse cube image."};
433+
}
434+
435+
if (!image->m_cubeMap)
436+
{
437+
bimg::imageFree(image);
438+
throw std::runtime_error{"Image is not a cubemap."};
439+
}
440+
441+
return image;
442+
}
443+
444+
// Port of Babylon.js CubeMapToSphericalPolynomialTools.ConvertCubeMapToSphericalPolynomial.
445+
// Prefiltered .dds environments need diffuse-IBL spherical harmonics, which Babylon's WebGL
446+
// path computes on the CPU from the top-mip faces. The native engine cannot read cube faces
447+
// back from the GPU (_readTexturePixels throws for cube faces), so we compute the harmonics
448+
// here from the bimg-decoded top mip. Returns the 9x3 polynomial coefficients in
449+
// SphericalPolynomial.FromArray order: x, y, z, xx, yy, zz, yz, zx, xy.
450+
std::array<float, 27> ComputeCubeSphericalPolynomial(bx::AllocatorI& allocator, bimg::ImageContainer* image)
451+
{
452+
std::array<float, 27> result{};
453+
454+
bimg::ImageContainer* f32{bimg::imageConvert(&allocator, bimg::TextureFormat::RGBA32F, *image, false)};
455+
if (f32 == nullptr)
456+
{
457+
return result;
458+
}
459+
460+
const uint32_t size{f32->m_width};
461+
constexpr double pi{3.14159265358979323846};
462+
463+
// Face orientations matching Babylon's _FileFaces, indexed by bimg cube side order
464+
// (+X, -X, +Y, -Y, +Z, -Z): worldAxisForNormal, worldAxisForFileX, worldAxisForFileY.
465+
struct FaceAxes
466+
{
467+
double n[3];
468+
double fx[3];
469+
double fy[3];
470+
};
471+
static const FaceAxes faces[6] = {
472+
{{1, 0, 0}, {0, 0, -1}, {0, -1, 0}}, // +X right
473+
{{-1, 0, 0}, {0, 0, 1}, {0, -1, 0}}, // -X left
474+
{{0, 1, 0}, {1, 0, 0}, {0, 0, 1}}, // +Y up
475+
{{0, -1, 0}, {1, 0, 0}, {0, 0, -1}}, // -Y down
476+
{{0, 0, 1}, {1, 0, 0}, {0, -1, 0}}, // +Z front
477+
{{0, 0, -1}, {-1, 0, 0}, {0, -1, 0}}, // -Z back
478+
};
479+
480+
const double shConst[9] = {
481+
std::sqrt(1.0 / (4.0 * pi)),
482+
-std::sqrt(3.0 / (4.0 * pi)),
483+
std::sqrt(3.0 / (4.0 * pi)),
484+
-std::sqrt(3.0 / (4.0 * pi)),
485+
std::sqrt(15.0 / (4.0 * pi)),
486+
-std::sqrt(15.0 / (4.0 * pi)),
487+
std::sqrt(5.0 / (16.0 * pi)),
488+
-std::sqrt(15.0 / (4.0 * pi)),
489+
std::sqrt(15.0 / (16.0 * pi)),
490+
};
491+
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};
492+
493+
const auto areaElement = [](double x, double y) { return std::atan2(x * y, std::sqrt(x * x + y * y + 1.0)); };
494+
495+
double sh[9][3] = {};
496+
double totalSolidAngle{0.0};
497+
498+
const double du{2.0 / static_cast<double>(size)};
499+
const double halfTexel{0.5 * du};
500+
const double minUV{halfTexel - 1.0};
501+
const double maxHdri{4096.0};
502+
503+
for (uint16_t side = 0; side < 6; ++side)
504+
{
505+
bimg::ImageMip mip{};
506+
if (!bimg::imageGetRawData(*f32, side, 0, f32->m_data, f32->m_size, mip))
507+
{
508+
continue;
509+
}
510+
511+
const float* data{reinterpret_cast<const float*>(mip.m_data)};
512+
const FaceAxes& f{faces[side]};
513+
514+
double v{minUV};
515+
for (uint32_t y = 0; y < size; ++y)
516+
{
517+
double u{minUV};
518+
for (uint32_t x = 0; x < size; ++x)
519+
{
520+
double dir[3] = {
521+
f.fx[0] * u + f.fy[0] * v + f.n[0],
522+
f.fx[1] * u + f.fy[1] * v + f.n[1],
523+
f.fx[2] * u + f.fy[2] * v + f.n[2],
524+
};
525+
const double len{std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2])};
526+
dir[0] /= len;
527+
dir[1] /= len;
528+
dir[2] /= len;
529+
530+
const double deltaSolidAngle{
531+
areaElement(u - halfTexel, v - halfTexel) -
532+
areaElement(u - halfTexel, v + halfTexel) -
533+
areaElement(u + halfTexel, v - halfTexel) +
534+
areaElement(u + halfTexel, v + halfTexel)};
535+
536+
const size_t idx{(static_cast<size_t>(y) * size + x) * 4};
537+
double rgb[3] = {data[idx + 0], data[idx + 1], data[idx + 2]};
538+
for (int c = 0; c < 3; ++c)
539+
{
540+
if (std::isnan(rgb[c]))
541+
{
542+
rgb[c] = 0.0;
543+
}
544+
rgb[c] = rgb[c] < 0.0 ? 0.0 : (rgb[c] > maxHdri ? maxHdri : rgb[c]);
545+
}
546+
547+
const double trig[9] = {
548+
1.0,
549+
dir[1],
550+
dir[2],
551+
dir[0],
552+
dir[0] * dir[1],
553+
dir[1] * dir[2],
554+
3.0 * dir[2] * dir[2] - 1.0,
555+
dir[0] * dir[2],
556+
dir[0] * dir[0] - dir[1] * dir[1],
557+
};
558+
for (int lm = 0; lm < 9; ++lm)
559+
{
560+
const double basis{shConst[lm] * trig[lm] * deltaSolidAngle};
561+
sh[lm][0] += rgb[0] * basis;
562+
sh[lm][1] += rgb[1] * basis;
563+
sh[lm][2] += rgb[2] * basis;
564+
}
565+
totalSolidAngle += deltaSolidAngle;
566+
u += du;
567+
}
568+
v += du;
569+
}
570+
}
571+
572+
bimg::imageFree(f32);
573+
574+
if (totalSolidAngle <= 0.0)
575+
{
576+
return result;
577+
}
578+
579+
// scaleInPlace(correction) + convertIncidentRadianceToIrradiance + convertIrradianceToLambertianRadiance.
580+
const double correction{(4.0 * pi) / totalSolidAngle};
581+
for (int lm = 0; lm < 9; ++lm)
582+
{
583+
const double scale{correction * cosKernel[lm] / pi};
584+
sh[lm][0] *= scale;
585+
sh[lm][1] *= scale;
586+
sh[lm][2] *= scale;
587+
}
588+
589+
// SphericalPolynomial.FromHarmonics (updateFromHarmonics then *1/pi).
590+
for (int c = 0; c < 3; ++c)
591+
{
592+
const double l00{sh[0][c]}, l1_1{sh[1][c]}, l10{sh[2][c]}, l11{sh[3][c]};
593+
const double l2_2{sh[4][c]}, l2_1{sh[5][c]}, l20{sh[6][c]}, l21{sh[7][c]}, l22{sh[8][c]};
594+
const double invPi{1.0 / pi};
595+
result[0 * 3 + c] = static_cast<float>(-1.02333 * l11 * invPi); // x
596+
result[1 * 3 + c] = static_cast<float>(-1.02333 * l1_1 * invPi); // y
597+
result[2 * 3 + c] = static_cast<float>(1.02333 * l10 * invPi); // z
598+
result[3 * 3 + c] = static_cast<float>((0.886277 * l00 - 0.247708 * l20 + 0.429043 * l22) * invPi); // xx
599+
result[4 * 3 + c] = static_cast<float>((0.886277 * l00 - 0.247708 * l20 - 0.429043 * l22) * invPi); // yy
600+
result[5 * 3 + c] = static_cast<float>((0.886277 * l00 + 0.495417 * l20) * invPi); // zz
601+
result[6 * 3 + c] = static_cast<float>(-0.858086 * l2_1 * invPi); // yz
602+
result[7 * 3 + c] = static_cast<float>(-0.858086 * l21 * invPi); // zx
603+
result[8 * 3 + c] = static_cast<float>(0.858086 * l2_2 * invPi); // xy
604+
}
605+
606+
return result;
607+
}
608+
609+
void LoadCubeTextureFromContainer(Graphics::Texture* texture, bimg::ImageContainer* image, bool srgb)
610+
{
611+
assert(image->m_cubeMap);
612+
assert(image->m_width == image->m_height);
613+
const uint32_t size{image->m_width};
614+
615+
if (texture->IsValid())
616+
{
617+
if (texture->Width() != size || texture->Height() != size)
618+
{
619+
bimg::imageFree(image);
620+
throw std::runtime_error{"Cannot update texture from image of different size"};
621+
}
622+
}
623+
else
624+
{
625+
const bool hasMips{image->m_numMips > 1};
626+
const bgfx::TextureFormat::Enum format{Cast(image->m_format)};
627+
const uint64_t flags{srgb ? BGFX_TEXTURE_SRGB : BGFX_TEXTURE_NONE};
628+
texture->CreateCube(static_cast<uint16_t>(size), hasMips, 1, format, flags);
629+
}
630+
631+
// Every (side, mip) view points into the single container's backing
632+
// store, so the allocation is released exactly once, after bgfx has
633+
// consumed the final upload.
634+
const uint8_t numMips{static_cast<uint8_t>(image->m_numMips)};
635+
for (uint8_t side = 0; side < 6; ++side)
636+
{
637+
for (uint8_t mip = 0; mip < numMips; ++mip)
638+
{
639+
bimg::ImageMip imageMip{};
640+
if (bimg::imageGetRawData(*image, side, mip, image->m_data, image->m_size, imageMip))
641+
{
642+
bgfx::ReleaseFn releaseFn{};
643+
if (side == 5 && mip == numMips - 1)
644+
{
645+
releaseFn = [](void*, void* userData) {
646+
bimg::imageFree(static_cast<bimg::ImageContainer*>(userData));
647+
};
648+
}
649+
650+
const bgfx::Memory* mem{bgfx::makeRef(imageMip.m_data, imageMip.m_size, releaseFn, image)};
651+
texture->UpdateCube(0, side, mip, 0, 0, static_cast<uint16_t>(imageMip.m_width), static_cast<uint16_t>(imageMip.m_height), mem);
652+
}
653+
}
654+
}
655+
}
420656
#endif // BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES
421657

422658
auto RenderTargetSamplesToBgfxMsaaFlag(uint32_t renderTargetSamples)
@@ -1533,6 +1769,44 @@ namespace Babylon
15331769
const auto onSuccess{info[5].As<Napi::Function>()};
15341770
const auto onError{info[6].As<Napi::Function>()};
15351771

1772+
// A single buffer means a self-contained cubemap container (.dds / .ktx /
1773+
// .ktx2) that already holds all six faces and their mip chain; hand it to
1774+
// bimg directly instead of expecting six pre-split face images.
1775+
if (data.Length() == 1)
1776+
{
1777+
const auto typedArray{data[0u].As<Napi::TypedArray>()};
1778+
const auto dataSpan{gsl::make_span(static_cast<uint8_t*>(typedArray.ArrayBuffer().Data()) + typedArray.ByteOffset(), typedArray.ByteLength())};
1779+
auto dataRef{Napi::Persistent(typedArray)};
1780+
arcana::make_task(arcana::threadpool_scheduler, *m_cancellationSource, [dataSpan]() {
1781+
return ParseCubeImage(Graphics::DeviceContext::GetDefaultAllocator(), dataSpan);
1782+
})
1783+
.then(arcana::inline_scheduler, *m_cancellationSource, [texture, srgb, cancellationSource{m_cancellationSource}](bimg::ImageContainer* image) {
1784+
// Compute the spherical harmonics from the decoded top mip before the upload
1785+
// hands the container's memory to bgfx.
1786+
auto sphericalPolynomial = ComputeCubeSphericalPolynomial(Graphics::DeviceContext::GetDefaultAllocator(), image);
1787+
LoadCubeTextureFromContainer(texture, image, srgb);
1788+
return sphericalPolynomial;
1789+
})
1790+
.then(m_runtimeScheduler, *m_cancellationSource, [dataRef{std::move(dataRef)}, onSuccessRef{Napi::Persistent(onSuccess)}, onErrorRef{Napi::Persistent(onError)}, cancellationSource{m_cancellationSource}](arcana::expected<std::array<float, 27>, std::exception_ptr> result) {
1791+
if (result.has_error())
1792+
{
1793+
onErrorRef.Call({});
1794+
}
1795+
else
1796+
{
1797+
const auto& sphericalPolynomial{result.value()};
1798+
auto array{Napi::Float32Array::New(onSuccessRef.Env(), sphericalPolynomial.size())};
1799+
float* dst{array.Data()};
1800+
for (size_t i = 0; i < sphericalPolynomial.size(); ++i)
1801+
{
1802+
dst[i] = sphericalPolynomial[i];
1803+
}
1804+
onSuccessRef.Call({array});
1805+
}
1806+
});
1807+
return;
1808+
}
1809+
15361810
std::array<Napi::Reference<Napi::TypedArray>, 6> dataRefs;
15371811
std::array<arcana::task<bimg::ImageContainer*, std::exception_ptr>, 6> tasks;
15381812
for (uint32_t face = 0; face < data.Length(); face++)

0 commit comments

Comments
 (0)