Add a native Draco encoder to the NativeDraco plugin - #1835
Add a native Draco encoder to the NativeDraco plugin#1835bkaradzic-microsoft wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a native Draco mesh encoder to the NativeDraco plugin so Babylon.js can synchronously encode via _native.DracoCodec.Encode instead of loading the WASM encoder at runtime.
Changes:
- Implement native mesh encoding path in
NativeDraco.cpp, including attribute upload, index handling, and option mapping (quantization/speed/method). - Expose
DracoCodec.Encodealongside existingDecode/Version. - Build full Draco (disable
DRACO_GLTF_BITSTREAM) to support encoding and required deduplication/back-compat behaviors.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| Plugins/NativeDraco/Source/NativeDraco.cpp | Adds encoder implementation and exports Encode on DracoCodec. |
| Dependencies/CMakeLists.txt | Disables DRACO_GLTF_BITSTREAM so full Draco features needed for encoding are built. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The plugin already exposes a native decoder as `_native.DracoCodec.Decode`.
This adds the matching encoder as `_native.DracoCodec.Encode`, so Babylon.js's
`DracoEncoder` has a native path instead of fetching and instantiating the
draco_encoder WebAssembly module at runtime.
The implementation mirrors Draco's own emscripten glue so the two paths agree:
* `AddAttributeToMesh` replicates `PointCloudBuilder::AddAttribute<T>`, creating
a de-interleaved per-point attribute and returning its attribute id (which
equals its unique id via `PointCloud::SetAttribute` -> `set_unique_id`).
* `AddTypedAttributeToMesh` dispatches on the typed array's element type,
mirroring the WASM encoder's `addAttributeMap`, and honors the typed array's
byte offset rather than assuming it views its buffer from 0.
* The returned `{ data, attributeIds }` shape matches what the WASM worker and
module paths already produce, so the JavaScript side needs no special casing
beyond the feature probe.
`DRACO_GLTF_BITSTREAM` is switched off. The glTF bitstream subset drops pre-glTF
backwards compatibility, so it rejects streams from older or full encoders with
"Unsupported major version", and it compiles out the attribute deduplication
passes the encoder relies on. Building the full library costs roughly 1.2 MB.
Both NativeDraco and NativeMeshopt are already built with `=ON` by every CI
workflow, so the new code is compiled and linked on all platforms.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
03af819 to
e34f68b
Compare
- ReadIndices now accepts only Uint16Array/Uint32Array and throws a TypeError for any other element type, which would otherwise be reinterpreted and silently produce a corrupt mesh. - Indices are stored as uint32_t end to end so a value above INT_MAX cannot wrap negative before reaching draco::PointIndex. - Reject an index count that is not a multiple of 3 rather than silently dropping a trailing partial triangle. - Reject an attribute length that is not a multiple of its component count, and a non-positive component count (which would divide by zero). - Assert the identity mapping that PointAttribute::Init establishes, so a future Draco change cannot silently fold distinct points onto one value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
The NativeDraco suite asserted that DracoCodec.Encode was undefined, which documented the absence of an encoder. Now that the plugin provides one, that assertion fails, so replace it with real coverage: - round trip an indexed mesh through Encode and Decode - encode an unindexed mesh - accept 32 bit indices - reject a non 16/32 bit index buffer, an index count that is not a multiple of three, an attribute length that is not a multiple of its component count, and a mesh with no position attribute The dist bundle is regenerated with the pinned toolchain so the diff matches the source change exactly.
|
The macOS failures were the it("does not expose an encoder", function () {
expect(_native.DracoCodec.Encode).to.equal(undefined);
});That was an accurate description of the old behavior, so this PR necessarily invalidates it. I replaced it with actual encoder coverage rather than just deleting it:
The last four exercise the validation added in response to the review comments above.
|
Encode built its result with Napi::Int8Array, so the encoded bytes came back signed. Draco output is a byte stream, so Uint8Array is the correct type and matches what Decode takes as input. Also fix the unindexed encode test. Without an index buffer the vertices are treated as a flat triangle list, so the vertex count has to be a multiple of three; the shared fixture is a four vertex quad, so that test now uses a single triangle.
|
CI caught two real problems in the new tests — thanks, both now fixed in b4bdc65. 1. Draco output is a byte stream, so 2. The unindexed encode test was wrong, not the code. Without an index buffer the vertices are taken as a flat triangle list, so the vertex count itself has to be a multiple of three. The shared fixture is a four-vertex quad, which correctly tripped the new The test now uses a single triangle. The validation behaved exactly as intended here. Verified locally on Win32 ( |
Two fixes for the encode path, both found by the Ubuntu QuickJS CI job segfaulting on the new tests. Reject indices that do not address a real vertex. Nothing checked index values against the vertex count, so an out of range index was stored in a face and then dereferenced by the deduplication passes and the encoder, reading past the end of the attribute buffers. This is reachable from script with a one line call, so it now throws instead. Also reject a non-positive position component count, which would otherwise divide by zero while computing the vertex count. Read typed array data through TypedArrayOf<T>::Data() rather than ArrayBuffer().Data() plus the byte offset. napi_get_typedarray_info already returns a pointer to the first element, so the manual offset arithmetic was redundant, and this no longer materializes a temporary ArrayBuffer handle purely to read a pointer. Verified locally on Win32 with both the default engine (50 passing) and QuickJS (40 passing), exit 0 in both cases.
|
The Finding it. CI stdout is block buffered, so the trailing output was lost with the crash. Diffing the printed test list against the passing Two fixes:
Verified locally on Win32 against both engines: default 50 passing, QuickJS 40 passing, exit 0 in both cases. I built a local QuickJS configuration specifically to reproduce this. |
|
Update on the
The tests added here make a native module throw, and Fix is up as BabylonJS/JsRuntimeHost#223. With that branch patched in, this PR's So this PR is blocked on JsRuntimeHost#223 landing plus a submodule bump. Every other job is green. Happy to either wait for the bump, or land this with the known-external red job if you would rather not serialize them. |
|
CI status update: re-ran the failed jobs, and the That one job is the external JsRuntimeHost use-after-free described above, not anything in this PR. The fix, BabylonJS/JsRuntimeHost#223, is now 24/24 green and ready for review. Once it lands and the JsRuntimeHost dependency here is bumped, this PR should be fully green — verified locally by building this branch against the fix with clang + QuickJS on Ubuntu, which gives 50 passing, exit 0, and 0 ASan errors. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Plugins/NativeDraco/Source/NativeDraco.cpp:484
- The native result uses
Uint8Array, but Babylon.js'sIDracoEncodedMeshDatacontract and existing WASM/module encoder path returnInt8Array. Because this entry point is intended as a drop-in native path, this changes observable behavior (instanceofand the declared return type), and the new test currently locks in the mismatch. Return anInt8Arrayhere and update the source/generated test expectation accordingly.
auto encodedData = Napi::Uint8Array::New(env, buffer.size());
Plugins/NativeDraco/Source/NativeDraco.cpp:514
- Exposing
Encodewhile building full Draco leavesPlugins/NativeDraco/README.md:5-13and its API declaration at lines 23-41 materially incorrect: they still describe a decode-only, glTF-bitstream-only plugin and omitEncode. Update that documentation as part of this API change so consumers are not told the new capability is unavailable.
codec.Set("Encode", Napi::Function::New(env, EncodeDracoMesh, "Encode"));
Plugins/NativeDraco/Source/NativeDraco.cpp:462
- This comment says the decoder is built with
DRACO_GLTF_BITSTREAM, but this PR explicitly sets that option toOFF. Keep the macro guards for externally supplied Draco targets, but describe the conditional case rather than the configuration used by this build.
// Mirror Encoder::EncodeMeshToDracoBuffer. The deduplication passes are compiled out by
// DRACO_GLTF_BITSTREAM (the glTF bitstream subset the decoder is built with does not need
// them), so guard them on the feature macros draco publishes. They only shrink the encoded
// output; skipping them still produces a valid stream.
Plugins/NativeDraco/Source/NativeDraco.cpp:245
- The added tests only pass typed arrays whose views start at byte offset zero, so the byte-offset behavior this helper specifically introduces is unverified. Add an encode/decode test using position and/or index subviews with non-zero
byteOffset; otherwise a backend-specificTypedArrayOf<T>::Data()regression could silently encode preceding buffer data.
This issue also appears in the following locations of the same file:
- line 459
- line 484
- line 514
const T* TypedArrayData(const Napi::TypedArray& array)
{
return array.As<Napi::TypedArrayOf<T>>().Data();
Encode returned a Uint8Array, but Babylon.js's IDracoEncodedMeshData declares data as Int8Array -- the WASM encoder hands back a view onto emscripten's signed HEAP8. The bytes are the same either way, but this entry point is meant to be a drop-in for that path, so the view type has to match what callers type-check against. The README still described a decode-only, glTF-bitstream-only plugin: it omitted Encode entirely and stated the opposite of the bitstream option this PR actually sets. Rewrite those sections and add the Encode declaration. Fix the stale comment claiming the decoder is built with DRACO_GLTF_BITSTREAM to describe the conditional case instead, since the guards are there for externally supplied draco targets. Adds a test encoding position and index subviews with a non-zero byteOffset, preceded by deliberately wrong padding, so that a TypedArrayData regression that read from the start of the backing ArrayBuffer would fail the round trip rather than pass silently.
|
Addressed the four comments in the collapsed Suppressed comments block of the Copilot review in
README was materially wrong. Correct — it still described a decode-only, glTF-bitstream-only plugin, omitted Stale No test with a non-zero 41/41 unit tests pass locally. On the remaining CI failure: |
|
The What happens. Every test passes, then the process segfaults on the way out: The backtrace points somewhere quite specific: Frame 8 is the shim's catch-all, which calls inline MaybeOrValue<Napi::Value> ObjectReference::Get(const char* utf8name) const {
EscapableHandleScope scope(_env);
...
return scope.Escape(result);
}The escaped handle is freed when that scope closes, so reading it afterwards dereferences freed memory. At the crash the JSValue has This PR triggers it because the encoder tests ( Verified by A/B. Same tree, same flags as CI (clang, QuickJS, RelWithDebInfo, no sanitizers), only the JsRuntimeHost dependency changed:
So this job should go green once #223 lands; nothing to change here. Happy to rebase once it does. |
What
Adds a native Draco encoder to the
NativeDracoplugin, exposed as_native.DracoCodec.Encode.The plugin already ships a native decoder (
_native.DracoCodec.Decode). This completes the pair, so Babylon.js'sDracoEncodercan use a synchronous native path instead of fetching and instantiating thedraco_encoderWebAssembly module at runtime.How
The implementation deliberately mirrors Draco's own emscripten glue so the native and WASM paths agree:
AddAttributeToMesh<T>PointCloudBuilder::AddAttribute<T>— creates a de-interleaved per-point attribute and returns its attribute id (which equals its unique id viaPointCloud::SetAttribute->set_unique_id)AddTypedAttributeToMeshaddAttributeMap— dispatches on the typed array's element typeReadIndicesUint16Array/Uint32ArrayThe returned
{ data, attributeIds }shape matches what the existing WASM worker and module paths produce, so the JavaScript side needs no special-casing beyond its feature probe.TypedArrayData<T>honors the typed array'sByteOffsetrather than assuming the view starts at offset 0 of its backing buffer — worth calling out since that is an easy thing to get wrong with sub-views.DRACO_GLTF_BITSTREAMis switched offThe glTF bitstream subset is not sufficient once the encoder is in play:
Building the full library costs roughly 1.2 MB.
Risk / coverage
DracoCodec.DecodeandDracoCodec.Versionare untouched, and nothing existing changes shape.BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ONandBABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ONare already set by every CI workflow (win32, uwp, linux, macos, ios, android), so this code is compiled and linked on all platforms by this PR's own CI run.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com