Skip to content

Add native Draco and meshopt codec plugins - #1797

Open
bkaradzic-microsoft wants to merge 9 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/native-codec-plugins
Open

Add native Draco and meshopt codec plugins#1797
bkaradzic-microsoft wants to merge 9 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/native-codec-plugins

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Jul 28, 2026

Copy link
Copy Markdown
Member

Adds native C++ implementations of the Draco and meshopt mesh decoders as two
new optional plugins, NativeDraco and NativeMeshopt.

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

Plugin Exposes Replaces
NativeDraco _native.DracoCodec.Decode(data, attributeIds?) draco_decoder_gltf.wasm
NativeDraco _native.DracoCodec.Version
NativeMeshopt _native.MeshoptCodec.Decode(source, count, stride, mode, filter?) meshopt_decoder.wasm
NativeMeshopt _native.MeshoptCodec.Version

Each 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.Decode supports both call shapes the Babylon loaders use: the glTF
path, where the caller passes the EXT/KHR_draco_mesh_compression map of
Babylon vertex-buffer kind to Draco unique id, and the standalone .drc path,
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 GetAttributeDataArrayForAllPoints that the WASM decoder relies
on, so the returned buffers are drop-in equivalents.

Dependencies

Two new FetchContent dependencies, both pinned to release tags:

  • google/draco @ 1.5.7
  • zeux/meshoptimizer @ v0.22

Both are declared EXCLUDE_FROM_ALL and built with tests, install, executables
and JS glue disabled. draco adds its include directories as PRIVATE, so
Dependencies/CMakeLists.txt re-exports them as PUBLIC on the combined
target; the target is named draco under MSVC and draco_static elsewhere and
both spellings are handled.

draco is additionally built with DRACO_GLTF_BITSTREAM=ON, which compiles only
mesh compression, normal encoding and the standard edgebreaker. That is the
same subset draco_decoder_gltf.wasm is built with, and that module is what
Babylon.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 OFF

Both options default to OFF. Nothing routes to these entry points until the
Babylon.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:

configuration Playground.exe delta
both OFF (default) 11,100,672 B baseline
NativeMeshopt only 11,132,928 B +31.5 KB
both ON 11,734,528 B +619 KB (+5.7%)

meshoptimizer 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:

  • No encoder. Babylon.js does not bundle one either — DracoDecoder loads
    draco_decoder_gltf.wasm and DracoEncoder fetches a separate
    draco_encoder.wasm on demand. Linking the encoder here would cost every
    binary ~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:

  • 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 this is
    net-new capability rather than a relocation of existing capability.
  • It removes a runtime fetch of executable code from a CDN, since no .wasm
    ships 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.cpp that initialize them, the UnitTests wiring for
the new coverage, and the three CI workflows that enable the plugins for the
jobs which run UnitTests. No existing plugin, engine path, or test
configuration is touched, and Apps/Playground/Scripts/config.json is
deliberately left alone.

On validation

These entry points are not reachable from the currently pinned babylonjs
npm 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/UnitTests
can, and now does.

Both plugins are linked and initialized in Apps/UnitTests behind their
options, and tests.javaScript.all.ts carries a describe block for each — 16
cases 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:

  • Draco — two fixtures from draco3dgltf 1.5.7, the same package
    Babylon.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_BITSTREAM actually restricts.
  • meshopt — a stream from the reference meshoptimizer 0.22.0 encoder,
    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 draco3dgltf decoder run over the same buffer, which
reports 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 decoder
correctly rejects with -1, so the encoder used to produce the fixture was
version-matched deliberately. That version is now also pinned at compile time
with a static_assert, because bgfx vendors its own copy of meshoptimizer and
the dependency guard skips our FetchContent when a target of that name
already exists — a mismatch there would surface as silent data corruption
rather than a build break.

  • No regression. Full validation suite on this branch:
    ran=295 passed=295 failed=0 missingRef=0 skipped=420, identical to the
    upstream master baseline. (Indices 53–57 are excluded from the run: they
    crash in RendererContextD3D11::submit on pristine master too.)
  • Unit tests. JavaScript.All — 42 passing, 0 failing.

Note for the Babylon.js side

dracoEncoder.ts on 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.

bkaradzic and others added 3 commits July 27, 2026 19:48
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
Copilot AI review requested due to automatic review settings July 28, 2026 03:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Embedding initialization.
  • Adds draco and meshoptimizer as 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.

Comment thread Plugins/NativeMeshopt/Source/NativeMeshopt.cpp
Comment thread Plugins/NativeDraco/Source/NativeDraco.cpp Outdated
Comment thread Plugins/NativeDraco/Source/NativeDraco.cpp Outdated
Comment thread Plugins/NativeDraco/Source/NativeDraco.cpp Outdated
Comment thread Plugins/CMakeLists.txt Outdated
…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
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Pushed 5d89056 addressing all five review comments plus the macOS/iOS build break.

Build break (macOS / iOS)

NativeDraco.cpp used long for vertex and face counts. That's 32-bit on MSVC but 64-bit on the Apple toolchains, so narrowing it to draco's uint32_t index types tripped -Werror,-Wshorten-64-to-32 at three sites. All three now use uint32_t directly. Windows/Linux/Android were green precisely because long is 32-bit there — worth noting for anyone reviewing similar code.

Plugin gating

You're right, and it mattered more than just convention. Both plugins now have BABYLON_NATIVE_PLUGIN_NATIVEDRACO / BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT options (default ON), wired through all four of the usual places — root CMakeLists.txt, Plugins/CMakeLists.txt, Embedding/CMakeLists.txt (option + compile definition + link), and the #if guards in Runtime.cpp.

I also gated the FetchContent blocks in Dependencies/CMakeLists.txt on the same options, since otherwise turning a plugin off would still pay for its dependency. Verified end to end: configuring with both OFF fetches neither draco nor meshoptimizer (no _deps entries for them) and Embedding still compiles.

Input validation

All four findings were real. Fixed and each one verified to now raise a JS exception rather than misbehave:

Input Before Now
Draco attribute size: 0 division by zero attribute 'size' must be in [1, 127], got 0
Draco attribute size: -3 negative component count same range error
attribute length not divisible by size silent truncation data length (4) is not a multiple of its component count (3)
index 99 with 3 vertices out-of-bounds read in the corner table builder index 99 is out of range for 3 vertices
index count 4 silently truncated to 1 face index count (4) is not a multiple of 3
Float32Array indices reinterpreted as Uint16Array indices must be a Uint16Array or a Uint32Array
meshopt stride: 0 zero-size allocation stride must be in [1, 256], got 0
meshopt count: -1 wrapped to a huge size_t count must not be negative, got -1
meshopt stride: 512 past the codec limit stride must be in [1, 256], got 512
meshopt ATTRIBUTES stride 6 not a multiple of 4 explicit error
meshopt TRIANGLES stride 12 must be 2 or 4 explicit error
unknown mode unchanged, still rejected

Worth calling out for the meshopt ones: meshoptimizer validates count/stride with assert(), which compiles out under NDEBUG, so these were undefined behavior in release builds rather than a caught error. The decoded size is now also bounded to 2 GB so count4 * stride can't wrap.

Range guards compare through uint64_t so they stay well-defined on 32-bit ABIs instead of turning into tautological comparisons.

Unindexed meshes now also require a vertex count that's a multiple of 3 rather than silently dropping the remainder.

Verification

  • 12/12 malformed inputs rejected with a clear message, 0 missed.
  • Draco encode -> decode round trip unchanged (both the attributeIds and named-probe paths).
  • meshopt decode still a byte-exact match.
  • Validation suite: ran=300 passed=300 failed=0 missingRef=0 skipped=420, same as the upstream baseline.

@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

CI is green across all 34 checks.

The four macOS/iOS jobs that were failing (MacOS, MacOS_QuickJS, MacOS_Xcode26, MacOS_Sanitizers, iOS_Xcode26, iOS_Installation, MacOS_Installation) all pass with the long -> uint32_t narrowing fix.

One note for the record: Win32_x64_D3D11 failed on the first run of this commit with a pixel diff on OpenPBR Analytic Lights Anisotropy — a test unrelated to this PR. It was a flake, and it passed on re-run. Evidence, in case it recurs for someone else:

  • The CI diff was 37.9% of pixels with the first mismatch reading (51, 51, 76) against an expected (1, 1, 1) — i.e. the frame rendered background where geometry should have been, rather than a subtly wrong shade.
  • Running the same test locally on this exact commit gives 0.524% (limit 2.5%), a comfortable pass.
  • The test pulls playground #GRQHVV#61 plus GUI assets and fonts from the snippet server, which the runner already calls out as a likely async asset-load timing flake.
  • The identical plugin code passed Win32_x64_D3D11 on the previous push, and nothing in this PR is reachable from that test.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 countIn values because count4 (a size_t) is narrowed to int64_t in the check. If count4 > 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 rounded count4 using 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 like NativeInput is nested under NativeEngine). Align the if/endif indentation 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
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Good catch — fixed in d784a88.

draco declares the combined library plus ~25 internal object libraries (draco_core, draco_mesh, draco_compression_*, draco_points_*, ...). I had only set FOLDER on draco / draco_static, so the other 25 landed at the top level of the solution, sitting right next to the Babylon Native projects.

Rather than hand-listing them — which would go stale the next time draco reorganizes its build — I added a small group_directory_targets helper that walks a directory's BUILDSYSTEM_TARGETS plus nested SUBDIRECTORIES and assigns the folder to every non-INTERFACE target. It's applied to both draco and meshoptimizer.

Verified against the regenerated solution:

Folder Targets
Dependencies/draco 26
Dependencies/meshoptimizer 1
Plugins (NativeDraco, NativeMeshopt) 2

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.

@bghgary bghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

Two comments inline. The code itself looks careful; both points are about what the change implies rather than what it does.

Comment thread CMakeLists.txt
Comment on lines +48 to +55
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)

@bghgary bghgary Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +81 to +88
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");
}

@bghgary bghgary Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +465 to +469
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"));

@bghgary bghgary Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bkaradzic and others added 3 commits July 29, 2026 07:53
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants