Add native Draco and meshopt codec plugins - #1797
Conversation
The WASM Draco decoder fails to instantiate in the full validation suite
(order-dependent LinkError after ~139 prior tests). Port the decoder to a
native C++ plugin per the project's no-WASM policy.
NativeDraco exposes _native.decodeDracoMesh(dataView, attributes?), a
synchronous decoder built on google/draco 1.5.7 (CMake FetchContent). It
emits the same { indices, attributes[], totalVertices } shape as the WASM
worker path (de-interleaved, tightly packed per-point values).
draco marks its include paths PRIVATE, so re-export them SYSTEM PUBLIC on
the combined target (draco/draco_static), including CMAKE_BINARY_DIR for the
generated draco/draco_features.h.
Un-excludes 7 Draco decode tests (202-206, 232, 233), all pass. Full
regression 373/373. Encoder-dependent tests (207, 217) stay WASM/excluded.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Extends the NativeDraco plugin with _native.encodeDracoMesh(attributes,
indices, options), a synchronous native replacement for the WASM Draco
encoder. Draco is now fully native (decode + encode); no Draco test
depends on WASM.
Faithfully replicates draco's emscripten encoder wrapper: builds a
draco::Mesh (SetNumFaces/SetFace), adds attributes via a templated
AddAttributeToMesh<T> (PointAttribute::Init + per-point SetAttributeValue),
applies quantization/method/speed options, then DeduplicateAttributeValues
+ DeduplicatePointIds + Encoder::EncodeMeshToBuffer. Returns { data,
attributeIds }. The returned attribute id equals draco's unique_id
(PointCloud::SetAttribute -> set_unique_id), so the decoder's
GetAttributeByUniqueId round-trips.
Un-excludes idx 207 (Decoder/Encoder roundtrip) and 217 (glTF serializer
KHR draco), both pass. Full regression 375/375.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Native replacement for the WASM meshopt decoder (zeux/meshoptimizer), exposing _native.decodeMeshopt(source, count, stride, mode, filter?). Mirrors the reference meshopt_decoder.js decode() helper: rounds count up to a multiple of 4, decodes via meshopt_decodeVertexBuffer/IndexBuffer/ IndexSequence, applies the optional Oct/Quat/Exp filter in-place, and returns the first count*stride bytes. FetchContent meshoptimizer v0.22. Un-excludes idx 234 (GLTF Buggy with Meshopt Compression). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
There was a problem hiding this comment.
Pull request overview
Adds two new native codec plugins (NativeDraco, NativeMeshopt) to replace the current WASM-based Draco/meshopt mesh decoding path in Babylon Native, wiring them into the Embedding runtime and build system via new FetchContent dependencies.
Changes:
- Introduces C++ N-API implementations for
_native.decodeDracoMesh,_native.encodeDracoMesh, and_native.decodeMeshopt. - Adds CMake targets for the new plugins and wires them into
Embeddinginitialization. - Adds
dracoandmeshoptimizeras FetchContent dependencies and configures their builds for use as libraries.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Plugins/NativeMeshopt/Source/NativeMeshopt.cpp | Implements native meshopt decode entry point exposed to JS. |
| Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h | Declares NativeMeshopt initialization API. |
| Plugins/NativeMeshopt/CMakeLists.txt | Builds/link-wires the NativeMeshopt plugin. |
| Plugins/NativeDraco/Source/NativeDraco.cpp | Implements native Draco decode + encode entry points exposed to JS. |
| Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h | Declares NativeDraco initialization API. |
| Plugins/NativeDraco/CMakeLists.txt | Builds/link-wires the NativeDraco plugin. |
| Plugins/CMakeLists.txt | Registers the new plugin subdirectories in the plugin build. |
| Embedding/Source/Runtime.cpp | Initializes the new plugins during runtime setup. |
| Embedding/CMakeLists.txt | Links the new plugins into the Embedding target. |
| Dependencies/CMakeLists.txt | Fetches/configures draco + meshoptimizer dependency targets. |
| CMakeLists.txt | Declares new FetchContent sources for draco and meshoptimizer. |
…rrowing Fixes the macOS/iOS build break and the review comments on BabylonJS#1797. Build fix: NativeDraco.cpp used `long` for vertex and face counts, which is 64-bit on the Apple toolchains and tripped -Werror,-Wshorten-64-to-32 when narrowed to draco's uint32_t index types. All three sites now use uint32_t directly. MSVC did not catch this because `long` is 32-bit there. Plugin gating: NativeDraco and NativeMeshopt were added unconditionally, unlike every other plugin. Both now follow the established convention with BABYLON_NATIVE_PLUGIN_NATIVEDRACO / _NATIVEMESHOPT options (default ON), wired through Plugins/CMakeLists.txt, Embedding/CMakeLists.txt and the Runtime.cpp initialization. The draco and meshoptimizer FetchContent dependencies are gated on the same options, so turning a plugin off also drops its dependency instead of paying the download and binary size. Verified: with both OFF, neither dependency is fetched and Embedding builds. Input validation: the entry points took caller-supplied sizes on trust. - Draco attribute `size` is validated to [1, 127] and checked to divide the attribute data length evenly. A size of 0 previously divided by zero. - Draco indices must be Uint16Array or Uint32Array, a multiple of 3 in length, and every index must address a real vertex. Out-of-range indices previously reached draco's corner table builder and read out of bounds. - Unindexed meshes must supply a vertex count that is a multiple of 3 rather than silently dropping the remainder. - meshopt `count` and `stride` are validated before use: count non-negative, stride in [1, 256], a multiple of 4 for ATTRIBUTES, exactly 2 or 4 for TRIANGLES/INDICES, TRIANGLES count a multiple of 3, and the decoded size bounded to 2 GB so count4 * stride cannot wrap size_t. meshoptimizer itself checks these with assert(), which compiles out in release builds. Range guards compare through uint64_t so they stay well-defined on 32-bit ABIs rather than becoming tautological. Verified all twelve malformed inputs now raise a JS exception instead of crashing, and the encode/decode round trip is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
|
Pushed 5d89056 addressing all five review comments plus the macOS/iOS build break. Build break (macOS / iOS)
Plugin gatingYou're right, and it mattered more than just convention. Both plugins now have I also gated the Input validationAll four findings were real. Fixed and each one verified to now raise a JS exception rather than misbehave:
Worth calling out for the meshopt ones: meshoptimizer validates Range guards compare through Unindexed meshes now also require a vertex count that's a multiple of 3 rather than silently dropping the remainder. Verification
|
|
CI is green across all 34 checks. The four macOS/iOS jobs that were failing ( One note for the record:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
Plugins/NativeMeshopt/Source/NativeMeshopt.cpp:84
- The decoded-size guard can be bypassed for very large
countInvalues becausecount4(asize_t) is narrowed toint64_tin the check. Ifcount4 > INT64_MAX, the cast becomes negative and the limit check can fail, potentially leading to a huge allocation (or wrap/DoS). Avoid narrowing casts and validate the roundedcount4using unsigned arithmetic before allocating.
const size_t count = static_cast<size_t>(countIn);
const size_t stride = static_cast<size_t>(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<size_t>(3);
// Guard the allocation size so a huge count cannot wrap size_t.
constexpr int64_t maxDecodedBytes = 1LL << 31;
if (static_cast<int64_t>(count4) * strideIn > maxDecodedBytes)
{
Plugins/CMakeLists.txt:25
- This block’s indentation is inconsistent with the surrounding plugin
if()/endif()sections (and currently makes it look likeNativeInputis nested underNativeEngine). Align theif/endifindentation to match the rest of the file for readability/maintenance.
if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE)
add_subdirectory(NativeEngine)
endif()
if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT)
draco declares the combined library plus ~25 internal object libraries, but only `draco` / `draco_static` were given a FOLDER property. The remaining 25 landed at the top level of the generated Visual Studio solution, next to the Babylon Native projects. Rather than hand-listing them, which would go stale the next time draco reorganizes its build, this adds a small `group_directory_targets` helper that walks a directory's BUILDSYSTEM_TARGETS (and nested SUBDIRECTORIES) and assigns the folder to each non-INTERFACE target. It is applied to draco and to meshoptimizer. Verified on the generated solution: all 26 draco targets are now under Dependencies/draco, meshoptimizer under Dependencies/meshoptimizer, and no target anywhere in the solution is left ungrouped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
|
Good catch — fixed in d784a88. draco declares the combined library plus ~25 internal object libraries ( Rather than hand-listing them — which would go stale the next time draco reorganizes its build — I added a small Verified against the regenerated solution:
and no target anywhere in the solution is left ungrouped — which wasn't true before this PR either, so this cleans up the tree slightly beyond just the draco additions. |
| 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) |
There was a problem hiding this comment.
[Reviewed by Copilot on behalf of @bghgary]
Both plugins default ON and the entry points aren't reachable from the pinned babylonjs yet, so every consumer carries draco's size (~25 internal object libraries, per the note in Dependencies/CMakeLists.txt) before anything can call it. Can we get a post-link delta against a baseline build?
Worth weighing against minidraco: pure TypeScript, ~23 KB brotli, reported bit-identical to the official wasm decoder and faster single-threaded (1.13x JavaScriptCore, 1.50x V8). That would cover Draco decode on all four engines with no native code. Caveats: its benchmarks are Chrome and bun, both JIT, while we also ship QuickJS and Hermes; and it defaults to a worker pool we have no equivalent for. If it holds up on the non-JIT engines, the decode half of this plugin is largely redundant.
It doesn't cover the rest: meshoptimizer's JS decoder embeds a wasm blob, so NativeMeshopt fills a real gap on QuickJS and Hermes; minidraco is decode-only, so encodeDracoMesh is unaffected; and it doesn't handle point clouds.
Also worth a line in the description: on V8 and JavaScriptCore this moves decoding of untrusted mesh data out of the WASM sandbox and into the host process; on QuickJS and Hermes there's no WebAssembly at all, so there it's net-new capability; and it drops a runtime fetch of executable code from a CDN, since no .wasm ships in this tree.
There was a problem hiding this comment.
Measured, and split per plugin. Playground, Win32 x64, RelWithDebInfo, same tree, only the two options changed:
| configuration | Playground.exe | delta |
|---|---|---|
| both OFF | 11,100,672 B | baseline |
NativeMeshopt only |
11,132,928 B | +31.5 KB |
| both ON | 12,857,344 B | +1.68 MB (+15.8%) |
So meshoptimizer is nearly free and draco is +1.64 MB, essentially all of it. Your instinct that this is a draco question specifically is right.
Both options now default to OFF (726c580). Nothing reaches these entry points until the Babylon.js side lands, so until then no consumer should pay for them, and the honest default is opt-in. CI turns both on for the three jobs that run UnitTests so the new coverage still executes everywhere.
On minidraco: I do not think it displaces this, but it may well displace part of it later, and I would rather not pretend I have data I do not have.
What it cannot cover, from your own list plus checking the tree: Encode (minidraco is decode-only, and DracoCodec.Encode is used by the exporter path), point clouds (this plugin decodes them, see the POINT_CLOUD branch), and NativeMeshopt, whose JS decoder embeds a wasm blob and so is the one that actually has to be native on QuickJS and Hermes. That last one is the +31.5 KB, which is not the part anyone is worried about.
What it might cover is Draco decode, which is exactly the +1.64 MB. The caveat you raised is the one that decides it: the published numbers are Chrome and bun, both JIT. Our QuickJS and Hermes configurations are not, and a pure-TypeScript entropy decoder is the workload where a non-JIT engine is worst. I have not benchmarked it, so I am not going to claim either way. If it holds up on the non-JIT engines it is clearly the better trade for decode and I would happily delete that half.
Defaulting to OFF means that question does not have to be settled to land this, and nobody pays 1.64 MB while it is open. Happy to file an issue to track the minidraco evaluation so it does not get lost.
Added to the description: on V8 and JavaScriptCore this moves decoding of untrusted mesh data out of the WASM sandbox and into the host process; on QuickJS and Hermes there is no WebAssembly at all, so it is net-new capability rather than a relocation; and it removes a runtime fetch of executable code from a CDN, since no .wasm ships in this tree. That cuts both ways, which is why the malformed and truncated input tests in 726c580 exist.
There was a problem hiding this comment.
Followed up on the binary size point: it is now +619 KB rather than +1.68 MB, and without needing minidraco.
Two changes, both of which make us more faithful to Babylon.js rather than less:
1. Decode only. Babylon.js does not bundle an encoder either — DracoDecoder loads draco_decoder_gltf.wasm, and DracoEncoder fetches a separate draco_encoder.wasm on demand. Exposing DracoCodec.Encode was linking the entire encoder into every binary to serve an authoring path Babylon Native does not exercise. DracoCodec.Encode is gone.
2. DRACO_GLTF_BITSTREAM=ON, which builds only mesh compression, normal encoding and the standard edgebreaker. This is exactly the subset draco_decoder_gltf.wasm is itself built with, so it costs no capability the JavaScript path has. It drops point cloud compression, the predictive edgebreaker and pre-glTF backwards compatibility; point cloud streams now return a Draco error instead of decoding, which is also what Babylon.js does with its default decoder.
Playground, Win32 x64, RelWithDebInfo:
| configuration | size | delta |
|---|---|---|
| both plugins OFF | 11,100,672 B | baseline |
| meshopt only | 11,132,928 B | +31.5 KB |
| both ON — before | 12,857,344 B | +1.68 MB (+15.8%) |
| both ON — after | 11,734,528 B | +619 KB (+5.7%) |
Draco alone went from 1.64 MB to 587 KB, a 65% reduction. That is close enough to minidraco's territory that I would rather not take on a fork, and it keeps us on the upstream bitstream.
Test consequence. The encode-based tests are replaced with fixtures from the reference draco3dgltf 1.5.7 encoder, which is strictly better evidence: the decoder is now pinned to the upstream bitstream instead of to our own encoder. I added a 63-vertex sphere fixture with POSITION + NORMAL + TEX_COORD so multi-attribute decoding and a real edgebreaker traversal are covered, which the two-triangle fixture could not do.
That fixture caught something worth recording. It decodes to 62 vertices and 91 triangles, not the 63/96 that went in — Draco merges points whose attributes all match and drops the degenerate triangles at the poles. I checked against the reference draco3dgltf decoder rather than just adjusting the number, and it reports 62 points / 91 faces for the same buffer. So the expected values in the test are the reference decoder's own output, and this decoder reproduces it exactly.
One coordination note: the Babylon.js side currently feature-detects _native.encodeDracoMesh in dracoEncoder.ts. With the encoder gone that probe is simply false and it falls back as it does today, so nothing breaks — but the paired Babylon.js PR should drop that hunk rather than ship a probe that can never succeed. Happy to restore the encoder behind an off-by-default option instead if you would rather keep the capability available.
UnitTests 42 passing, validation suite 295/295.
| 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"); | ||
| } |
There was a problem hiding this comment.
[Reviewed by Copilot on behalf of @bghgary]
787 lines of new parsing code with no automated coverage. The validation suite can't reach these entry points yet, as the description says, but Apps/UnitTests can: Tests.JavaScript.cpp runs JavaScript against _native.* and already initializes NativeEncoding for the same reason. Adding NativeDraco and NativeMeshopt there exercises decodeDracoMesh and decodeMeshopt directly, independent of the Babylon.js side landing.
Worth pinning the cases a manual Playground run can't repeat on every build: the encode/decode round trip, and malformed or truncated input for each entry point. The malformed cases matter most now that the codecs run in-process.
There was a problem hiding this comment.
Agreed, and done in 726c580. Apps/UnitTests was the right pointer: both plugins are now linked and initialized there behind their options, alongside NativeEncoding, so the entry points are exercised independently of the Babylon.js side.
tests.javaScript.all.ts gains a describe block per plugin, 17 cases, all passing locally:
NativeDraco — encode/decode round trip, malformed input, truncated input, empty buffer, encode without a position attribute, index out of range for the vertex count, non-positive attribute size, attribute data not a multiple of its component count.
NativeMeshopt — byte-for-byte decode of a reference stream, malformed input, truncated input, unknown mode, stride outside [1, 256], ATTRIBUTES stride not a multiple of 4, negative count, TRIANGLES count not a multiple of 3, non-typed-array source.
Two things worth flagging.
The meshopt positive test is not self-referential. NativeMeshopt exposes no encoder, so a round trip is not possible from JavaScript, and a decoder tested only against its own output or only against error paths can return garbage on valid input and still pass. Instead the fixture was produced by the reference meshoptimizer 0.22 JavaScript encoder (MeshoptEncoder.encodeVertexBuffer, 6 vertices at 16-byte stride) and the test asserts the native decoder reproduces the original bytes exactly. That pins us to the upstream bitstream.
The draco round trip immediately earned its place: DracoCodec.Encode was returning an Int8Array, so every byte above 127 surfaced as a negative number, and it disagreed with MeshoptCodec.Decode, which returns Uint8Array. Fixed in the same commit. That is precisely the class of bug a manual Playground run would not have caught.
The blocks report as skipped rather than passing when the build did not opt in, since both options now default to OFF. CI sets both to ON for the Linux, macOS and Win32 jobs that run UnitTests, so they genuinely execute rather than quietly disappearing — including under the sanitizer configurations, which is where the malformed-input cases are most valuable.
| void BABYLON_API Initialize(Napi::Env env) | ||
| { | ||
| auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; | ||
| native.Set("decodeDracoMesh", Napi::Function::New(env, DecodeDracoMesh, "decodeDracoMesh")); | ||
| native.Set("encodeDracoMesh", Napi::Function::New(env, EncodeDracoMesh, "encodeDracoMesh")); |
There was a problem hiding this comment.
[Reviewed by Copilot on behalf of @bghgary]
Worth settling the shape before Babylon.js feature-detects against it — after that the names are fixed in two repos at once.
Every other plugin puts a constructor on _native (Engine, Camera, DeviceInputSystem, NativeDataStream); NativeEncoding's EncodeImageAsync is the only free function, and these three follow it. Grouping — say _native.DracoCodec.decode/encode and _native.MeshoptCodec.decode — would match the DracoDecoder/MeshoptDecoder objects being replaced and collapse three feature probes into one.
Version is the part a bare name can't express. Per the description, meshoptimizer encodes its codec version in the first header byte and a v0.22 decoder rejects v0.23+ streams with -1 — a real compatibility window. An object can carry a version or capability field; a function name can't.
Two smaller ones: the casing matches neither existing pattern (both the class-style entries and EncodeImageAsync are PascalCase), and decodeMeshopt's mode and filter are validated strings rather than anything self-describing.
There was a problem hiding this comment.
Agreed on every point, and settled in 726c580 before Babylon.js binds to it.
_native.DracoCodec.Decode(data, attributeIds?)
_native.DracoCodec.Encode(attributes, indices?, options?)
_native.DracoCodec.Version // "1.5.7"
_native.MeshoptCodec.Decode(source, count, stride, mode, filter?)
_native.MeshoptCodec.Version // "0.22"
Grouped, so three feature probes collapse into two, and PascalCase to match both the class-style entries and EncodeImageAsync rather than inventing a third convention.
On version, your reasoning is what changed the design. A bare name cannot carry it, and meshopt is the case that proves it: the codec version is in the first header byte and a decoder returns an error for streams newer than it understands, so a caller that cannot see the version only finds out by failing. Both objects now publish Version, and there is a test asserting each is present and well-formed.
Two follow-ons from settling the shape:
Encode was returning an Int8Array. Now Uint8Array, matching MeshoptCodec.Decode and every other binary value crossing this boundary. Worth catching now for the same reason as the naming.
I also pinned the meshoptimizer version at compile time. bgfx vendors its own copy under bgfx/3rdparty/meshoptimizer, and the guard in Dependencies/CMakeLists.txt skips our FetchContent when a target named meshoptimizer already exists. Today the two are reached through different include paths so ours wins, but if that ever resolved the other way we would publish Version "0.22" while decoding with a different codec — silent data corruption rather than a build break. There is now a static_assert on MESHOPTIMIZER_VERSION.
On mode and filter being validated strings: you are right that they are not self-describing. I left them as strings deliberately, because they mirror the reference MeshoptDecoder.decodeGltfBufferAsync contract exactly, and the Babylon.js caller is porting from that decoder. Changing them to enums here would mean the native path and the WASM path disagree on their argument shape while both are live. If you would rather they were enumerated on the codec object, say the word and I will add them — it is a small change and better made now than after Babylon.js binds to it.
Addresses review feedback on the plugin's public surface, footprint and
lack of automated coverage.
API shape. The three entry points were free functions on `_native`
(`decodeDracoMesh`, `encodeDracoMesh`, `decodeMeshopt`), following
`NativeEncoding.EncodeImageAsync`. Every other plugin exposes an object,
and Babylon.js would otherwise need one feature probe per function. They
are now `_native.DracoCodec.Decode` / `.Encode` and
`_native.MeshoptCodec.Decode`, PascalCase to match the surrounding
convention, and each object carries a `Version` string. Version is the
part a bare function name cannot express: meshoptimizer stores its codec
version in the first header byte and a decoder refuses streams newer than
it understands, so the JavaScript side needs to see the version to fall
back before it tries. Settling this now avoids fixing the names in two
repositories at once.
`DracoCodec.Encode` returned its payload as an `Int8Array`, which
surfaces every byte above 127 as a negative number and disagrees with
`MeshoptCodec.Decode`. It now returns a `Uint8Array`. The new round-trip
test is what caught this.
Footprint. Both plugins defaulted to `ON`, so every consumer linked
draco before anything could reach the entry points. Both now default to
`OFF`. Measured on Playground, Win32 x64, RelWithDebInfo:
both OFF (new default) 11,100,672 bytes
NativeMeshopt only 11,132,928 bytes +31.5 KB
both ON 12,857,344 bytes +1.68 MB
so meshoptimizer is nearly free and draco accounts for +1.64 MB of the
+15.8% total.
Coverage. `Apps/UnitTests` can reach these entry points even though the
validation suite cannot yet, so both plugins are now linked and
initialized there behind the same options, and `tests.javaScript.all.ts`
grows a describe block for each: an encode/decode round trip for draco,
and for meshopt a byte-for-byte decode of a stream produced by the
reference meshoptimizer 0.22 JavaScript encoder, which pins the native
decoder against the upstream bitstream rather than against itself. Both
cover malformed, truncated and out-of-range input, which matters more now
that the codecs run in-process rather than inside the WASM sandbox. The
blocks report as skipped when the build did not opt in, rather than
passing vacuously. CI enables both options for the three jobs that run
UnitTests.
Finally, bgfx vendors its own copy of meshoptimizer, and the guard in
Dependencies/CMakeLists.txt skips our FetchContent when a target of that
name already exists. The two are reached through different include paths
today, but if that ever resolved the other way we would decode with a
different codec version than this file was written against, which fails
as silent data corruption rather than a build break, so it is now pinned
with a static_assert.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
The glTF decode path enumerated the caller's attributeIds map with 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. JavaScriptCore is the default engine on macOS and iOS, so that decode path could not have worked there. The new round trip test caught this: it is the first coverage to reach the entry point, and it failed on exactly the four JavaScriptCore jobs while every V8, Hermes and Chakra job passed. Enumerate with Object.keys through the global object instead, which goes through napi_call_function with the argument actually supplied and behaves the same on every engine for the plain data objects this map is built from. Fixing napi_get_property_names itself belongs in JsRuntimeHost and is filed separately; doing it here keeps this change from depending on a dependency bump. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
The QuickJS N-API backend in JsRuntimeHost does not surface Napi::Error messages: the seven negative tests that matched on message text failed on MacOS_QuickJS with "Uncaught C++ exception: " and an empty message, while passing on V8, Hermes, Chakra and JSC. Every pre-existing test in this file uses a bare to.throw() for exactly this reason, so follow that convention. Each assertion is still pinned to a specific invalid input, so what is under test is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Draco was 1.64 MB of the plugin's 1.68 MB cost, which is the size concern raised in review. Two changes cut that to 587 KB, a 65% reduction, without losing any capability the JavaScript path has: Drop the encoder. Babylon.js does not bundle one either: its default configuration loads draco_decoder_gltf.wasm, and DracoEncoder fetches a separate draco_encoder.wasm on demand. Exposing DracoCodec.Encode linked the whole encoder into every binary to serve an authoring path Babylon Native does not exercise. Set DRACO_GLTF_BITSTREAM, which builds only mesh compression, normal encoding and the standard edgebreaker. This is the same subset Babylon.js decodes with, since draco_decoder_gltf.wasm is itself the glTF-bitstream build, so no stream the JavaScript path accepts becomes undecodable. It drops point cloud compression, the predictive edgebreaker and pre-glTF backwards compatibility. Point cloud streams now fail with a Draco error instead of decoding, matching Babylon.js. Playground, Win32 x64, RelWithDebInfo: both plugins OFF 11,100,672 meshopt only 11,132,928 +31.5 KB both ON (before) 12,857,344 +1.68 MB (+15.8%) both ON (after) 11,734,528 +619 KB (+5.7%) The encoder tests are replaced by fixtures from the reference draco3dgltf 1.5.7 encoder, which pins the decoder to the upstream bitstream rather than to our own encoder. A 63-vertex sphere fixture covers multi-attribute decoding and a real edgebreaker traversal, which the two-triangle fixture cannot. Its expected counts are what the reference decoder reports for the same buffer: Draco merges points whose attributes all match and drops the degenerate pole triangles, so 63 vertices / 96 triangles encodes to 62 vertices / 91 triangles, and this decoder reproduces that exactly. UnitTests 42 passing, validation suite 295/295. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Adds native C++ implementations of the Draco and meshopt mesh decoders as two
new optional plugins,
NativeDracoandNativeMeshopt.Today Babylon Native runs these codecs through the same WASM modules the web
uses. That pulls a WASM runtime into a native app purely to decompress meshes,
costs a module instantiation per session, and copies buffers across the JS/WASM
boundary. These plugins let the native host do the work directly.
What's here
NativeDraco_native.DracoCodec.Decode(data, attributeIds?)draco_decoder_gltf.wasmNativeDraco_native.DracoCodec.VersionNativeMeshopt_native.MeshoptCodec.Decode(source, count, stride, mode, filter?)meshopt_decoder.wasmNativeMeshopt_native.MeshoptCodec.VersionEach codec is a single object rather than a set of free functions, so the
JavaScript side needs one feature probe per codec instead of one per entry
point, and so the object can publish the codec version compiled into the
binary. Version matters because meshoptimizer records its codec version in the
first header byte and a decoder refuses streams newer than it understands; a
bare function name gives a caller no way to find that out except by failing.
DracoCodec.Decodesupports both call shapes the Babylon loaders use: the glTFpath, where the caller passes the
EXT/KHR_draco_mesh_compressionmap ofBabylon vertex-buffer kind to Draco unique id, and the standalone
.drcpath,where no map is supplied and the plugin probes the standard named attributes.
Attribute data is de-interleaved and tightly packed per point, mirroring
emscripten's
GetAttributeDataArrayForAllPointsthat the WASM decoder relieson, so the returned buffers are drop-in equivalents.
Dependencies
Two new
FetchContentdependencies, both pinned to release tags:google/draco@1.5.7zeux/meshoptimizer@v0.22Both are declared
EXCLUDE_FROM_ALLand built with tests, install, executablesand JS glue disabled. draco adds its include directories as
PRIVATE, soDependencies/CMakeLists.txtre-exports them asPUBLICon the combinedtarget; the target is named
dracounder MSVC anddraco_staticelsewhere andboth spellings are handled.
draco is additionally built with
DRACO_GLTF_BITSTREAM=ON, which compiles onlymesh compression, normal encoding and the standard edgebreaker. That is the
same subset
draco_decoder_gltf.wasmis built with, and that module is whatBabylon.js's default configuration loads, so this costs no capability the
JavaScript path has. It drops point cloud compression, the predictive
edgebreaker and pre-glTF backwards compatibility; point cloud streams return a
Draco error instead of decoding, matching Babylon.js.
Footprint, and why both plugins default to
OFFBoth options default to
OFF. Nothing routes to these entry points until theBabylon.js side lands, so until then no consumer should be paying for them.
Measured on Playground, Win32 x64, RelWithDebInfo, same tree, only the two
options changed:
Playground.exeOFF(default)NativeMeshoptonlyONmeshoptimizer is nearly free; draco is +587 KB.
Draco started at +1.64 MB. Two things brought it down, both of which track
Babylon.js more closely rather than less:
DracoDecoderloadsdraco_decoder_gltf.wasmandDracoEncoderfetches a separatedraco_encoder.wasmon demand. Linking the encoder here would cost everybinary ~1.2 MB to serve an authoring path Babylon Native does not exercise.
DRACO_GLTF_BITSTREAM, as described above.Together that is a 65% reduction in draco's contribution, which is close enough
to what a minidraco fork would buy that staying on the upstream bitstream looks
like the better trade.
Trust boundary
Worth stating plainly, because it cuts both ways:
out of the WASM sandbox and into the host process.
net-new capability rather than a relocation of existing capability.
.wasmships in this tree.
That is the reason the input-validation and malformed/truncated-input tests
below exist, and why the codecs validate every caller-supplied extent rather
than trusting the calling loader.
Scope
The change is additive. Nothing existing is modified except the CMake
wiring that registers the new plugins, four lines in
Embedding/Source/Runtime.cppthat initialize them, theUnitTestswiring forthe new coverage, and the three CI workflows that enable the plugins for the
jobs which run
UnitTests. No existing plugin, engine path, or testconfiguration is touched, and
Apps/Playground/Scripts/config.jsonisdeliberately left alone.
On validation
These entry points are not reachable from the currently pinned
babylonjsnpm package (9.15.0), so the plugins are inert until the corresponding
Babylon.js side lands and the loaders start feature-detecting them. The
validation suite therefore cannot exercise this code, but
Apps/UnitTestscan, and now does.
Both plugins are linked and initialized in
Apps/UnitTestsbehind theiroptions, and
tests.javaScript.all.tscarries a describe block for each — 16cases covering decoding and malformed, truncated and out-of-range input for
every entry point. The blocks report as skipped when the build did not opt
in, rather than passing vacuously; CI enables both options for the Linux, macOS
and Win32 jobs that run
UnitTests, including the sanitizer configurations.Neither codec's positive test is self-referential. Since neither plugin exposes
an encoder, both fixtures come from the reference JavaScript encoders, which
pins these decoders to the upstream bitstreams rather than to themselves:
draco3dgltf1.5.7, the same packageBabylon.js takes its decoder from. One is a two-triangle mesh; the other is a
63-vertex sphere carrying POSITION, NORMAL and TEX_COORD, which covers
multi-attribute decoding and a non-degenerate edgebreaker traversal — the
parts
DRACO_GLTF_BITSTREAMactually restricts.with the test asserting the native decoder reproduces the original bytes
exactly.
The sphere fixture is worth a note. It decodes to 62 vertices and 91
triangles, not the 63 and 96 that were encoded: Draco merges points whose
attributes all match and drops the degenerate triangles at the poles. Rather
than adjust the expected numbers to whatever this decoder produced, they were
taken from the reference
draco3dgltfdecoder run over the same buffer, whichreports 62 points and 91 faces. This decoder reproduces that exactly.
meshoptimizer's vertex codec is versioned in the first header byte. Encoders
from 0.23.0 onward emit format version 1 (
0xa1), which a v0.22 decodercorrectly rejects with
-1, so the encoder used to produce the fixture wasversion-matched deliberately. That version is now also pinned at compile time
with a
static_assert, because bgfx vendors its own copy of meshoptimizer andthe dependency guard skips our
FetchContentwhen a target of that namealready exists — a mismatch there would surface as silent data corruption
rather than a build break.
ran=295 passed=295 failed=0 missingRef=0 skipped=420, identical to theupstream
masterbaseline. (Indices 53–57 are excluded from the run: theycrash in
RendererContextD3D11::submiton pristinemastertoo.)JavaScript.All— 42 passing, 0 failing.Note for the Babylon.js side
dracoEncoder.tson the Babylon.js side feature-detects_native.encodeDracoMesh.With no native encoder that probe is simply false and it falls back exactly as
it does today, so nothing breaks — but the paired Babylon.js change should drop
that hunk rather than ship a probe that can never succeed. If the capability is
wanted, the encoder can come back behind an off-by-default option.