diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 1a04d5ee3..4e55fd855 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -47,4 +47,6 @@ jobs: -PjsEngine=${{ inputs.js-engine }} \ -PARM64Only \ -PNDK_VERSION=${{ env.NDK_VERSION }} \ + -PBABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -PBABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -PSANITIZERS=OFF diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 49d22f804..e48cbb110 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -37,6 +37,8 @@ jobs: -D IOS=ON \ -D DEPLOYMENT_TARGET=${{ inputs.deployment-target }} \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -D CMAKE_IOS_INSTALL_COMBINED=NO - name: Build Playground iOS diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index aa60abf74..d6b288f7f 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -49,6 +49,8 @@ jobs: -D BX_CONFIG_DEBUG=ON \ -D OpenGL_GL_PREFERENCE=GLVND \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ ${{ inputs.js-engine == 'Hermes' && '-D HERMES_UNICODE_LITE=ON -D HERMES_ALLOW_BOOST_CONTEXT=0' || '' }} \ -D ENABLE_SANITIZERS=${{ inputs.enable-sanitizers && 'ON' || 'OFF' }} . ninja -C build/Linux diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 8437034c5..8f735220f 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -47,6 +47,8 @@ jobs: cmake -G "${{ inputs.generator }}" -B build/macOS \ ${{ inputs.js-engine != '' && format('-D NAPI_JAVASCRIPT_ENGINE={0}', inputs.js-engine) || '' }} \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -D ENABLE_SANITIZERS=${{ inputs.enable-sanitizers && 'ON' || 'OFF' }} \ -D BABYLON_NATIVE_TESTS_USE_NOOP_METAL_DEVICE=ON diff --git a/.github/workflows/build-uwp.yml b/.github/workflows/build-uwp.yml index d0699cc37..b307f9e46 100644 --- a/.github/workflows/build-uwp.yml +++ b/.github/workflows/build-uwp.yml @@ -45,6 +45,8 @@ jobs: -D CMAKE_SYSTEM_VERSION=10.0 ^ ${{ steps.napi.outputs.define }} ^ -A ${{ inputs.platform }} ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON ^ -D BABYLON_DEBUG_TRACE=ON - name: Build UWP diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index b73bd85bb..64198185b 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -67,6 +67,8 @@ jobs: -D GRAPHICS_API=${{ inputs.graphics-api }} ^ -D BGFX_CONFIG_MAX_FRAME_BUFFERS=256 ^ -D BABYLON_DEBUG_TRACE=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON ^ -D ENABLE_SANITIZERS=${{ steps.vars.outputs.sanitizer_flag }} - name: Build diff --git a/Apps/Playground/Android/BabylonNative/build.gradle b/Apps/Playground/Android/BabylonNative/build.gradle index 3f075645d..439f309b8 100644 --- a/Apps/Playground/Android/BabylonNative/build.gradle +++ b/Apps/Playground/Android/BabylonNative/build.gradle @@ -37,6 +37,15 @@ if (project.hasProperty("importHostCompilers")) { cmakeArguments.add("-DIMPORT_HOST_COMPILERS=${project.property('importHostCompilers')}") } +// The experimental codec plugins default to OFF in CMake. Allow the caller (CI) to +// turn them on so the NDK toolchain gets build coverage for their third-party +// dependencies, without changing the default for local Android builds. +["BABYLON_NATIVE_PLUGIN_NATIVEDRACO", "BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT"].each { option -> + if (project.hasProperty(option)) { + cmakeArguments.add("-D${option}=${project.property(option)}") + } +} + configurations { natives } android { diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 4ffe04381..5993f2c4e 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -85,6 +85,18 @@ target_link_libraries(UnitTests target_compile_definitions(UnitTests PRIVATE ${ADDITIONAL_COMPILE_DEFINITIONS}) +# NativeDraco and NativeMeshopt default to OFF, so link and exercise them only when the +# consuming build opted in. CI turns both on for the jobs that run UnitTests. +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + target_link_libraries(UnitTests PRIVATE NativeDraco) + target_compile_definitions(UnitTests PRIVATE HAS_NATIVE_DRACO) +endif() + +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + target_link_libraries(UnitTests PRIVATE NativeMeshopt) + target_compile_definitions(UnitTests PRIVATE HAS_NATIVE_MESHOPT) +endif() + if(GRAPHICS_API STREQUAL "D3D12") target_compile_definitions(UnitTests PRIVATE SKIP_RENDER_TESTS) endif() diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 53a4d4c17..a54b08775 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28247,7 +28247,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__); - +function _createForOfIteratorHelper(r, e) {var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];if (!t) {if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) {t && (r = t);var _n = 0,F = function F() {};return { s: F, n: function n() {return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] };}, e: function e(r) {throw r;}, f: F };}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");}var o,a = !0,u = !1;return { s: function s() {t = t.call(r);}, n: function n() {var r = t.next();return a = r.done, r;}, e: function e(r) {u = !0, o = r;}, f: function f() {try {a || null == t.return || t.return();} finally {if (u) throw o;}} };}function _unsupportedIterableToArray(r, a) {if (r) {if ("string" == typeof r) return _arrayLikeToArray(r, a);var t = {}.toString.call(r).slice(8, -1);return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;}}function _arrayLikeToArray(r, a) {(null == a || a > r.length) && (a = r.length);for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];return n;} @@ -28559,6 +28559,223 @@ describe("NativeEncoding", function () { ); }); +function hexToBytes(hex) { + var out = new Uint8Array(hex.length / 2); + for (var i = 0; i < out.length; ++i) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// Both plugins default to OFF, so report them as skipped rather than silently passing +// when the build did not opt in. CI enables both for the jobs that run UnitTests. +(typeof _native.DracoCodec !== "undefined" ? describe : describe.skip)("NativeDraco", function () { + this.timeout(0); + + // Two triangles sharing an edge. Values are exact halves so they survive the float32 + // round trip bit-for-bit once quantization is disabled. + var positions = new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + 1, 1, 0] + ); + var indices = new Uint16Array([0, 1, 2, 1, 3, 2]); + + // Encoded by the reference draco3dgltf 1.5.7 encoder (the same package Babylon.js takes + // its decoder from), standard edgebreaker, 14-bit position quantization. Using a fixture + // from the reference encoder rather than our own output pins this decoder to the upstream + // bitstream instead of to itself. + var ENCODED = hexToBytes( + "445241434f02020101000000040200020000011fff011101ff00000100090300000201010100030301300110030024824a0400000000ff3f00000000000000000000000000000000803f0e"); + var POSITION_ATTRIBUTE_ID = 0; + + // A 63-vertex UV sphere carrying POSITION, NORMAL and TEX_COORD, encoded by the same + // reference encoder with per-attribute quantization. The single-triangle-pair fixture + // above cannot exercise multi-attribute decoding or a non-degenerate edgebreaker + // traversal, which are what the glTF-bitstream-only build actually restricts. + // + // The expected counts below are what the reference draco3dgltf decoder reports for this + // exact buffer, not the pre-encode mesh: Draco merges points whose attributes all match + // and drops the degenerate triangles at the poles, so 63 vertices / 96 triangles going + // in becomes 62 vertices / 91 triangles coming out. + var SPHERE = hexToBytes( + "445241434f020201010000003a5b025b05001a5fd73e55ad3e55d5aa3e5555adaa3ea55455559faaaaaa565501ff0111" + + "ff02694af8058097a3755f03ff0000000000010100010009030000020101090300010301030902000202010101000f2b" + + "a106b907592e51030c141534f4dfc29a78ddaf7f80bed2ffff2bad28fedf4fcb03010030a7577c44104633030047e86d" + + "00c02304008f08128a7a003c181d05009108c20010d48301102848f888fa0fdc030090d02b010050db9591d895901000" + + "749b07f388bca224060084f91f49f408cd0c0088ee9110006466480800ba0d840110a80703405010e61175c5500b00ba" + + "12a805005dc92b23d12b9dc1b6fbb400e0113dc2480c8007eb8a9208c2b43d000009bd1200f8b4a016007425510b00bc" + + "525702009fd62b010050eb950040a8ed4a0040d48a5a00d095422d00f04aa0160078a54f0b00ba92570200a2b62b0180" + + "50eb0cb89da8ed3600e0110200490409030580074762004098a00000e1aef8881e8cb7010090b0ed018047f8081f1100" + + "80b0001075ea0e00cc0c00000000ff3f0000000080bf000080bf000080bf000000400e000301000b036904135103f109" + + "a106b9270e3ecb87a50ecead84ceeaa8bdde65000002dc7542d24fdbb29af35e882de5dab6c48d3dd1bbeceebaec45c6" + + "73b0bb6b772244eb067057e873dc4e3d72c57b21b6539244f9b4fe44ef620ee3d4cf9108b00b40340076034034000000" + + "44130dc01c19673cc72e4413bd8b398c673cc72e00d10010057042d294b60000fc0fd127b6b63cbd9acf91f1ffff9492" + + "621f37ff030000ff0100000a010101000d039532550a1b0901090109010a0eb9fbab2e93abef3f8700fcff00aceaff00" + + "60a0001a8220220800400800820822089a0000000000ff0f000000000000000000000000803f0c"); + var SPHERE_VERTICES = 62; + var SPHERE_INDICES = 273; + + it("publishes the codec version it was built against", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.be.a("string"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); + }); + + it("decodes a mesh produced by the reference glTF encoder", function () { + var decoded = _native.DracoCodec.Decode(ENCODED, { position: POSITION_ATTRIBUTE_ID }); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(positions.length / 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices.length).to.equal(indices.length); + + // Draco reorders points, so compare the triangles as sets of resolved corner + // positions rather than assuming the original vertex order survived. Rounded to + // two decimals so the comparison tolerates quantization but still separates + // coordinates that are a whole unit apart. + var attribute = decoded.attributes.find(function (a) {return a.kind === "position";}); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(attribute, "decoded position attribute").to.not.equal(undefined); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(attribute.size).to.equal(3); + + var corner = function corner(buffer, i) {return ( + [buffer[i * 3], buffer[i * 3 + 1], buffer[i * 3 + 2]]. + map(function (v) {return v.toFixed(2);}). + join(","));}; + + var expectedCorners = []; + var actualCorners = []; + for (var i = 0; i < indices.length; ++i) { + expectedCorners.push(corner(positions, indices[i])); + actualCorners.push(corner(attribute.data, decoded.indices[i])); + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); + }); + + it("decodes without an explicit attribute id map", function () { + var decoded = _native.DracoCodec.Decode(ENCODED); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(positions.length / 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.attributes.find(function (a) {return a.kind === "position";})).to.not.equal(undefined); + }); + + it("decodes a multi-attribute mesh", function () { + var decoded = _native.DracoCodec.Decode(SPHERE, { position: 0, normal: 1, uv: 2 }); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(SPHERE_VERTICES); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices.length).to.equal(SPHERE_INDICES); + + var byKind = {};var _iterator = _createForOfIteratorHelper( + decoded.attributes),_step;try {for (_iterator.s(); !(_step = _iterator.n()).done;) {var a = _step.value; + byKind[a.kind] = a; + }} catch (err) {_iterator.e(err);} finally {_iterator.f();} + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.position.size).to.equal(3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.normal.size).to.equal(3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.uv.size).to.equal(2); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.position.data.length).to.equal(SPHERE_VERTICES * 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.normal.data.length).to.equal(SPHERE_VERTICES * 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.uv.data.length).to.equal(SPHERE_VERTICES * 2); + + // Every index must address a real vertex, and the geometry must actually be the + // unit sphere that was encoded rather than plausible-looking noise. + for (var i = 0; i < decoded.indices.length; ++i) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices[i]).to.be.lessThan(SPHERE_VERTICES); + } + + for (var v = 0; v < SPHERE_VERTICES; ++v) { + var x = byKind.position.data[v * 3]; + var y = byKind.position.data[v * 3 + 1]; + var z = byKind.position.data[v * 3 + 2]; + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Math.sqrt(x * x + y * y + z * z)).to.be.closeTo(1, 0.01); + } + }); + + it("rejects malformed input", function () { + var garbage = new Uint8Array(64); + for (var i = 0; i < garbage.length; ++i) { + garbage[i] = i * 37 & 0xff; + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(garbage);}).to.throw(); + }); + + it("rejects truncated input", function () { + var truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(truncated);}).to.throw(); + }); + + it("rejects an empty buffer", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(new Uint8Array(0));}).to.throw(); + }); + + it("does not expose an encoder", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Encode).to.equal(undefined); + }); +}); + +(typeof _native.MeshoptCodec !== "undefined" ? describe : describe.skip)("NativeMeshopt", function () { + this.timeout(0); + + // Produced by the reference meshoptimizer 0.22 JavaScript encoder + // (MeshoptEncoder.encodeVertexBuffer) over 6 vertices of 16-byte stride, so this + // pins our native decoder against the upstream bitstream rather than against itself. + var ENCODED = hexToBytes( + "a00000013ff000007fffa0606001380000007e0000013ff0000020ff9070480130800000800000013ff0000080ff" + + "a0606001320000007e012aa000000000000000000000000000000000000000000000000000000000800000000000beadde"); + var EXPECTED = hexToBytes( + "00000000000000800000000000beadde0000c03f000010c00000403f01beadde00004040000090c00000c03f02beadde" + + "000090400000d8c00000104003beadde0000c040000010c10000404004beadde0000f040000034c10000704005beadde"); + var COUNT = 6; + var STRIDE = 16; + + it("publishes the codec version it was built against", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.MeshoptCodec.Version).to.be.a("string"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.MeshoptCodec.Version).to.match(/^\d+\.\d+$/); + }); + + it("decodes a reference stream byte for byte", function () { + var decoded = _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "ATTRIBUTES"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.length).to.equal(EXPECTED.length); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(decoded)).to.deep.equal(Array.from(EXPECTED)); + }); + + it("rejects malformed input", function () { + var garbage = new Uint8Array(ENCODED.length); + for (var i = 0; i < garbage.length; ++i) { + garbage[i] = i * 37 & 0xff; + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(garbage, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects truncated input", function () { + var truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(truncated, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects an unknown mode", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "NOT_A_MODE");}).to.throw(); + }); + + it("rejects a stride outside [1, 256]", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES");}).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects a negative count", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects a TRIANGLES count that is not a multiple of 3", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES");}).to.throw(); + }); + + it("rejects a non-typed-array source", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(null, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); +}); + mocha.run(function (failures) { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index e7ebaacbe..40c05d006 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -328,6 +328,223 @@ describe("NativeEncoding", function () { }); }); +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; ++i) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// Both plugins default to OFF, so report them as skipped rather than silently passing +// when the build did not opt in. CI enables both for the jobs that run UnitTests. +(typeof _native.DracoCodec !== "undefined" ? describe : describe.skip)("NativeDraco", function () { + this.timeout(0); + + // Two triangles sharing an edge. Values are exact halves so they survive the float32 + // round trip bit-for-bit once quantization is disabled. + const positions = new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + 1, 1, 0, + ]); + const indices = new Uint16Array([0, 1, 2, 1, 3, 2]); + + // Encoded by the reference draco3dgltf 1.5.7 encoder (the same package Babylon.js takes + // its decoder from), standard edgebreaker, 14-bit position quantization. Using a fixture + // from the reference encoder rather than our own output pins this decoder to the upstream + // bitstream instead of to itself. + const ENCODED = hexToBytes( + "445241434f02020101000000040200020000011fff011101ff00000100090300000201010100030301300110030024824a0400000000ff3f00000000000000000000000000000000803f0e"); + const POSITION_ATTRIBUTE_ID = 0; + + // A 63-vertex UV sphere carrying POSITION, NORMAL and TEX_COORD, encoded by the same + // reference encoder with per-attribute quantization. The single-triangle-pair fixture + // above cannot exercise multi-attribute decoding or a non-degenerate edgebreaker + // traversal, which are what the glTF-bitstream-only build actually restricts. + // + // The expected counts below are what the reference draco3dgltf decoder reports for this + // exact buffer, not the pre-encode mesh: Draco merges points whose attributes all match + // and drops the degenerate triangles at the poles, so 63 vertices / 96 triangles going + // in becomes 62 vertices / 91 triangles coming out. + const SPHERE = hexToBytes( + "445241434f020201010000003a5b025b05001a5fd73e55ad3e55d5aa3e5555adaa3ea55455559faaaaaa565501ff0111" + + "ff02694af8058097a3755f03ff0000000000010100010009030000020101090300010301030902000202010101000f2b" + + "a106b907592e51030c141534f4dfc29a78ddaf7f80bed2ffff2bad28fedf4fcb03010030a7577c44104633030047e86d" + + "00c02304008f08128a7a003c181d05009108c20010d48301102848f888fa0fdc030090d02b010050db9591d895901000" + + "749b07f388bca224060084f91f49f408cd0c0088ee9110006466480800ba0d840110a80703405010e61175c5500b00ba" + + "12a805005dc92b23d12b9dc1b6fbb400e0113dc2480c8007eb8a9208c2b43d000009bd1200f8b4a016007425510b00bc" + + "525702009fd62b010050eb950040a8ed4a0040d48a5a00d095422d00f04aa0160078a54f0b00ba92570200a2b62b0180" + + "50eb0cb89da8ed3600e0110200490409030580074762004098a00000e1aef8881e8cb7010090b0ed018047f8081f1100" + + "80b0001075ea0e00cc0c00000000ff3f0000000080bf000080bf000080bf000000400e000301000b036904135103f109" + + "a106b9270e3ecb87a50ecead84ceeaa8bdde65000002dc7542d24fdbb29af35e882de5dab6c48d3dd1bbeceebaec45c6" + + "73b0bb6b772244eb067057e873dc4e3d72c57b21b6539244f9b4fe44ef620ee3d4cf9108b00b40340076034034000000" + + "44130dc01c19673cc72e4413bd8b398c673cc72e00d10010057042d294b60000fc0fd127b6b63cbd9acf91f1ffff9492" + + "621f37ff030000ff0100000a010101000d039532550a1b0901090109010a0eb9fbab2e93abef3f8700fcff00aceaff00" + + "60a0001a8220220800400800820822089a0000000000ff0f000000000000000000000000803f0c"); + const SPHERE_VERTICES = 62; + const SPHERE_INDICES = 273; + + it("publishes the codec version it was built against", function () { + expect(_native.DracoCodec.Version).to.be.a("string"); + expect(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); + }); + + it("decodes a mesh produced by the reference glTF encoder", function () { + const decoded = _native.DracoCodec.Decode(ENCODED, { position: POSITION_ATTRIBUTE_ID }); + + expect(decoded.totalVertices).to.equal(positions.length / 3); + expect(decoded.indices.length).to.equal(indices.length); + + // Draco reorders points, so compare the triangles as sets of resolved corner + // positions rather than assuming the original vertex order survived. Rounded to + // two decimals so the comparison tolerates quantization but still separates + // coordinates that are a whole unit apart. + const attribute = decoded.attributes.find((a: any) => a.kind === "position"); + expect(attribute, "decoded position attribute").to.not.equal(undefined); + expect(attribute.size).to.equal(3); + + const corner = (buffer: any, i: number) => + [buffer[i * 3], buffer[i * 3 + 1], buffer[i * 3 + 2]] + .map((v: number) => v.toFixed(2)) + .join(","); + + const expectedCorners: string[] = []; + const actualCorners: string[] = []; + for (let i = 0; i < indices.length; ++i) { + expectedCorners.push(corner(positions, indices[i])); + actualCorners.push(corner(attribute.data, decoded.indices[i])); + } + expect(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); + }); + + it("decodes without an explicit attribute id map", function () { + const decoded = _native.DracoCodec.Decode(ENCODED); + expect(decoded.totalVertices).to.equal(positions.length / 3); + expect(decoded.attributes.find((a: any) => a.kind === "position")).to.not.equal(undefined); + }); + + it("decodes a multi-attribute mesh", function () { + const decoded = _native.DracoCodec.Decode(SPHERE, { position: 0, normal: 1, uv: 2 }); + + expect(decoded.totalVertices).to.equal(SPHERE_VERTICES); + expect(decoded.indices.length).to.equal(SPHERE_INDICES); + + const byKind: any = {}; + for (const a of decoded.attributes) { + byKind[a.kind] = a; + } + + expect(byKind.position.size).to.equal(3); + expect(byKind.normal.size).to.equal(3); + expect(byKind.uv.size).to.equal(2); + + expect(byKind.position.data.length).to.equal(SPHERE_VERTICES * 3); + expect(byKind.normal.data.length).to.equal(SPHERE_VERTICES * 3); + expect(byKind.uv.data.length).to.equal(SPHERE_VERTICES * 2); + + // Every index must address a real vertex, and the geometry must actually be the + // unit sphere that was encoded rather than plausible-looking noise. + for (let i = 0; i < decoded.indices.length; ++i) { + expect(decoded.indices[i]).to.be.lessThan(SPHERE_VERTICES); + } + + for (let v = 0; v < SPHERE_VERTICES; ++v) { + const x = byKind.position.data[v * 3]; + const y = byKind.position.data[v * 3 + 1]; + const z = byKind.position.data[v * 3 + 2]; + expect(Math.sqrt(x * x + y * y + z * z)).to.be.closeTo(1, 0.01); + } + }); + + it("rejects malformed input", function () { + const garbage = new Uint8Array(64); + for (let i = 0; i < garbage.length; ++i) { + garbage[i] = (i * 37) & 0xff; + } + expect(() => _native.DracoCodec.Decode(garbage)).to.throw(); + }); + + it("rejects truncated input", function () { + const truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + expect(() => _native.DracoCodec.Decode(truncated)).to.throw(); + }); + + it("rejects an empty buffer", function () { + expect(() => _native.DracoCodec.Decode(new Uint8Array(0))).to.throw(); + }); + + it("does not expose an encoder", function () { + expect(_native.DracoCodec.Encode).to.equal(undefined); + }); +}); + +(typeof _native.MeshoptCodec !== "undefined" ? describe : describe.skip)("NativeMeshopt", function () { + this.timeout(0); + + // Produced by the reference meshoptimizer 0.22 JavaScript encoder + // (MeshoptEncoder.encodeVertexBuffer) over 6 vertices of 16-byte stride, so this + // pins our native decoder against the upstream bitstream rather than against itself. + const ENCODED = hexToBytes( + "a00000013ff000007fffa0606001380000007e0000013ff0000020ff9070480130800000800000013ff0000080ff" + + "a0606001320000007e012aa000000000000000000000000000000000000000000000000000000000800000000000beadde"); + const EXPECTED = hexToBytes( + "00000000000000800000000000beadde0000c03f000010c00000403f01beadde00004040000090c00000c03f02beadde" + + "000090400000d8c00000104003beadde0000c040000010c10000404004beadde0000f040000034c10000704005beadde"); + const COUNT = 6; + const STRIDE = 16; + + it("publishes the codec version it was built against", function () { + expect(_native.MeshoptCodec.Version).to.be.a("string"); + expect(_native.MeshoptCodec.Version).to.match(/^\d+\.\d+$/); + }); + + it("decodes a reference stream byte for byte", function () { + const decoded = _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "ATTRIBUTES"); + expect(decoded.length).to.equal(EXPECTED.length); + expect(Array.from(decoded)).to.deep.equal(Array.from(EXPECTED)); + }); + + it("rejects malformed input", function () { + const garbage = new Uint8Array(ENCODED.length); + for (let i = 0; i < garbage.length; ++i) { + garbage[i] = (i * 37) & 0xff; + } + expect(() => _native.MeshoptCodec.Decode(garbage, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); + + it("rejects truncated input", function () { + const truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + expect(() => _native.MeshoptCodec.Decode(truncated, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); + + it("rejects an unknown mode", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "NOT_A_MODE")).to.throw(); + }); + + it("rejects a stride outside [1, 256]", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES")).to.throw(); + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES")).to.throw(); + }); + + it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES")).to.throw(); + }); + + it("rejects a negative count", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES")).to.throw(); + }); + + it("rejects a TRIANGLES count that is not a multiple of 3", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES")).to.throw(); + }); + + it("rejects a non-typed-array source", function () { + expect(() => _native.MeshoptCodec.Decode(null, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); +}); + mocha.run((failures) => { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 817d3f06d..3eb048ca6 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -9,6 +9,12 @@ #include #include #include +#ifdef HAS_NATIVE_DRACO +#include +#endif +#ifdef HAS_NATIVE_MESHOPT +#include +#endif #include #include @@ -78,6 +84,12 @@ TEST(JavaScript, All) nativeCanvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); Babylon::Plugins::NativeEngine::Initialize(env); Babylon::Plugins::NativeEncoding::Initialize(env); +#ifdef HAS_NATIVE_DRACO + Babylon::Plugins::NativeDraco::Initialize(env); +#endif +#ifdef HAS_NATIVE_MESHOPT + Babylon::Plugins::NativeMeshopt::Initialize(env); +#endif auto setExitCodeCallback = Napi::Function::New( env, [&exitCodePromise](const Napi::CallbackInfo& info) { diff --git a/CMakeLists.txt b/CMakeLists.txt index cb1ce4b05..66f2e9879 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,14 @@ FetchContent_Declare(glslang GIT_REPOSITORY https://github.com/BabylonJS/glslang.git GIT_TAG 284e4301e5a6b44b279635276588db7cdd942624 EXCLUDE_FROM_ALL) +FetchContent_Declare(draco + GIT_REPOSITORY https://github.com/google/draco.git + GIT_TAG 1.5.7 + EXCLUDE_FROM_ALL) +FetchContent_Declare(meshoptimizer + GIT_REPOSITORY https://github.com/zeux/meshoptimizer.git + GIT_TAG v0.22 + EXCLUDE_FROM_ALL) FetchContent_Declare(googletest URL "https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz" EXCLUDE_FROM_ALL) @@ -117,6 +125,7 @@ option(BABYLON_NATIVE_CHECK_THREAD_AFFINITY "Checks thread safety in the graphic option(BABYLON_NATIVE_PLUGIN_EXTERNALTEXTURE "Include Babylon Native Plugin ExternalTexture." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAMERA "Include Babylon Native Plugin NativeCamera." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAPTURE "Include Babylon Native Plugin NativeCapture." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEDRACO "Include Babylon Native Plugin NativeDraco." OFF) option(BABYLON_NATIVE_PLUGIN_NATIVEENCODING "Include Babylon Native Plugin NativeEncoding." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE "Include Babylon Native Plugin NativeEngine." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_WEBP "Enable WebP image parsing in NativeEngine (via bimg)." ON) @@ -127,6 +136,7 @@ if(NOT BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) endif() option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_COMPILESHADERS "Include Babylon Native Plugin NativeEngine - Compile Shaders." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEINPUT "Include Babylon Native Plugin NativeInput." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT "Include Babylon Native Plugin NativeMeshopt." OFF) option(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS "Include Babylon Native Plugin NativeOptimizations." ON) option(BABYLON_NATIVE_PLUGIN_NATIVETRACING "Include Babylon Native Plugin NativeTracing." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEXR "Include Babylon Native Plugin XR." ON) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 5a41c1853..9f7a5a6f4 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -167,6 +167,82 @@ if(NOT TARGET glslang) set_property(TARGET SPIRV PROPERTY FOLDER Dependencies/glslang) endif() +# Assigns an IDE folder to every target a dependency declares, including those in +# nested directories. draco alone contributes ~25 internal object libraries; listing +# them by name would silently go stale whenever the dependency reorganizes its build, +# leaving targets ungrouped at the solution root. +function(group_directory_targets DIR FOLDER_NAME) + get_property(_targets DIRECTORY "${DIR}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(_target IN LISTS _targets) + # INTERFACE libraries have no build output and cannot carry a FOLDER. + get_target_property(_type ${_target} TYPE) + if(NOT _type STREQUAL "INTERFACE_LIBRARY") + set_property(TARGET ${_target} PROPERTY FOLDER "${FOLDER_NAME}") + endif() + endforeach() + + get_property(_subdirs DIRECTORY "${DIR}" PROPERTY SUBDIRECTORIES) + foreach(_subdir IN LISTS _subdirs) + group_directory_targets("${_subdir}" "${FOLDER_NAME}") + endforeach() +endfunction() + +# -------------------------------------------------- +# draco (mesh decompression, native replacement for the WASM decoder) +# -------------------------------------------------- +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO AND NOT TARGET draco AND NOT TARGET draco_static) + set(DRACO_TESTS OFF CACHE BOOL "" FORCE) + set(DRACO_INSTALL OFF CACHE BOOL "" FORCE) + set(DRACO_BUILD_EXECUTABLES OFF CACHE BOOL "" FORCE) + set(DRACO_JS_GLUE OFF CACHE BOOL "" FORCE) + + # Build only the features in the Draco glTF bitstream spec: mesh compression, normal + # encoding and the standard edgebreaker. This is the same subset Babylon.js decodes + # with, since its default configuration loads draco_decoder_gltf.wasm rather than the + # full decoder, so it costs no capability the JavaScript path has. It drops point cloud + # compression, the predictive edgebreaker and pre-glTF backwards compatibility. + set(DRACO_GLTF_BITSTREAM ON CACHE BOOL "" FORCE) + + FetchContent_MakeAvailable_With_Message(draco) + + # The combined draco library target is named `draco` for MSVC and + # `draco_static` for other toolchains (static build); `draco::draco` aliases + # whichever exists. + if(TARGET draco) + set(_draco_lib draco) + else() + set(_draco_lib draco_static) + endif() + + # draco adds its include paths as PRIVATE (see cmake/draco_targets.cmake + # draco_add_library), so linking draco::draco does not expose the draco/* + # headers to consumers. Re-export them as PUBLIC on the combined target. + # The generated draco/draco_features.h is emitted under CMAKE_BINARY_DIR + # (draco uses ${CMAKE_BINARY_DIR} as its generated-sources root). + target_include_directories(${_draco_lib} SYSTEM PUBLIC + "${draco_SOURCE_DIR}" + "${draco_SOURCE_DIR}/src" + "${draco_BINARY_DIR}" + "${CMAKE_BINARY_DIR}") + + # Assign IDE folders for every draco target, not just the combined library. + group_directory_targets("${draco_SOURCE_DIR}" Dependencies/draco) +endif() + +# -------------------------------------------------- +# meshoptimizer (mesh decompression, native replacement for the WASM decoder) +# -------------------------------------------------- +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT AND NOT TARGET meshoptimizer) + set(MESHOPT_BUILD_DEMO OFF CACHE BOOL "" FORCE) + set(MESHOPT_BUILD_GLTFPACK OFF CACHE BOOL "" FORCE) + set(MESHOPT_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(MESHOPT_WERROR OFF CACHE BOOL "" FORCE) + + FetchContent_MakeAvailable_With_Message(meshoptimizer) + + group_directory_targets("${meshoptimizer_SOURCE_DIR}" Dependencies/meshoptimizer) +endif() + # -------------------------------------------------- # googletest # -------------------------------------------------- diff --git a/Embedding/CMakeLists.txt b/Embedding/CMakeLists.txt index 20715f0bb..b2f704726 100644 --- a/Embedding/CMakeLists.txt +++ b/Embedding/CMakeLists.txt @@ -107,6 +107,16 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING) target_link_libraries(Embedding PRIVATE NativeEncoding) endif() +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEDRACO=1) + target_link_libraries(Embedding PRIVATE NativeDraco) +endif() + +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=1) + target_link_libraries(Embedding PRIVATE NativeMeshopt) +endif() + if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS=1) target_link_libraries(Embedding PRIVATE NativeOptimizations) diff --git a/Embedding/Source/Runtime.cpp b/Embedding/Source/Runtime.cpp index 852528288..c27945818 100644 --- a/Embedding/Source/Runtime.cpp +++ b/Embedding/Source/Runtime.cpp @@ -5,6 +5,12 @@ #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE #include #endif +#if BABYLON_NATIVE_PLUGIN_NATIVEDRACO +#include +#endif +#if BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT +#include +#endif #if BABYLON_NATIVE_PLUGIN_NATIVECAMERA #include #endif @@ -270,6 +276,12 @@ namespace Babylon::Embedding #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE Babylon::Plugins::NativeEngine::Initialize(env); #endif +#if BABYLON_NATIVE_PLUGIN_NATIVEDRACO + Babylon::Plugins::NativeDraco::Initialize(env); +#endif +#if BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT + Babylon::Plugins::NativeMeshopt::Initialize(env); +#endif #if BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS Babylon::Plugins::NativeOptimizations::Initialize(env); #endif diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 38b7aaa18..a5c82ee5d 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -10,18 +10,26 @@ if(BABYLON_NATIVE_PLUGIN_NATIVECAPTURE) add_subdirectory(NativeCapture) endif() +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + add_subdirectory(NativeDraco) +endif() + if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING) add_subdirectory(NativeEncoding) endif() if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE) add_subdirectory(NativeEngine) -endif() + endif() -if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) + if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) add_subdirectory(NativeInput) endif() +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + add_subdirectory(NativeMeshopt) +endif() + if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) add_subdirectory(NativeOptimizations) endif() diff --git a/Plugins/NativeDraco/CMakeLists.txt b/Plugins/NativeDraco/CMakeLists.txt new file mode 100644 index 000000000..2a596b74d --- /dev/null +++ b/Plugins/NativeDraco/CMakeLists.txt @@ -0,0 +1,17 @@ +set(SOURCES + "Include/Babylon/Plugins/NativeDraco.h" + "Source/NativeDraco.cpp") + +add_library(NativeDraco ${SOURCES}) +warnings_as_errors(NativeDraco) + +target_include_directories(NativeDraco + PUBLIC "Include") + +target_link_libraries(NativeDraco + PUBLIC napi + PRIVATE JsRuntimeInternal + PRIVATE draco::draco) + +set_property(TARGET NativeDraco PROPERTY FOLDER Plugins) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h b/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h new file mode 100644 index 000000000..6f56e1b94 --- /dev/null +++ b/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace Babylon::Plugins::NativeDraco +{ + // Exposes `_native.decodeDracoMesh(dataView, attributes?)`, a synchronous + // native replacement for Babylon's WebAssembly Draco decoder. Babylon.js + // routes its DracoDecoder to this function when it is present. + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Plugins/NativeDraco/README.md b/Plugins/NativeDraco/README.md new file mode 100644 index 000000000..aeb092c8d --- /dev/null +++ b/Plugins/NativeDraco/README.md @@ -0,0 +1,51 @@ +# NativeDraco + +> ⚠️ **This plugin is experimental and subject to change.** + +The NativeDraco plugin provides native [Draco](https://github.com/google/draco) geometry decompression to Babylon, so `KHR_draco_mesh_compression` glTF assets can be decoded without shipping and instantiating the Draco WebAssembly module. + +The plugin is **off by default**. Enable it with `-D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON`. + +## Limitations + +- **Decode only.** There is no encoder. This mirrors Babylon.js, whose default configuration loads `draco_decoder_gltf.wasm`; its encoder lives in a separate module (`draco_encoder.wasm`) that `DracoEncoder` fetches on demand. Linking Draco's encoder here would add roughly 1.2 MB to every binary to serve an authoring path Babylon Native does not exercise. +- **glTF bitstream only.** Draco is built with `DRACO_GLTF_BITSTREAM=ON`, matching the `draco3dgltf` package Babylon.js decodes with. That enables mesh compression, normal encoding and the standard edgebreaker, and excludes point clouds, the predictive edgebreaker, backwards compatibility with pre-1.0 streams, and attribute deduplication. A point-cloud stream decodes to an error rather than geometry. +- **No consumer yet.** Nothing in the pinned `babylonjs` package calls `_native.DracoCodec`. The grouping and the entry-point names have therefore not faced a real consumer and may still move. + +## Design + +The API is exposed as a single `DracoCodec` object on the `_native` global rather than as free functions, so that: + +1. **One feature probe.** JavaScript checks for the object once instead of once per entry point. +2. **The object can carry a version.** Draco's bitstream is versioned; a caller holding a stream this build is too old to read otherwise has no way to find out ahead of time. A bare function name cannot express that. + +```typescript +interface INative { + DracoCodec: { + Decode: ( + data: ArrayBufferView, + attributeIds?: { [kind: string]: number } + ) => { + indices: Uint32Array | null; + attributes: Array<{ + kind: string; + data: Float32Array | Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array; + size: number; + byteOffset: number; + byteStride: number; + normalized: boolean; + }>; + totalVertices: number; + }; + Version: string; + }; +} +``` + +`attributeIds` is the glTF path: a map of Babylon vertex-buffer kind to Draco unique attribute id, taken from the `KHR_draco_mesh_compression` extension. When it is omitted, attributes are identified by their Draco geometry type instead. + +`indices` is `null` for geometry that decodes to a point cloud rather than a triangular mesh. + +## Notes + +`Decode` reads the keys of `attributeIds` via `Object.keys` obtained from the global object, rather than `Napi::Object::GetPropertyNames()`. `GetPropertyNames()` is unusable on JavaScriptCore — the default engine on macOS and iOS — and the enumeration semantics differ across the non-V8 backends generally. See the comment in `Source/NativeDraco.cpp` and [JsRuntimeHost#216](https://github.com/BabylonJS/JsRuntimeHost/issues/216). diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp new file mode 100644 index 000000000..eb1b21c88 --- /dev/null +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -0,0 +1,246 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Babylon::Plugins +{ + namespace + { + // Enumerates an object's own keys. + // + // Deliberately not Napi::Object::GetPropertyNames(). JsRuntimeHost's JavaScriptCore + // backend implements napi_get_property_names by calling Object.getOwnPropertyNames with + // an argument count of zero, so the object under inspection is never passed and the call + // evaluates getOwnPropertyNames(undefined), which throws. That makes GetPropertyNames + // unusable on JavaScriptCore, which is the default engine on macOS and iOS. + // + // Supplying the missing argument would not be enough for a general fix: Node-API + // specifies the enumerable properties including the prototype chain (what V8 returns), + // whereas getOwnPropertyNames is own-only and includes non-enumerables. Chakra has the + // same enumerability mismatch and QuickJS omits the prototype chain, so this is a + // conformance gap across all three non-V8 backends. + // Tracked by https://github.com/BabylonJS/JsRuntimeHost/issues/216 - remove this helper + // in favour of GetPropertyNames() once that is fixed. + // + // Calling Object.keys through the global object goes through napi_call_function with the + // argument actually supplied, and behaves identically on every engine for the plain data + // objects this map is built from. + Napi::Array OwnPropertyNames(Napi::Env env, const Napi::Object& object) + { + const auto objectCtor = env.Global().Get("Object").As(); + const auto keys = objectCtor.Get("keys").As(); + return keys.Call(objectCtor, {object}).As(); + } + + // De-interleaves and tightly packs one Draco attribute's per-point values into a + // freshly allocated JS typed array of type T. This mirrors emscripten's + // GetAttributeDataArrayForAllPoints, which Babylon's WASM decoder relies on. + template + Napi::Value CopyAttributeData(Napi::Env env, const draco::PointCloud& pointCloud, const draco::PointAttribute& attribute) + { + const int8_t numComponents = attribute.num_components(); + const uint32_t numPoints = pointCloud.num_points(); + const size_t numValues = static_cast(numPoints) * numComponents; + + auto array = Napi::TypedArrayOf::New(env, numValues); + T* out = array.Data(); + + for (draco::PointIndex i(0); i < numPoints; ++i) + { + const draco::AttributeValueIndex valueIndex = attribute.mapped_index(i); + attribute.ConvertValue(valueIndex, numComponents, out + static_cast(i.value()) * numComponents); + } + + return array; + } + + // Builds the { kind, data, size, byteOffset, byteStride, normalized } record that + // Babylon's DracoDecoder consumes for each decoded vertex attribute. + Napi::Value DecodeAttribute(Napi::Env env, const draco::PointCloud& pointCloud, const draco::PointAttribute& attribute, const std::string& kind) + { + const int8_t numComponents = attribute.num_components(); + + Napi::Value data; + uint32_t bytesPerComponent = 0; + switch (attribute.data_type()) + { + case draco::DT_FLOAT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + case draco::DT_INT8: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 1; break; + case draco::DT_UINT8: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 1; break; + case draco::DT_INT16: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 2; break; + case draco::DT_UINT16: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 2; break; + case draco::DT_INT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + case draco::DT_UINT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + default: + throw Napi::Error::New(env, "Draco: Cannot decode invalid attribute data type " + std::to_string(attribute.data_type())); + } + + auto result = Napi::Object::New(env); + result.Set("kind", Napi::String::New(env, kind)); + result.Set("data", data); + result.Set("size", Napi::Number::New(env, numComponents)); + // GetAttributeDataArrayForAllPoints returns a tightly packed array, so the + // consumable buffer has offset 0 and a stride of one full vertex. + result.Set("byteOffset", Napi::Number::New(env, 0)); + result.Set("byteStride", Napi::Number::New(env, static_cast(numComponents) * bytesPerComponent)); + result.Set("normalized", Napi::Boolean::New(env, attribute.normalized())); + return result; + } + + Napi::Value DecodeDracoMesh(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + + if (info.Length() < 1 || !info[0].IsTypedArray()) + { + throw Napi::TypeError::New(env, "decodeDracoMesh: expected a typed array of compressed Draco data"); + } + + const auto typedArray = info[0].As(); + const auto* data = static_cast(typedArray.ArrayBuffer().Data()) + typedArray.ByteOffset(); + const size_t size = typedArray.ByteLength(); + + draco::DecoderBuffer buffer; + buffer.Init(data, size); + + draco::Decoder decoder; + const auto geometryTypeStatus = draco::Decoder::GetEncodedGeometryType(&buffer); + if (!geometryTypeStatus.ok()) + { + throw Napi::Error::New(env, geometryTypeStatus.status().error_msg()); + } + + std::unique_ptr geometry; + Napi::Value indices = env.Null(); + + switch (geometryTypeStatus.value()) + { + case draco::TRIANGULAR_MESH: + { + auto meshStatus = decoder.DecodeMeshFromBuffer(&buffer); + if (!meshStatus.ok()) + { + throw Napi::Error::New(env, meshStatus.status().error_msg()); + } + + std::unique_ptr mesh = std::move(meshStatus).value(); + + const uint32_t numFaces = mesh->num_faces(); + auto indicesArray = Napi::Uint32Array::New(env, static_cast(numFaces) * 3); + uint32_t* out = indicesArray.Data(); + for (uint32_t f = 0; f < numFaces; ++f) + { + const draco::Mesh::Face& face = mesh->face(draco::FaceIndex(f)); + out[f * 3 + 0] = face[0].value(); + out[f * 3 + 1] = face[1].value(); + out[f * 3 + 2] = face[2].value(); + } + indices = indicesArray; + + geometry = std::move(mesh); + break; + } + case draco::POINT_CLOUD: + { + auto pointCloudStatus = decoder.DecodePointCloudFromBuffer(&buffer); + if (!pointCloudStatus.ok()) + { + throw Napi::Error::New(env, pointCloudStatus.status().error_msg()); + } + geometry = std::move(pointCloudStatus).value(); + break; + } + default: + throw Napi::Error::New(env, "Draco: Cannot decode invalid geometry type"); + } + + auto attributes = Napi::Array::New(env); + uint32_t attributeCount = 0; + + if (info.Length() > 1 && info[1].IsObject()) + { + // glTF path: caller provides a map of Babylon vertex-buffer kind -> Draco unique id. + const auto attributeIds = info[1].As(); + const auto keys = OwnPropertyNames(env, attributeIds); + for (uint32_t i = 0; i < keys.Length(); ++i) + { + const auto kind = keys.Get(i).As().Utf8Value(); + const uint32_t id = attributeIds.Get(kind).As().Uint32Value(); + const draco::PointAttribute* attribute = geometry->GetAttributeByUniqueId(id); + if (attribute != nullptr) + { + attributes.Set(attributeCount++, DecodeAttribute(env, *geometry, *attribute, kind)); + } + } + } + else + { + // Standalone path: probe the standard named attributes. + const struct + { + const char* kind; + draco::GeometryAttribute::Type type; + } namedAttributes[] = { + {"position", draco::GeometryAttribute::POSITION}, + {"normal", draco::GeometryAttribute::NORMAL}, + {"color", draco::GeometryAttribute::COLOR}, + {"uv", draco::GeometryAttribute::TEX_COORD}, + }; + + for (const auto& named : namedAttributes) + { + if (geometry->GetNamedAttributeId(named.type) != -1) + { + attributes.Set(attributeCount++, DecodeAttribute(env, *geometry, *geometry->GetNamedAttribute(named.type), named.kind)); + } + } + } + + auto result = Napi::Object::New(env); + result.Set("indices", indices); + result.Set("attributes", attributes); + result.Set("totalVertices", Napi::Number::New(env, static_cast(geometry->num_points()))); + return result; + } + + } +} + +namespace Babylon::Plugins::NativeDraco +{ + void BABYLON_API Initialize(Napi::Env env) + { + auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; + + // Exposed as a single object rather than free functions so that the JavaScript side + // needs one feature probe instead of one per entry point, and so the object can carry + // the version of the codec built into this binary. A bare function name cannot express + // that, and Draco's bitstream is versioned, so a caller holding a stream this build is + // too old to read has no other way to find out ahead of time. + // + // Decode only, mirroring Babylon.js: its default configuration loads + // draco_decoder_gltf.wasm, and the encoder is a separate module fetched on demand by + // DracoEncoder. Linking the encoder here would add ~1.2 MB to every binary to serve an + // authoring path that Babylon Native does not exercise. + auto codec = Napi::Object::New(env); + codec.Set("Decode", Napi::Function::New(env, DecodeDracoMesh, "Decode")); + codec.Set("Version", Napi::String::New(env, draco::kDracoVersion)); + native.Set("DracoCodec", codec); + } +} diff --git a/Plugins/NativeMeshopt/CMakeLists.txt b/Plugins/NativeMeshopt/CMakeLists.txt new file mode 100644 index 000000000..a1014b648 --- /dev/null +++ b/Plugins/NativeMeshopt/CMakeLists.txt @@ -0,0 +1,17 @@ +set(SOURCES + "Include/Babylon/Plugins/NativeMeshopt.h" + "Source/NativeMeshopt.cpp") + +add_library(NativeMeshopt ${SOURCES}) +warnings_as_errors(NativeMeshopt) + +target_include_directories(NativeMeshopt + PUBLIC "Include") + +target_link_libraries(NativeMeshopt + PUBLIC napi + PRIVATE JsRuntimeInternal + PRIVATE meshoptimizer) + +set_property(TARGET NativeMeshopt PROPERTY FOLDER Plugins) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h b/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h new file mode 100644 index 000000000..12f004aed --- /dev/null +++ b/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace Babylon::Plugins::NativeMeshopt +{ + // Exposes `_native.decodeMeshopt(source, count, stride, mode, filter?)`, a + // synchronous native replacement for Babylon's WebAssembly meshopt decoder + // (zeux/meshoptimizer). Babylon.js routes its MeshoptCompression to this + // function when it is present. + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Plugins/NativeMeshopt/README.md b/Plugins/NativeMeshopt/README.md new file mode 100644 index 000000000..1f7786467 --- /dev/null +++ b/Plugins/NativeMeshopt/README.md @@ -0,0 +1,42 @@ +# NativeMeshopt + +> ⚠️ **This plugin is experimental and subject to change.** + +The NativeMeshopt plugin provides native [meshoptimizer](https://github.com/zeux/meshoptimizer) vertex and index buffer decompression to Babylon, so `EXT_meshopt_compression` glTF assets can be decoded without shipping and instantiating the meshoptimizer WebAssembly module. + +The plugin is **off by default**. Enable it with `-D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON`. + +## Limitations + +- **Decode only.** Encoding is an authoring-time concern that Babylon Native does not exercise. +- **No consumer yet.** Nothing in the pinned `babylonjs` package calls `_native.MeshoptCodec`. The grouping and the entry-point names have therefore not faced a real consumer and may still move. + +## Design + +The API is exposed as a single `MeshoptCodec` object on the `_native` global, for the same reasons as `DracoCodec`. Publishing the version matters more here: meshoptimizer stores its codec version in the first header byte, and a decoder **rejects** streams newer than it understands — returning an error rather than degraded output. Exposing the version lets the JavaScript side fall back before it tries. + +```typescript +interface INative { + MeshoptCodec: { + Decode: ( + source: ArrayBufferView, + count: number, + stride: number, + mode: "ATTRIBUTES" | "TRIANGLES" | "INDICES", + filter?: "NONE" | "OCTAHEDRAL" | "QUATERNION" | "EXPONENTIAL" + ) => Uint8Array; + Version: string; + }; +} +``` + +`Decode` mirrors the reference `meshopt_decoder.js` `decode()` helper exactly: it decodes into a buffer sized for `count` rounded up to a multiple of 4, applies the filter in place over that rounded count, then returns the first `count * stride` bytes. + +## Notes + +meshoptimizer validates `count` and `stride` with `assert()`, which compiles out in release builds — out-of-range values would be undefined behavior rather than a thrown error. The plugin therefore range-checks both before calling into the library: + +- `count` must not be negative. +- `stride` must be in `[1, 256]`. +- `ATTRIBUTES` requires a stride that is a multiple of 4. +- `TRIANGLES` and `INDICES` require a stride of exactly 2 or 4. diff --git a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp new file mode 100644 index 000000000..c9ae2f08b --- /dev/null +++ b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp @@ -0,0 +1,169 @@ +#include +#include + +#include + +#include + +// bgfx vendors its own copy of meshoptimizer under bgfx/3rdparty/meshoptimizer. It is reached +// as rather than , so it does not collide +// today, but the guard in Dependencies/CMakeLists.txt skips our FetchContent when a target named +// meshoptimizer already exists. If that ever resolves the other way we would decode with a +// different codec version than the one this file was written against, which fails as silent +// data corruption rather than a build break. Pin it. +static_assert(MESHOPTIMIZER_VERSION == 220, "NativeMeshopt expects meshoptimizer 0.22; check the include path and revalidate the decode paths before bumping."); + +#include +#include +#include +#include + +namespace Babylon::Plugins +{ + namespace + { + // MESHOPTIMIZER_VERSION is an integer like 220 meaning 0.22. Render it the way the + // project versions its own releases so the JavaScript side can compare it directly + // against the version a stream was produced with. + std::string MeshoptVersionString() + { + return std::to_string(MESHOPTIMIZER_VERSION / 1000) + "." + std::to_string((MESHOPTIMIZER_VERSION / 10) % 100); + } + // Native equivalent of MeshoptDecoder.decodeGltfBufferAsync: + // decodeMeshopt(source: Uint8Array, count, stride, mode, filter?) -> Uint8Array + // where mode is "ATTRIBUTES" | "TRIANGLES" | "INDICES" and filter (optional) + // is "NONE" | "OCTAHEDRAL" | "QUATERNION" | "EXPONENTIAL". Mirrors the + // reference meshopt_decoder.js decode() helper exactly: decode into a buffer + // sized for count rounded up to a multiple of 4, then apply the filter + // in-place over that rounded count, then return the first count*stride bytes. + Napi::Value DecodeMeshopt(const Napi::CallbackInfo& info) + { + const auto env = info.Env(); + + if (info.Length() < 4 || !info[0].IsTypedArray()) + { + throw Napi::TypeError::New(env, "Meshopt: decodeMeshopt(source, count, stride, mode, filter?) requires a source typed array."); + } + + const auto sourceArray = info[0].As(); + const auto* source = static_cast(sourceArray.ArrayBuffer().Data()) + sourceArray.ByteOffset(); + const size_t sourceSize = sourceArray.ByteLength(); + + const int64_t countIn = info[1].As().Int64Value(); + const int64_t strideIn = info[2].As().Int64Value(); + const std::string mode = info[3].As().Utf8Value(); + + // meshoptimizer validates these with assert(), which compiles out in release builds, + // so out-of-range values would be undefined behavior rather than a thrown error. + if (countIn < 0) + { + throw Napi::RangeError::New(env, "Meshopt: count must not be negative, got " + std::to_string(countIn)); + } + if (strideIn <= 0 || strideIn > 256) + { + throw Napi::RangeError::New(env, "Meshopt: stride must be in [1, 256], got " + std::to_string(strideIn)); + } + if (mode == "ATTRIBUTES") + { + if (strideIn % 4 != 0) + { + throw Napi::RangeError::New(env, "Meshopt: ATTRIBUTES stride must be a multiple of 4, got " + std::to_string(strideIn)); + } + } + else if (mode == "TRIANGLES" || mode == "INDICES") + { + if (strideIn != 2 && strideIn != 4) + { + throw Napi::RangeError::New(env, "Meshopt: " + mode + " stride must be 2 or 4, got " + std::to_string(strideIn)); + } + if (mode == "TRIANGLES" && countIn % 3 != 0) + { + throw Napi::RangeError::New(env, "Meshopt: TRIANGLES count must be a multiple of 3, got " + std::to_string(countIn)); + } + } + else + { + throw Napi::Error::New(env, "Meshopt: Unsupported decode mode: " + mode); + } + + const size_t count = static_cast(countIn); + const size_t stride = static_cast(strideIn); + + // Round count up to a multiple of 4 (the reference decoder over-allocates + // and runs the vertex filter over count4 elements). + const size_t count4 = (count + 3) & ~static_cast(3); + + // Guard the allocation size so a huge count cannot wrap size_t. + constexpr int64_t maxDecodedBytes = 1LL << 31; + if (static_cast(count4) * strideIn > maxDecodedBytes) + { + throw Napi::RangeError::New(env, "Meshopt: decoded size (" + std::to_string(count4) + " x " + + std::to_string(strideIn) + " bytes) exceeds the 2 GB limit."); + } + + std::vector temp(count4 * stride, 0); + + int result; + if (mode == "ATTRIBUTES") + { + result = meshopt_decodeVertexBuffer(temp.data(), count, stride, source, sourceSize); + } + else if (mode == "TRIANGLES") + { + result = meshopt_decodeIndexBuffer(temp.data(), count, stride, source, sourceSize); + } + else + { + result = meshopt_decodeIndexSequence(temp.data(), count, stride, source, sourceSize); + } + + if (result != 0) + { + throw Napi::Error::New(env, "Meshopt: Malformed buffer data: " + std::to_string(result)); + } + + if (info.Length() > 4 && info[4].IsString()) + { + const std::string filter = info[4].As().Utf8Value(); + if (filter == "OCTAHEDRAL") + { + meshopt_decodeFilterOct(temp.data(), count4, stride); + } + else if (filter == "QUATERNION") + { + meshopt_decodeFilterQuat(temp.data(), count4, stride); + } + else if (filter == "EXPONENTIAL") + { + meshopt_decodeFilterExp(temp.data(), count4, stride); + } + else if (filter != "NONE") + { + throw Napi::Error::New(env, std::string("Meshopt: Unsupported decode filter: ") + filter); + } + } + + const size_t outSize = count * stride; + auto output = Napi::Uint8Array::New(env, outSize); + std::memcpy(output.Data(), temp.data(), outSize); + return output; + } + } +} + +namespace Babylon::Plugins::NativeMeshopt +{ + void BABYLON_API Initialize(Napi::Env env) + { + auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; + + // Grouped for the same reasons as DracoCodec. Version matters more here: meshoptimizer + // stores its codec version in the first header byte and a decoder rejects streams newer + // than it understands, returning an error rather than degraded output. Publishing the + // version lets the JavaScript side fall back before it tries. + auto codec = Napi::Object::New(env); + codec.Set("Decode", Napi::Function::New(env, DecodeMeshopt, "Decode")); + codec.Set("Version", Napi::String::New(env, MeshoptVersionString())); + native.Set("MeshoptCodec", codec); + } +}