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.
03af819 to
e34f68b
Compare
|
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.
|
|
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 ( |
|
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();
|
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. |
…cope closes (#223) [Updated by Copilot on behalf of @bghgary] `napi_escape_handle` inserted the escaped handle at the scope start index so it would live in the parent scope, but `napi_close_escapable_handle_scope` recomputed `scope_start` from the token and called `resize(scope_start)`, freeing the very handle the close was supposed to preserve. Every caller of `napi_escape_handle` got a dangling `napi_value` back. This is reachable from ordinary code, not just direct N-API use. `Napi::ObjectReference::Get` uses an `EscapableHandleScope` and `Napi::Error::Message()` / `what()` are built on it, so reading the message of a native error on QuickJS was a heap-use-after-free. `Napi::FunctionReference::Call` and `MakeCallback` escape as well, which puts every WebSocket, `setTimeout`, `XMLHttpRequest` and `AbortSignal` callback on this path: instrumenting `napi_escape_handle` counted ~201 escapes in a single `JavaScript.All` run with no escape-specific test in scope. **How it was found.** BabylonNative [#1835](BabylonJS/BabylonNative#1835) adds tests that make a native module throw. `ExternalCallback::Callback` calls `e.what()` when there is no pending QuickJS exception, walking straight into the freed handle; its `Ubuntu_Clang_QuickJS` job segfaulted while every other engine and platform passed. ``` #0 ToJSValue js_native_api_quickjs.cc:302 #3 Napi::Error::Message #4 Napi::Error::what #5 ExternalCallback::Callback js_native_api_quickjs.cc:164 freed by: #1 napi_close_escapable_handle_scope js_native_api_quickjs.cc:1939 #2 Napi::ObjectReference::Get ``` ## The change Each open escapable scope gets a record on the env, keyed by a monotonic counter that is handed out as the opaque token. The escaped handle lives in that record until `napi_close_escapable_handle_scope` pushes it onto the handle stack, once the scope's own handles are gone; it lands at `scope_start`, in the parent scope, so it outlives the close. The token is a counter rather than a position because two escapable scopes opened with no handle allocated between them occupy the same position. Keyed on that, their escaped handles collide and the second scope to escape is refused with `napi_escape_called_twice` having never escaped. The handle stack is never modified in the middle, which matters: inserting at `scope_start` shifts every entry above it and invalidates the recorded start of any nested scope still open, reintroducing the same dangling value by a different route. A close whose recorded start is past the end of the stack now reports `napi_handle_scope_mismatch` rather than resizing, which previously grew the stack with null entries for the next close to dereference. Env teardown frees handles still held for scopes that were never closed. `napi_open_handle_scope` keeps its position-derived token: a position is all a regular scope needs, and its comment now says not to key per-scope state on it, which is the mistake the escapable version made. ## Chakra and JavaScriptCore Both returned the escapee without tracking scopes, so neither could report `napi_escape_called_twice`. Both now track open escapable scopes; values there are rooted independently of any scope, so this is the error contract only. That removes the need for `JSRUNTIMEHOST_NAPI_ESCAPE_HANDLE_IS_PASSTHROUGH`, so `SecondEscapeIsRejected` runs on every backend rather than being compiled out on two of them. ## Testing Four tests in `Tests/UnitTests/Shared/Shared.cpp`: - `EscapedHandleOutlivesItsScope` — reproduces the original `heap-use-after-free` under ASan without the fix. - `NestedEscapableScopesBothEscape` — fails on every run against the pre-fix implementation. - `SecondEscapeIsRejected` — the `napi_escape_called_twice` contract. - `AdjacentEscapableScopesEscapeIndependently` — two scopes with no handle allocated between them; confirmed to fail against the position-derived token and pass with the counter. Each test closes its escapable scopes on every exit path. Leaving one open made the enclosing `Napi::HandleScope` fail to close, and `Napi::Error::Fatal` throws from a destructor that is implicitly `noexcept`, so a failing assertion terminated the process instead of reporting `FAILED`. Verified locally at this head on Windows Release: QuickJS 10/10 and Chakra 10/10. V8, JavaScriptCore and Hermes are covered by CI. The BabylonNative #1835 end-to-end run (clang + QuickJS + RelWithDebInfo, changing only this dependency: `master` gives exit 139, 1, 139; this branch gives exit 0 × 5, clean 16/16) was made against `6238b5ab`, before the scope-identity change. --------- Co-authored-by: Branimir Karadzic <branimirkaradzic@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Gary Hsu <bghgary@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09 Copilot-Session: c26bf58d-8462-4ea4-908d-67d366b657c5
8c53b98 to
fa7fe62
Compare
|
CI is green now (34/34). Rebased on master and added one commit that is not Draco work, so flagging it explicitly:
Fixed upstream in BabylonJS/JsRuntimeHost#223, now merged, so this bumps the Verified locally on Linux/Clang/QuickJS rather than relying on CI alone. With the old pin: and with the new one the same build runs 16/16 suites, 51 assertions, no crash. Happy to split the pin bump into its own PR if you would rather keep this one purely Draco, but on its own it would have nothing to demonstrate. |
… unit tests) # Conflicts: # CMakeLists.txt # Dependencies/CMakeLists.txt # Plugins/NativeDraco/Source/NativeDraco.cpp
…merge The merge resolution took BabylonJS#1835's side of this file wholesale, which was not the comment-only change it appeared to be. It also re-added the bgfx uniform buffer sizing overrides that shotgun had deliberately removed (they violate bgfx's static_asserts) and dropped the Win32 NAPI_JAVASCRIPT_ENGINE=V8 default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
…n onto it The initial resolution of the BabylonJS#1835 merge took that PR's NativeDraco.cpp wholesale. That version hangs 12 Playground tests (the Draco decode tests, the glTF Draco models, KHR_materials_volume, node geometry and the non-aligned vertex stride test), all of which pass with shotgun's implementation. A/B against both plugin versions confirmed shotgun's decode path is the working one. Restore shotgun's implementation and port over the three input validation behaviours that BabylonJS#1835 adds and its unit tests cover: * attribute/position lengths must be a multiple of the component count, and the component count must be positive * indices must be a Uint16Array or a Uint32Array * the index count must be a multiple of 3 and every index must address a real vertex Also takes BabylonJS#1835's unit tests and README, which replace the stale upstream assertion that no encoder is exposed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
0c71934 to
284ae97
Compare
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
- 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.
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.
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.
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.
284ae97 to
635ed90
Compare
Adds a native Draco encoder to the
NativeDracoplugin as_native.DracoCodec.Encode. The plugin already ships a native decoder, so this completes the pair and lets Babylon.js'sDracoEncoderuse a synchronous native path instead of fetching and instantiating thedraco_encoderWebAssembly module at runtime.Implementation. Deliberately mirrors Draco's own emscripten glue so the native and WASM paths agree:
AddAttributeToMesh<T>mirrorsPointCloudBuilder::AddAttribute<T>,AddTypedAttributeToMeshmirrors the WASM encoder'saddAttributeMap, andReadIndiceshandlesUint16Array/Uint32Arrayindex upload. The 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 view'sByteOffsetrather than assuming offset 0.DRACO_GLTF_BITSTREAMis switched off. That subset is not sufficient once the encoder is in play: it drops pre-glTF backwards compatibility (rejecting older streams with "Unsupported major version") and compiles out the attribute deduplication passes the encoder relies on. Building the full library costs roughly 1.2 MB.Risk. Purely additive —
DracoCodec.DecodeandVersionare untouched and nothing existing changes shape, so no Babylon.js change is required and the encoder sits unused until a JS consumer probes for it.BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ONis 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 run.Blocked on JsRuntimeHost#223. The sole red job,
Ubuntu_Clang_QuickJS, is not a defect here: the new tests make a native module throw, which walksNapi::Error::what()into a use-after-free in QuickJS'snapi_escape_handle. With only that dependency changed, this branch goes fromexit 139to a clean 16/16 across five runs. Needs a JsRuntimeHost pin bump once #223 lands.